feat(08-05-interaction-pattern-unification): 统一前端交互与反馈模式

这个提交包含在:
2026-08-07 06:50:40 +08:00
父节点 d4e9597b37
当前提交 94f1bad336
共修改 97 个文件,包含 784 行新增和 836 行删除
+1 -1
查看文件
@@ -19,7 +19,7 @@ const FEATURE_FLAG_DISABLED_MESSAGES: Record<string, string> = {
weekly_report_v1_disabled: "周报模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
employee_lifecycle_v1_disabled: "人员生命周期模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
wecom_hr_master_sync_v1_disabled: "企微 HR 主数据镜像当前未启用,请联系管理员检查环境开关与功能开关配置。",
todo_board_v1_disabled: "待办模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
todo_board_v1_disabled: "个人事项功能当前未启用,请联系管理员检查环境开关与功能开关配置。",
project_domain_v1_disabled: "项目模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
project_linking_v1_disabled: "项目关联功能当前未启用,请联系管理员检查环境开关与功能开关配置。",
project_communication_log_v1_disabled: "项目沟通回填当前未启用,请联系管理员检查环境开关与功能开关配置。",
+5 -5
查看文件
@@ -1,9 +1,9 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { searchGlobal, type GlobalSearchHit } from "../api/search";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
@@ -21,7 +21,7 @@ function entityLabel(entityType: string) {
if (entityType === "employee") return "人员";
if (entityType === "announcement") return "公告";
if (entityType === "weekly_report") return "周报";
if (entityType === "todo") return "待办";
if (entityType === "todo") return "我的事项";
if (entityType === "reimbursement") return "报销";
if (entityType === "leave") return "请假";
if (entityType === "purchase_request") return "采购";
@@ -116,12 +116,12 @@ export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOp
}, [searchQuery.data?.items]);
const message = useMemo(() => {
if (!trimmed) return "输入姓名、公告、项目、合同、资产、请假、报销、周报或待办关键字开始搜索。";
if (!trimmed) return "输入姓名、公告、项目、合同、资产、请假、报销、周报或事项关键字开始搜索。";
if (trimmed.length < 2) return "至少输入 2 个字符。";
if (searchQuery.isLoading) return "搜索中...";
if (searchQuery.isError) {
return isApiError(searchQuery.error)
? `${searchQuery.error.code}: ${mapApiErrorMessage(searchQuery.error.code, searchQuery.error.details, searchQuery.error.message)}`
? resolveUserErrorMessage(searchQuery.error)
: "搜索失败,请稍后重试";
}
if (!groupedItems.length) return "未找到匹配结果。";
@@ -137,7 +137,7 @@ export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOp
<div className="space-y-4">
<Input
autoFocus
placeholder="搜索人员、项目、合同、公告、资产、请假、报销、周报、待办"
placeholder="搜索人员、项目、合同、公告、资产、请假、报销、周报、事项"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
+1 -1
查看文件
@@ -47,7 +47,7 @@ export function StatusBadge({
status: string;
label?: string;
}) {
const config = STATUS_MAP[status] || { label: status, className: "badge-neutral" };
const config = STATUS_MAP[status] || { label: "未知状态", className: "badge-neutral" };
return (
<span className={cn(config.className, className)} {...props}>
@@ -1,7 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../../api/errorMessages";
import {
archiveHolidayCalendar,
createHolidayCalendar,
@@ -12,7 +12,6 @@ import {
type HolidayCalendarDayType,
type LegalEntityItem,
} from "../../api/masterData";
import { isApiError } from "../../api/types";
import { ConfirmDialog } from "../common/ConfirmDialog";
import { StatusBadge } from "../common/StatusBadge";
import { TableSkeleton } from "../common/TableSkeleton";
@@ -42,13 +41,7 @@ type DraftDay = {
};
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "操作失败,请稍后重试。";
return resolveUserErrorMessage(error, "操作失败,请稍后重试。");
}
function toEnabledFilter(value: "" | "true" | "false") {
@@ -1,7 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../../api/errorMessages";
import {
archiveSupplier,
createSupplier,
@@ -9,7 +9,6 @@ import {
type LegalEntityItem,
type SupplierItem,
} from "../../api/masterData";
import { isApiError } from "../../api/types";
import { ConfirmDialog } from "../common/ConfirmDialog";
import { StatusBadge } from "../common/StatusBadge";
import { TableSkeleton } from "../common/TableSkeleton";
@@ -26,13 +25,7 @@ type SupplierTabProps = {
};
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "操作失败,请稍后重试。";
return resolveUserErrorMessage(error, "操作失败,请稍后重试。");
}
function toEnabledFilter(value: "" | "true" | "false") {
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { resolveErrorMessage } from "../../lib/resolveErrorMessage";
import { Button } from "../ui/button";
type BarcodeScannerPanelProps = {
@@ -22,13 +23,6 @@ type BrowserWithBarcodeDetector = Window & {
const SCAN_FORMATS = ["qr_code", "code_128", "code_39", "ean_13", "ean_8", "upc_a", "upc_e"];
function resolveErrorMessage(error: unknown) {
if (error instanceof Error && error.message) {
return error.message;
}
return "无法启动相机扫码,请改用手工输入。";
}
export function BarcodeScannerPanel({ disabled = false, onDetected }: BarcodeScannerPanelProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const mediaStreamRef = useRef<MediaStream | null>(null);
@@ -166,9 +160,9 @@ export function BarcodeScannerPanel({ disabled = false, onDetected }: BarcodeSca
await startWithBarcodeDetector();
setIsStarting(false);
return;
} catch (detectorError) {
} catch {
stopScanner();
setError(`BarcodeDetector 启动失败,已回退到 ZXing:${resolveErrorMessage(detectorError)}`);
setError("");
}
}
@@ -176,7 +170,7 @@ export function BarcodeScannerPanel({ disabled = false, onDetected }: BarcodeSca
await startWithZxing();
} catch (fallbackError) {
stopScanner();
setError(resolveErrorMessage(fallbackError));
setError(resolveErrorMessage(fallbackError, "无法启动相机扫码,请改用手工输入。"));
}
setIsStarting(false);
+12 -3
查看文件
@@ -32,7 +32,7 @@ export const NAV_ITEMS: NavItem[] = [
{ to: "/announcements", label: "公告", icon: "campaign" },
{ to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_own", "project.view_all", "project.manage"] },
{ to: "/system/settings", label: "管理", icon: "settings", anyOf: MANAGEMENT_ROOT_PERMISSIONS },
{ to: "/workflow/tasks", label: "待我处理", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] },
{ to: "/workflow/tasks", label: "待我审批", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] },
{ to: "/contracts-registry", label: "合同", icon: "description", anyOf: ["project.view_all", "project.manage", "system.settings"] },
{ to: "/purchase-requests", label: "采购", icon: "shopping_cart", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
{ to: "/travel-requests", label: "差旅申请", icon: "flight", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] },
@@ -46,10 +46,14 @@ export const NAV_ITEMS: NavItem[] = [
const ACCESSIBLE_LABELS: Record<string, string> = {
"/reimbursements": "我的报销",
"/workflow/tasks": "待我处理",
"/workflow/tasks": "待我审批",
"/assets": "资产",
};
const PRODUCT_LABELS: Record<string, string> = {
"/workflow/tasks": "待我审批",
};
const TEAM_NAVIGATION_ROLE_CODES = ["manager", "ceo", "finance_mgr", "hr", "admin"];
const PAYMENT_REVIEWER_ROLE_CODES = ["manager", "ceo"];
@@ -177,7 +181,12 @@ function orderItems(items: ResolvedNavItem[], order: string[]) {
}
function toResolved(items: NavigationItem[]) {
return items.map((item) => ({ key: item.key, href: item.href, label: item.label, icon: item.icon }));
return items.map((item) => ({
key: item.key,
href: item.href,
label: PRODUCT_LABELS[item.href] ?? item.label,
icon: item.icon,
}));
}
function normalizeSections(sections: { main: NavigationItem[]; admin: NavigationItem[]; backup: NavigationItem[] }) {
@@ -9,38 +9,19 @@ import {
DialogTitle,
} from "../ui/dialog";
type WelcomeLinkItem = {
export type WelcomeLinkItem = {
icon: string;
label: string;
description: string;
to: string;
};
const WELCOME_LINKS: WelcomeLinkItem[] = [
{
icon: "fact_check",
label: "处理待办",
description: "先看今天需要审批或跟进的事项",
to: "/workflow/tasks",
},
{
icon: "request_quote",
label: "提交报销",
description: "录入票据和报销说明,查看进度",
to: "/reimbursements",
},
{
icon: "event_available",
label: "请假记录",
description: "日常请假走企业微信,OA 查看记录和状态",
to: "/leave-requests",
},
];
export function WelcomeDialog({
links,
open,
onClose,
}: {
links: WelcomeLinkItem[];
open: boolean;
onClose: () => void;
}) {
@@ -50,30 +31,32 @@ export function WelcomeDialog({
<DialogHeader>
<DialogTitle>常用入口</DialogTitle>
<DialogDescription>
你的账号已就绪。先从工作台处理待办,也可以直接进入常用业务。
你的账号已就绪。下面只展示当前账号可用的常用业务入口。
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 sm:grid-cols-3">
{WELCOME_LINKS.map((item) => (
<Link
key={item.label}
to={item.to}
onClick={onClose}
className="rounded-lg border border-zinc-200 bg-white px-4 py-4 shadow-sm transition-colors hover:bg-zinc-50"
>
<div className="flex h-full flex-col gap-3">
<div className="flex size-10 items-center justify-center rounded-md bg-primary/10 text-primary">
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
{links.length ? (
<div className="grid gap-3 sm:grid-cols-3">
{links.map((item) => (
<Link
key={item.label}
to={item.to}
onClick={onClose}
className="rounded-lg border border-zinc-200 bg-white px-4 py-4 shadow-sm transition-colors hover:bg-zinc-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
>
<div className="flex h-full flex-col gap-3">
<div className="flex size-10 items-center justify-center rounded-md bg-primary/10 text-primary">
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
</div>
<div className="min-w-0">
<div className="font-medium text-zinc-900">{item.label}</div>
<div className="mt-1 text-sm leading-6 text-zinc-500">{item.description}</div>
</div>
</div>
<div className="min-w-0">
<div className="font-medium text-zinc-900">{item.label}</div>
<div className="mt-1 text-sm leading-6 text-zinc-500">{item.description}</div>
</div>
</div>
</Link>
))}
</div>
</Link>
))}
</div>
) : null}
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-600">
关闭后不再显示这个提示;所有功能都可以从左侧菜单进入。
@@ -3,6 +3,10 @@ import { Link } from "react-router-dom";
import { cn } from "../../lib/utils";
import { Button } from "../ui/button";
import { WorkspaceHeader } from "./ListWorkspace";
import {
MOBILE_STICKY_ACTION_PAGE_CLASS,
MobileStickyActionBar,
} from "./MobileStickyActionBar";
import { SectionPanel } from "./SectionPanel";
export function BusinessEditTemplate({
@@ -45,7 +49,15 @@ export function BusinessEditTemplate({
) : null;
return (
<div className={cn("page-scaffold", bottomActions && "pb-24 md:pb-0", className)} data-testid={testId}>
<div
className={cn(
"page-scaffold",
bottomActions && MOBILE_STICKY_ACTION_PAGE_CLASS,
bottomActions && "md:pb-0",
className,
)}
data-testid={testId}
>
<WorkspaceHeader eyebrow={eyebrow} title={title} meta={meta} actions={headerActions} />
{validation ? <BusinessEditValidationSummary>{validation}</BusinessEditValidationSummary> : null}
{children}
@@ -149,14 +161,5 @@ export function BusinessEditActionBar({
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"mobile-sticky-action-surface fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur md:static md:inset-auto md:mt-5 md:rounded-lg md:border md:bg-white md:p-3 md:shadow-none",
className,
)}
>
<div className="mobile-sticky-action-grid md:flex md:justify-end md:gap-2">{children}</div>
</div>
);
return <MobileStickyActionBar className={className} inlineFromMedium>{children}</MobileStickyActionBar>;
}
@@ -0,0 +1,34 @@
import type { ReactNode } from "react";
import { cn } from "../../lib/utils";
export const MOBILE_STICKY_ACTION_PAGE_CLASS =
"mobile-fixed-action-page pb-[calc(var(--mobile-sticky-action-height,78px)+env(safe-area-inset-bottom,0px)+1.25rem)]";
export function MobileStickyActionBar({
children,
className,
contentClassName,
inlineFromMedium = false,
testId,
}: {
children: ReactNode;
className?: string;
contentClassName?: string;
inlineFromMedium?: boolean;
testId?: string;
}) {
return (
<div
className={cn(
"mobile-sticky-action-surface fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur",
inlineFromMedium && "md:static md:inset-auto md:mt-5 md:rounded-lg md:border md:bg-white md:p-3 md:shadow-none",
className,
)}
data-testid={testId}
>
<div className={cn("mobile-sticky-action-grid", inlineFromMedium && "md:flex md:justify-end md:gap-2", contentClassName)}>
{children}
</div>
</div>
);
}
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
@@ -5,8 +6,6 @@ import {
type PerformanceEmployeeReviewTimeline,
type PerformanceReviewDimensionType,
} from "../../api/performance";
import { mapApiErrorMessage } from "../../api/errorMessages";
import { isApiError } from "../../api/types";
import { StatusBadge } from "../common/StatusBadge";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
@@ -57,10 +56,7 @@ function buildFormState(review: PerformanceEmployeeReviewTimeline["items"][numbe
}
function errorMessage(error: unknown, fallback: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
function cycleStatusLabel(status: string) {
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -8,8 +9,6 @@ import {
type EmployeeSalaryProfileSnapshot,
type Payslip,
} from "../../api/payroll";
import { mapApiErrorMessage } from "../../api/errorMessages";
import { isApiError } from "../../api/types";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
@@ -17,10 +16,7 @@ import { toast } from "sonner";
import { StatusBadge } from "../common/StatusBadge";
function errorMessage(error: unknown, fallback: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
function todayDateString() {
@@ -1,8 +1,8 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateReimbursement } from "../../api/reimbursements";
import { isApiError } from "../../api/types";
import { mapApiErrorMessage } from "../../api/errorMessages";
import { useUnsavedChangesGuard } from "../common/UnsavedChangesGuard";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
@@ -58,7 +58,7 @@ export function ReimbursementHeaderEditor({
},
onError: (mutationError) => {
setError(isApiError(mutationError)
? mapApiErrorMessage(mutationError.code, mutationError.details, mutationError.message)
? resolveUserErrorMessage(mutationError)
: "保存报销信息失败,请重试");
},
});
@@ -1,13 +1,12 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../../api/errorMessages";
import {
bindInvoice,
fetchInvoicePoolList,
type InvoicePoolListItem,
} from "../../api/invoicePool";
import { isApiError } from "../../api/types";
import {
fetchTravelPaymentTrack,
markTravelOrderPaid,
@@ -22,13 +21,7 @@ import { Button } from "../ui/button";
import { TravelOrderMarkPaidDialog } from "./TravelOrderMarkPaidDialog";
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function freezeReasonFromAnomaly(code: string): "amount_mismatch" | "currency_mismatch" | "missing_order" | "duplicate" | "manual_review" {
@@ -1,7 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { mapApiErrorMessage } from '../../api/errorMessages';
import { useAuth } from '../../auth/AuthContext';
import { hasPermission } from '../../auth/permissions';
import { fetchCostCenters, fetchLegalEntities } from '../../api/masterData';
@@ -17,7 +17,6 @@ import {
type TravelReconciliationBatchStatus,
type TravelReconciliationItem,
} from '../../api/travelReconciliation';
import { isApiError } from '../../api/types';
import { TravelReconciliationBatchCard } from './TravelReconciliationBatchCard';
import { TravelReconciliationItemTable } from './TravelReconciliationItemTable';
import { CardSkeleton } from '../common/CardSkeleton';
@@ -33,13 +32,7 @@ import { Textarea } from '../ui/textarea';
const PAGE_SIZE = 10;
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return '请求失败,请稍后重试。';
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function currentMonthRange() {
+1 -1
查看文件
@@ -42,7 +42,7 @@ export type MobileNavItem = {
export const MOBILE_NAV_ITEMS: MobileNavItem[] = [
{ key: "home", to: "/mobile", label: "首页", icon: "home" },
{ key: "approvals", to: "/mobile/approvals", label: "待办", icon: "fact_check" },
{ key: "approvals", to: "/mobile/approvals", label: "待我审批", icon: "fact_check" },
{ key: "reimbursements", to: "/mobile/reimbursements", label: "报销", icon: "request_quote", anyOf: MOBILE_REIMBURSEMENT_PERMISSIONS },
{ key: "me", to: "/mobile/me", label: "我的", icon: "person" },
];
+2 -2
查看文件
@@ -23,13 +23,13 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
{ path: "/invoice-pool", title: "票据", group: "财务" },
{ path: "/inventory-checks", title: "盘点任务", group: "财务" },
{ path: "/weekly-reports", title: "周报", group: "业务" },
{ path: "/todos", title: "待办", group: "工作" },
{ path: "/todos", title: "我的事项", group: "工作" },
{ path: "/reimbursements/", title: "报销详情", group: "工作" },
{ path: "/reimbursements", title: "报销", group: "工作" },
{ path: "/leave-requests", title: "请假记录", group: "业务" },
{ path: "/assets", title: "资产", group: "业务" },
{ path: "/workflow/tasks/", title: "审批详情", group: "工作" },
{ path: "/workflow/tasks", title: "待我处理", group: "工作" },
{ path: "/workflow/tasks", title: "待我审批", group: "工作" },
{ path: "/notifications", title: "通知", group: "工作" },
{ path: "/mobile/assets/scan", title: "移动资产扫描", group: "移动工作台" },
{ path: "/mobile/approvals", title: "移动审批", group: "移动工作台" },
+3 -3
查看文件
@@ -43,7 +43,7 @@ export function getActionConfirmCopy(kind: ActionConfirmKind, context: ActionCon
const copies: Record<ActionConfirmKind, ActionConfirmCopy> = {
submit: {
title: withTarget("确认提交审批", targetName),
description: detail ?? "提交后将进入审批流程,系统会生成待办、通知审批人并写入提交历史。",
description: detail ?? "提交后将进入审批流程,系统会生成审批任务、通知审批人并写入提交历史。",
confirmLabel: "确认提交",
variant: "default",
},
@@ -85,13 +85,13 @@ export function getActionConfirmCopy(kind: ActionConfirmKind, context: ActionCon
},
batchApprove: {
title: `确认批量通过${count ? ` ${countText}` : ""}`,
description: detail ?? "系统会逐条处理所选待办;每条结果都会写入审批历史。",
description: detail ?? "系统会逐条处理所选审批任务;每条结果都会写入审批历史。",
confirmLabel: "确认批量通过",
variant: "default",
},
batchReject: {
title: `确认批量驳回${count ? ` ${countText}` : ""}`,
description: detail ?? "系统会逐条驳回所选待办;驳回意见会写入审批历史。",
description: detail ?? "系统会逐条驳回所选审批任务;驳回意见会写入审批历史。",
confirmLabel: "确认批量驳回",
variant: "destructive",
},
+10 -6
查看文件
@@ -2,27 +2,31 @@ import type { PrecheckDecision, PrecheckResponse } from "../api/feeControl";
function decisionLabel(decision: PrecheckDecision) {
if (decision === "pass") return "通过";
if (decision === "warn") return "预警";
if (decision === "manual_review") return "需人工复核";
return "阻断";
if (decision === "warn") return "可继续,但需确认提醒";
if (decision === "manual_review") return "需人工复核后继续";
return "暂不能提交";
}
export function buildPrecheckDialogMessage(precheck: PrecheckResponse) {
const lines = [`提交前校验结果:${decisionLabel(precheck.decision)}`];
if (precheck.matchedBudgetControlRule) {
lines.push(`命中规则:${precheck.matchedBudgetControlRule.name}(${precheck.matchedBudgetControlRule.controlMode})`);
lines.push(
`命中规则:${precheck.matchedBudgetControlRule.name}(${
precheck.matchedBudgetControlRule.controlMode === "block" ? "需先处理超标项" : "提交前请确认超标原因"
})`,
);
}
if (precheck.blockingReasons.length) {
lines.push("", "阻断原因:");
lines.push("", "暂不能提交的原因:");
precheck.blockingReasons.forEach((item, index) => {
lines.push(`${index + 1}. ${item.title}${item.detail ? `:${item.detail}` : ""}`);
});
}
if (precheck.warnings.length) {
lines.push("", "预警信息:");
lines.push("", "提交前提醒:");
precheck.warnings.forEach((item, index) => {
lines.push(`${index + 1}. ${item.title}${item.detail ? `:${item.detail}` : ""}`);
});
+25 -4
查看文件
@@ -1,14 +1,35 @@
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
export function resolveErrorMessage(error: unknown): string {
function toUserSafeMessage(message: string | undefined, fallback: string) {
const normalized = (message ?? "")
.trim()
.replace(/^(?:[A-Z][A-Z0-9_]{2,}|\d{3})[::]\s*/, "");
if (!normalized) {
return fallback;
}
const exposesTechnicalDetail =
!/[\u3400-\u9fff]/.test(normalized) ||
/https?:\/\/|\/api\/|(?:^|\s)\/[A-Za-z0-9._-]+(?:\/[^\s]*)?|[A-Za-z]:\\|(?:^|\n)\s*at\s|\b(?:GET|POST|PUT|PATCH|DELETE)\s+\/|^(?:\{|\[)/.test(normalized);
return exposesTechnicalDetail ? fallback : normalized;
}
export function resolveErrorMessage(
error: unknown,
fallback = "操作失败,请稍后重试",
): string {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
return toUserSafeMessage(
mapApiErrorMessage(error.code, error.details, error.message),
fallback,
);
}
if (error instanceof Error) {
return error.message;
return toUserSafeMessage(error.message, fallback);
}
return "操作失败,请稍后重试";
return fallback;
}
+2 -7
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -14,7 +15,6 @@ import {
type FinanceLedgerPaymentStatus,
type FinanceLedgerStatus,
} from "../api/apOperations";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
dismissRiskFlag,
fetchRiskFlags,
@@ -37,7 +37,6 @@ import {
fetchTravelReconciliationBatchDetail,
type TravelReconciliationItem,
} from "../api/travelReconciliation";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
@@ -87,11 +86,7 @@ type FailedPaymentItemRow = PaymentBatchItem & {
const BUDGET_RISK_CODES = new Set(["budget_exceeded", "budget_threshold_exceeded"]);
function resolveErrorMessage(error: unknown, fallback = "操作失败") {
return isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
: error instanceof Error
? error.message
: fallback;
return resolveUserErrorMessage(error, fallback);
}
function formatDateTime(value: string | null) {
+5 -12
查看文件
@@ -1,8 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import {
createApprovalRule,
fetchApprovalRules,
@@ -112,7 +111,7 @@ export function ApprovalRulesPage() {
toast.success("规则已创建");
},
onError: (err) => {
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "创建审批规则失败";
const msg = resolveUserErrorMessage(err, "创建审批规则失败");
setError(msg);
toast.error(msg);
},
@@ -126,7 +125,7 @@ export function ApprovalRulesPage() {
toast.success("状态已更新");
},
onError: (err) => {
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "更新审批规则失败";
const msg = resolveUserErrorMessage(err, "更新审批规则失败");
setError(msg);
toast.error(msg);
},
@@ -148,7 +147,7 @@ export function ApprovalRulesPage() {
toast.success("规则已更新");
},
onError: (err) => {
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "更新审批规则失败";
const msg = resolveUserErrorMessage(err, "更新审批规则失败");
setError(msg);
toast.error(msg);
},
@@ -382,13 +381,7 @@ export function ApprovalRulesPage() {
{listQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(listQuery.error)
? `${listQuery.error.code}: ${mapApiErrorMessage(
listQuery.error.code,
listQuery.error.details,
listQuery.error.message,
)}`
: "加载审批规则失败"}
{resolveUserErrorMessage(listQuery.error, "加载审批规则失败")}
</p>
) : null}
+2 -9
查看文件
@@ -1,8 +1,8 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
assignAsset,
createAsset,
@@ -23,7 +23,6 @@ import {
} from "../api/assets";
import { fetchPurchaseRequests } from "../api/purchaseRequests";
import { fetchCostCenters, fetchDocumentCategories, fetchLegalEntities } from "../api/masterData";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { DateTextInput } from "../components/common/DateTextInput";
@@ -51,13 +50,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
import { formatCompactIdentifier, formatDisplayDateTime, formatTextWithCompactLongNumbers, isCompactedIdentifier } from "../lib/displayFormat";
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function initialCategoryForm() {
+2 -8
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { type ReactNode, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -13,7 +14,6 @@ import {
type AttendanceDailyStatus,
type AttendanceMonthlyDay,
} from "../api/attendance";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
@@ -90,13 +90,7 @@ function formatTime(value: string | null) {
}
function resolveErrorMessage(error: unknown, fallback = "请求失败,请稍后重试") {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
return resolveUserErrorMessage(error, fallback);
}
function isUnifiedDisabledError(error: unknown) {
+2 -9
查看文件
@@ -1,6 +1,6 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { mapApiErrorMessage } from "../api/errorMessages";
import { archiveBudget, createBudget, fetchBudgets, type BudgetListQuery, type BudgetPool, type BudgetScopeType } from "../api/budgets";
import {
archiveBudgetControlRule,
@@ -14,7 +14,6 @@ import {
import { fetchDepartments } from "../api/departments";
import { fetchCostCenters, fetchLegalEntities } from "../api/masterData";
import { fetchExpenseTypes } from "../api/expenseTypes";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
@@ -59,13 +58,7 @@ function emptyRuleForm(): RuleFormState {
}
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function describeScope(item: BudgetPool) {
+2 -2
查看文件
@@ -1,7 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { useAuth } from "../auth/AuthContext";
import { AuthPanel } from "../components/auth/AuthPanel";
import { Button } from "../components/ui/button";
@@ -41,7 +41,7 @@ export function ChangePasswordPage() {
navigate("/portal", { replace: true });
} catch (err) {
if (isApiError(err)) {
setError(mapApiErrorMessage(err.code, err.details, err.message));
setError(resolveUserErrorMessage(err));
} else {
setError("修改密码失败,请稍后重试");
}
+2 -6
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -16,9 +17,7 @@ import {
type ContractRegistryType,
} from "../api/contractsRegistry";
import { fetchEmployees } from "../api/employees";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchProjects, type ConfidentialityLevel } from "../api/projects";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
@@ -92,10 +91,7 @@ type EditorState = {
};
function resolveError(error: unknown, fallback = "操作失败") {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
function confidentialityLabel(value: ConfidentialityLevel) {
+2 -4
查看文件
@@ -26,6 +26,7 @@ import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
const SCOPE_TYPE_OPTIONS: Array<{ value: CutoverScope["scopeType"]; label: string }> = [
{ value: "department", label: "department" },
@@ -44,10 +45,7 @@ type ScopeFormState = {
};
function resolveErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function formatDateTime(value: string | null) {
+3 -12
查看文件
@@ -1,6 +1,6 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { mapApiErrorMessage } from "../api/errorMessages";
import { exportDashboardReimbursements } from "../api/exports";
import {
fetchDashboardApprovalEfficiency,
@@ -10,7 +10,6 @@ import {
type DashboardQuery,
} from "../api/dashboard";
import { fetchDepartments } from "../api/departments";
import { isApiError } from "../api/types";
import { toast } from "sonner";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
@@ -36,15 +35,7 @@ function toCurrency(value: string | number | null | undefined) {
}
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 "加载失败,请稍后重试。";
return resolveUserErrorMessage(error, "加载失败,请稍后重试。");
}
export function DashboardPage() {
@@ -107,7 +98,7 @@ export function DashboardPage() {
toast.success("导出完成");
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "导出失败");
toast.error(resolveUserErrorMessage(err, "导出失败"));
},
});
+4 -12
查看文件
@@ -1,8 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { createDelegation, fetchMyDelegations, revokeDelegation } from "../api/delegations";
import { fetchWorkflowApprovers } from "../api/workflow";
import { PageHeader } from "../components/common/PageHeader";
@@ -50,7 +49,7 @@ export function DelegationsPage() {
toast.success("委派已创建");
},
onError: (err) => {
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "创建委派失败";
const msg = resolveUserErrorMessage(err, "创建委派失败");
setError(msg);
toast.error(msg);
},
@@ -64,7 +63,7 @@ export function DelegationsPage() {
toast.success("已撤销");
},
onError: (err) => {
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "撤销委派失败";
const msg = resolveUserErrorMessage(err, "撤销委派失败");
setError(msg);
toast.error(msg);
},
@@ -161,13 +160,7 @@ export function DelegationsPage() {
{listQuery.isLoading ? <TableSkeleton columns={5} rows={3} /> : null}
{listQuery.isError ? (
<p className="text-sm text-red-600">
{isApiError(listQuery.error)
? `${listQuery.error.code}: ${mapApiErrorMessage(
listQuery.error.code,
listQuery.error.details,
listQuery.error.message,
)}`
: "加载委派列表失败"}
{resolveUserErrorMessage(listQuery.error, "加载委派列表失败")}
</p>
) : null}
@@ -259,4 +252,3 @@ export function DelegationsPage() {
</div>
);
}
+11 -17
查看文件
@@ -1,7 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import {
archiveDepartment,
@@ -31,34 +31,34 @@ import { ConfirmDialog } from "../components/common/ConfirmDialog";
function mapDepartmentErrorMessage(error: unknown, fallback: string) {
if (!isApiError(error)) {
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
if (error.code === "VALID_001") {
if (error.details?.parentId === "not_found") {
return `${error.code}: 上级部门不存在。`;
return "上级部门不存在。";
}
if (error.details?.parentId === "archived") {
return `${error.code}: 上级部门已归档,无法继续使用。`;
return "上级部门已归档,无法继续使用。";
}
if (error.details?.parentId === "self_not_allowed") {
return `${error.code}: 上级部门不能选择自身。`;
return "上级部门不能选择自身。";
}
if (error.details?.parentId === "cycle_not_allowed") {
return `${error.code}: 上级部门不能形成循环层级。`;
return "上级部门不能形成循环层级。";
}
if (error.details?.status === "archived") {
return `${error.code}: 已归档部门不可编辑。`;
return "已归档部门不可编辑。";
}
if (error.details?.department === "has_children") {
return `${error.code}: 当前部门存在下级节点,无法归档。`;
return "当前部门存在下级节点,无法归档。";
}
if (error.details?.department === "has_active_employees") {
return `${error.code}: 当前部门仍有在职员工,无法归档。`;
return "当前部门仍有在职员工,无法归档。";
}
}
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
return resolveUserErrorMessage(error);
}
function flattenDepartmentTree(nodes: DepartmentTreeNode[], depth = 0): Array<{ id: string; label: string }> {
@@ -288,13 +288,7 @@ export function DepartmentsPage() {
{listQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(listQuery.error)
? `${listQuery.error.code}: ${mapApiErrorMessage(
listQuery.error.code,
listQuery.error.details,
listQuery.error.message,
)}`
: "加载部门列表失败"}
{resolveUserErrorMessage(listQuery.error, "加载部门列表失败")}
</p>
) : null}
+16 -7
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useState } from "react";
import { Link, useParams, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -27,6 +28,7 @@ import {
type EmployeeCertificate,
type EmployeeContract,
type EmployeeContractType,
type EmployeeEmploymentStatus,
type EmployeeTraining,
type EmployeeTrainingStatus,
type EmployeeTrainingType,
@@ -50,12 +52,19 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import { Textarea } from "../components/ui/textarea";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { downloadBlob } from "../lib/download";
type ProfileTab = "profile" | "contracts" | "trainingCertificates" | "performance" | "salary";
const EMPLOYMENT_STATUS_LABELS: Record<EmployeeEmploymentStatus, string> = {
onboarding: "待入职",
probation: "试用期",
active: "在职",
offboarding: "离职办理中",
inactive: "已离职",
};
type ContractFormState = {
contractNo: string;
contractType: EmployeeContractType;
@@ -90,10 +99,7 @@ const CONTRACT_TYPE_OPTIONS: Array<{ value: EmployeeContractType; label: string
];
function errorMessage(error: unknown, fallback: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
function buildEmptyContractForm(): ContractFormState {
@@ -808,7 +814,10 @@ export function EmployeeProfilePage() {
</div>
<div className="mt-3 flex flex-wrap gap-2">
<StatusBadge status={profile.employee.status} />
<StatusBadge status={profile.employee.employmentStatus} />
<StatusBadge
status={profile.employee.employmentStatus}
label={EMPLOYMENT_STATUS_LABELS[profile.employee.employmentStatus] ?? "未知状态"}
/>
</div>
</div>
</div>
@@ -989,7 +998,7 @@ export function EmployeeProfilePage() {
资产未归还:<span className="font-medium text-zinc-900">{profile.lifecycle.blockers.openAssetCount}</span>
</div>
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
待办审批任务:<span className="font-medium text-zinc-900">{profile.lifecycle.blockers.pendingWorkflowTaskCount}</span>
待审批任务:<span className="font-medium text-zinc-900">{profile.lifecycle.blockers.pendingWorkflowTaskCount}</span>
</div>
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
可完成离职:<span className="font-medium text-zinc-900">{profile.lifecycle.blockers.canCompleteOffboarding ? "是" : "否"}</span>
+2 -9
查看文件
@@ -1,8 +1,8 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchDepartments } from "../api/departments";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchExpenseTypes } from "../api/expenseTypes";
import {
createExpenseStandard,
@@ -14,7 +14,6 @@ import {
type ExpenseStandardPerUnit,
type ExpenseStandardScopeType,
} from "../api/expenseStandards";
import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
@@ -77,13 +76,7 @@ function toForm(item: ExpenseStandard): FormState {
}
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 "请求失败,请稍后重试";
return resolveUserErrorMessage(error, "请求失败,请稍后重试");
}
function toneClassForControlMode(mode: ExpenseStandardControlMode) {
+3 -10
查看文件
@@ -1,8 +1,8 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchDepartments } from "../api/departments";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
batchUpdateFeatureFlags,
fetchFeatureFlagChangelog,
@@ -16,7 +16,6 @@ import {
type FeatureFlagSection,
type FeatureFlagSnapshot,
} from "../api/featureFlags";
import { isApiError } from "../api/types";
import { Pagination } from "../components/common/Pagination";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { SectionPanel } from "../components/patterns/SectionPanel";
@@ -60,7 +59,7 @@ function toDisplayName(flagKey: string) {
if (flagKey === "attendance_unified_v1") return "企微考勤统一汇总";
if (flagKey === "weekly_report_v1") return "周报工作台";
if (flagKey === "employee_lifecycle_v1") return "人员生命周期";
if (flagKey === "todo_board_v1") return "待办工作台";
if (flagKey === "todo_board_v1") return "个人事项工作台";
if (flagKey === "wecom_entry_v1") return "企业微信入口";
if (flagKey === "project_domain_v1") return "项目主域";
if (flagKey === "project_linking_v1") return "项目联动";
@@ -96,13 +95,7 @@ function dedupeIds(items: string[]) {
}
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 "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function summarizeSnapshot(snapshot: FeatureFlagSnapshot | null) {
+3 -3
查看文件
@@ -1,9 +1,9 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { MailCheck } from "lucide-react";
import { Link, useNavigate } from "react-router-dom";
import { fetchAuthCapabilities, forgotPassword, resetPassword } from "../api/auth";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { AuthPanel } from "../components/auth/AuthPanel";
import { Button } from "../components/ui/button";
@@ -64,7 +64,7 @@ export function ForgotPasswordPage() {
setMessage("如账号可用,验证码将发送到企业邮箱;10 分钟内有效,请同时查看免提醒邮件。");
} catch (err) {
if (isApiError(err)) {
setError(mapApiErrorMessage(err.code, err.details, err.message));
setError(resolveUserErrorMessage(err));
} else {
setError("请求验证码失败,请稍后重试或联系管理员。");
}
@@ -103,7 +103,7 @@ export function ForgotPasswordPage() {
navigate("/login", { replace: true });
} catch (err) {
if (isApiError(err)) {
setError(mapApiErrorMessage(err.code, err.details, err.message));
setError(resolveUserErrorMessage(err));
} else {
setError("重置密码失败,请稍后重试或联系管理员。");
}
+2 -9
查看文件
@@ -1,9 +1,9 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchAssets } from "../api/assets";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
completeInventoryCheck,
createInventoryCheck,
@@ -14,7 +14,6 @@ import {
type InventoryCheckScopeType,
} from "../api/inventoryChecks";
import { fetchCostCenters, fetchLegalEntities } from "../api/masterData";
import { isApiError } from "../api/types";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { PageHeader } from "../components/common/PageHeader";
import { TableSkeleton } from "../components/common/TableSkeleton";
@@ -24,13 +23,7 @@ import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function scopeLabel(scopeType: InventoryCheckScopeType) {
+3 -5
查看文件
@@ -1,8 +1,8 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchMyDelegations } from "../api/delegations";
import { exportInvoicePool } from "../api/exports";
import {
@@ -228,9 +228,7 @@ export function InvoicePoolPage() {
// --- Helper for error handling ---
const toUserError = (err: unknown, fallback: string) => {
return isApiError(err)
? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`
: err instanceof Error ? err.message : fallback;
return resolveUserErrorMessage(err, fallback);
};
const handleError = (err: unknown, fallback: string) => {
@@ -330,7 +328,7 @@ export function InvoicePoolPage() {
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
setError(resolveUserErrorMessage(err));
} else {
setError("预览链接获取失败");
}
+2 -9
查看文件
@@ -1,7 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
createLeaveRequest,
fetchLeaveBalances,
@@ -15,7 +15,6 @@ import {
type LeaveRequestStatus,
type LeaveSession,
} from "../api/leave";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton";
@@ -149,13 +148,7 @@ function formatCalendarEntryLabel(item: LeaveRequestItem, dateKey: string) {
}
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "操作失败,请稍后重试。";
return resolveUserErrorMessage(error, "操作失败,请稍后重试。");
}
function calculateRequestedDaysPreview(
+4 -4
查看文件
@@ -1,9 +1,9 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Building2, MailCheck } from "lucide-react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { fetchAuthCapabilities, fetchSsoProviders, fetchWecomConfig, requestEmailLoginCode } from "../api/auth";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { AuthPanel } from "../components/auth/AuthPanel";
@@ -127,7 +127,7 @@ export function LoginPage() {
}
} catch (err) {
if (isApiError(err)) {
setError(mapApiErrorMessage(err.code, err.details, err.message));
setError(resolveUserErrorMessage(err));
} else if (err instanceof TypeError) {
setError("无法连接后端服务,请稍后再试或联系管理员。");
} else {
@@ -179,7 +179,7 @@ export function LoginPage() {
setEmailCodeMessage("如账号可用,验证码将发送到企业邮箱;10 分钟内有效,请同时查看免提醒邮件。");
} catch (err) {
if (isApiError(err)) {
setEmailCodeError(mapApiErrorMessage(err.code, err.details, err.message));
setEmailCodeError(resolveUserErrorMessage(err));
} else if (err instanceof TypeError) {
setEmailCodeError("无法连接后端服务,请稍后再试或联系管理员。");
} else {
@@ -215,7 +215,7 @@ export function LoginPage() {
}
} catch (err) {
if (isApiError(err)) {
setEmailCodeError(mapApiErrorMessage(err.code, err.details, err.message));
setEmailCodeError(resolveUserErrorMessage(err));
} else if (err instanceof TypeError) {
setEmailCodeError("无法连接后端服务,请稍后再试或联系管理员。");
} else {
+2 -9
查看文件
@@ -1,7 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
archiveCostCenter,
archiveDocumentCategory,
@@ -16,7 +16,6 @@ import {
type DocumentCategoryEntityType,
type DocumentResetPeriod,
} from "../api/masterData";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
@@ -48,13 +47,7 @@ const RESET_PERIOD_OPTIONS: Array<{ value: DocumentResetPeriod; label: string }>
];
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "操作失败,请稍后重试。";
return resolveUserErrorMessage(error, "操作失败,请稍后重试。");
}
function toEnabledFilter(value: "" | "true" | "false") {
+51 -29
查看文件
@@ -1,8 +1,8 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useLocation, 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";
@@ -25,8 +25,10 @@ import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { MOBILE_STICKY_ACTION_PAGE_CLASS, MobileStickyActionBar } from "../components/patterns/MobileStickyActionBar";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Spinner } from "../components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { Textarea } from "../components/ui/textarea";
@@ -171,13 +173,7 @@ function publicReference(value: string | null | undefined) {
}
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 "动作执行失败";
return resolveUserErrorMessage(error, "动作执行失败");
}
function isAlreadyProcessedTaskError(error: unknown) {
@@ -643,10 +639,10 @@ export function MobileApprovalsPage() {
throw new Error("请选择任务");
}
if (!selectedTask) {
throw new Error("当前待办尚未缓存,请联网加载后再操作");
throw new Error("当前审批任务尚未缓存,请联网加载后再操作");
}
if (!selectedTask.allowedActions.includes(action)) {
throw new Error("当前待办不允许该动作");
throw new Error("当前审批任务不允许该动作");
}
if (action === "reject" && !comment.trim()) {
throw new Error("驳回必须填写意见");
@@ -685,10 +681,10 @@ export function MobileApprovalsPage() {
throw new Error("请选择任务");
}
if (!selectedTask) {
throw new Error("当前待办尚未缓存,请联网加载后再操作");
throw new Error("当前审批任务尚未缓存,请联网加载后再操作");
}
if (!selectedTask.allowedActions.includes(action)) {
throw new Error("当前待办不允许该动作");
throw new Error("当前审批任务不允许该动作");
}
if (action === "approve") {
@@ -744,6 +740,11 @@ export function MobileApprovalsPage() {
});
const isActionSubmitting = actionMutation.isPending || offlineDraftSubmitting;
const transferDisabledReason = !transferToApproverId
? "请先选择目标审批人。"
: transferReason.trim().length < 10
? `请填写转审理由,至少 10 个字(还差 ${10 - transferReason.trim().length} 个字)。`
: "转审理由已满足要求。";
const syncMutation = useMutation({
mutationFn: async (items: WorkflowDraftQueueItem[]) => {
@@ -831,7 +832,7 @@ export function MobileApprovalsPage() {
}
}, [canUseRemote, retryableQueueItems, syncMutation]);
const listTitle = taskTab === "pending" ? "待我处理" : "已处理";
const listTitle = taskTab === "pending" ? "待我审批" : "已处理";
const shouldShowDraftQueue = !isOnline || currentQueueItems.length > 0;
const showMobileApprovalActions = Boolean(isPushDetail && selectedTask && selectedTaskAllowsActions);
const approvalFocusText = taskTab === "pending"
@@ -852,9 +853,7 @@ export function MobileApprovalsPage() {
<PageScaffold
className={cn(
"space-y-4",
showMobileApprovalActions
? "mobile-fixed-action-page pb-[calc(var(--mobile-sticky-action-height,78px)+env(safe-area-inset-bottom,0px)+1.25rem)]"
: "",
showMobileApprovalActions ? MOBILE_STICKY_ACTION_PAGE_CLASS : "",
)}
>
{isPushDetail ? (
@@ -871,14 +870,14 @@ export function MobileApprovalsPage() {
/>
) : (
<PageHeader
title={canAccessRemoteApprovals ? "待我处理" : "进度"}
subtitle={canAccessRemoteApprovals ? "处理待办,查看已处理。" : "查看我发起的申请进度。"}
title={canAccessRemoteApprovals ? "待我审批" : "进度"}
subtitle={canAccessRemoteApprovals ? "处理待审批任务,查看已处理记录。" : "查看我发起的申请进度。"}
/>
)}
{!isOnline && canAccessRemoteApprovals ? (
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">
当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存待办,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。
当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存审批任务,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。
</p>
) : null}
@@ -985,7 +984,7 @@ export function MobileApprovalsPage() {
<div className="mobile-command-surface">
<Tabs value={taskTab} onValueChange={handleTaskTabChange}>
<TabsList className="flex w-full justify-start gap-1">
<TabsTrigger value="pending">待我处理</TabsTrigger>
<TabsTrigger value="pending">待我审批</TabsTrigger>
<TabsTrigger value="completed" disabled={!canUseRemote}>已处理</TabsTrigger>
</TabsList>
</Tabs>
@@ -1041,7 +1040,7 @@ export function MobileApprovalsPage() {
{taskTab === "completed"
? "最近 30 天暂无符合筛选条件的已处理任务。"
: !canAccessRemoteApprovals && isOnline
? "暂无待我处理;收到企业微信审批消息后可从这里查看。"
? "暂无待我审批;收到企业微信审批消息后可从这里查看。"
: canUseRemote
? "暂无符合筛选条件的审批任务。"
: "暂无符合筛选条件的已缓存审批。"}
@@ -1102,7 +1101,7 @@ export function MobileApprovalsPage() {
{isOfflineUncachedDeepLink ? (
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">
离线未缓存该待办,请联网后重试。
离线未缓存该审批任务,请联网后重试。
</p>
) : null}
@@ -1223,7 +1222,14 @@ export function MobileApprovalsPage() {
</p>
) : (
<>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs leading-5 text-zinc-600" data-testid="mobile-workflow-action-consequence-help">
<div><span className="font-medium text-zinc-800">退回:</span>原单回到申请人修改,保留单号和历史,修改后可重新提交。</div>
<div><span className="font-medium text-zinc-800">驳回:</span>本次流程终止或进入驳回态,申请人通常需要重新发起。</div>
</div>
<Label className="sr-only" htmlFor="mobile-approvals-comment">审批意见</Label>
<Textarea
id="mobile-approvals-comment"
data-testid="mobile-approvals-comment"
onChange={(event) => setComment(event.target.value)}
placeholder="意见(驳回/退回必填)"
@@ -1235,11 +1241,10 @@ export function MobileApprovalsPage() {
<p className="text-xs text-zinc-500">离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。</p>
) : null}
<div
className="mobile-sticky-action-surface fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur"
data-testid="mobile-approvals-sticky-actions"
<MobileStickyActionBar
contentClassName="mobile-approval-action-grid"
testId="mobile-approvals-sticky-actions"
>
<div className="mobile-sticky-action-grid mobile-approval-action-grid">
<Button
className="min-h-12 w-full"
data-testid="mobile-approvals-approve"
@@ -1269,13 +1274,14 @@ export function MobileApprovalsPage() {
>
退回
</Button>
</div>
</div>
</MobileStickyActionBar>
{canTransfer ? (
<div className="mobile-list-card space-y-3 text-sm">
<p className="font-medium text-zinc-800">转审</p>
<Label htmlFor="mobile-approvals-transfer-to">目标审批人</Label>
<select
id="mobile-approvals-transfer-to"
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)}
@@ -1289,18 +1295,34 @@ export function MobileApprovalsPage() {
))}
</select>
<Label htmlFor="mobile-approvals-transfer-reason">转审理由</Label>
<Textarea
id="mobile-approvals-transfer-reason"
aria-describedby="mobile-approvals-transfer-help"
data-testid="mobile-approvals-transfer-reason"
onChange={(event) => setTransferReason(event.target.value)}
placeholder="转审理由(至少10字)"
rows={2}
value={transferReason}
/>
<p
id="mobile-approvals-transfer-help"
className={transferReason.trim().length >= 10 && transferToApproverId ? "text-xs text-emerald-700" : "text-xs text-amber-700"}
data-testid="mobile-approvals-transfer-disabled-reason"
>
{transferDisabledReason}
</p>
<Button
aria-describedby="mobile-approvals-transfer-help"
className="min-h-12 w-full"
data-testid="mobile-approvals-transfer"
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("transfer")}
disabled={
isActionSubmitting ||
!selectedTask.allowedActions.includes("transfer") ||
!transferToApproverId ||
transferReason.trim().length < 10
}
onClick={() => actionMutation.mutate("transfer")}
type="button"
variant="secondary"
+2 -9
查看文件
@@ -1,9 +1,9 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { fetchAssets, type AssetItem } from "../api/assets";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
fetchInventoryChecks,
recordInventoryItems,
@@ -11,7 +11,6 @@ import {
type InventoryCheck,
type InventoryCheckResult,
} from "../api/inventoryChecks";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
@@ -29,13 +28,7 @@ import {
} from "../mobile/inventoryScanQueue";
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 "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function statusLabel(status: string) {
+4 -13
查看文件
@@ -1,8 +1,8 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { uploadInvoice } from "../api/invoicePool";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader";
import { MobileIcon } from "../components/mobile/MobileIcon";
@@ -70,9 +70,7 @@ export function MobileCapturePage() {
} else {
result.failed.push({
file,
message: isApiError(err)
? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`
: err instanceof Error ? err.message : "上传失败",
message: resolveUserErrorMessage(err, "上传失败"),
});
}
}
@@ -99,14 +97,7 @@ export function MobileCapturePage() {
toast.success(`上传完成:成功 ${result.succeeded},待重试 ${result.queued}`);
},
onError: (err) => {
if (isApiError(err)) {
const msg = `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`;
setError(msg);
toast.error(msg);
return;
}
const msg = err instanceof Error ? err.message : "上传失败";
const msg = resolveUserErrorMessage(err, "上传失败");
setError(msg);
toast.error(msg);
},
@@ -138,7 +129,7 @@ export function MobileCapturePage() {
toast.success(`同步完成:成功 ${result.succeeded},失败 ${result.failed}`);
},
onError: (err) => {
const msg = err instanceof Error ? err.message : "队列同步失败";
const msg = resolveUserErrorMessage(err, "队列同步失败");
setError(msg);
toast.error(msg);
},
+3 -10
查看文件
@@ -1,10 +1,9 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
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 { hasAnyPermission, hasBusinessApproverRole } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
@@ -16,13 +15,7 @@ import { Button } from "../components/ui/button";
import { MOBILE_PURCHASE_PERMISSIONS } from "../features/mobile/navigation";
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 "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function MobileActionLink({
@@ -115,7 +108,7 @@ export function MobileMePage() {
<MobileActionLink to="/mobile/leave" label="请假记录" icon="event_available" />
<MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" />
{canAccessPurchase ? <MobileActionLink to="/mobile/purchase-requests" label="采购" icon="shopping_cart" /> : null}
<MobileActionLink to="/mobile/approvals" label={canAccessApprovals ? "待我处理" : "进度"} icon="fact_check" />
<MobileActionLink to="/mobile/approvals" label={canAccessApprovals ? "待我审批" : "进度"} icon="fact_check" />
{canAccessAssets ? <MobileActionLink to="/mobile/assets/scan" label="资产扫码" icon="inventory_2" /> : null}
</div>
</SectionPanel>
+2 -2
查看文件
@@ -1,9 +1,9 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
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";
import { Pagination } from "../components/common/Pagination";
import { PageScaffold } from "../components/patterns/PageScaffold";
@@ -27,7 +27,7 @@ const NOTIFICATION_TYPE_OPTIONS = [
function resolveNotificationError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
return resolveUserErrorMessage(error);
}
return "加载通知失败";
}
+6 -44
查看文件
@@ -1,11 +1,10 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchNotifications, markNotificationRead, type NotificationItem } from "../api/notifications";
import { fetchPortalHome } from "../api/portal";
import { isApiError } from "../api/types";
import { fetchWorkflowTasks, fetchWorkflowTaskSummary } from "../api/workflow";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasBusinessApproverRole, hasFinanceRole } from "../auth/permissions";
@@ -34,13 +33,7 @@ type MobileAction = {
};
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 "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function formatDateTime(value: string | null) {
@@ -114,13 +107,6 @@ export function MobilePortalPage() {
const canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]);
const canAccessPurchase = hasAnyPermission(permissions, [...MOBILE_PURCHASE_PERMISSIONS]);
const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]);
const canAccessWecomApprovalArchive = hasAnyPermission(permissions, [
"leave.view_own",
"attendance.view_own",
"leave.view_all",
"attendance.view_all",
"*",
]);
const canCreateExpense = hasAnyPermission(permissions, ["expense.create"]);
const currentMobileSource = `${location.pathname}${location.search}`;
@@ -259,7 +245,7 @@ export function MobilePortalPage() {
return (
<PageScaffold className="mobile-page-stack">
<PageHeader
title="今天待办"
title="今日待处理"
meta={<span className="badge-neutral">移动工作台</span>}
/>
@@ -267,8 +253,8 @@ export function MobilePortalPage() {
<section className="mobile-card mobile-focus-card mobile-page-stack" data-testid="mobile-pending-summary">
<div className="mobile-section-header mobile-section-header--top">
<div className="min-w-0">
<div className="mobile-section-title">待处理</div>
<div className="mobile-list-card__subtitle mt-1">待我处理</div>
<div className="mobile-section-title">待我审批</div>
<div className="mobile-list-card__subtitle mt-1">今日待处理</div>
</div>
<div className="text-right">
<div className="text-3xl font-semibold leading-none text-zinc-900" data-testid="mobile-pending-summary-count">
@@ -307,7 +293,7 @@ export function MobilePortalPage() {
</div>
) : null}
<Button asChild className="mt-3 w-full" size="sm" type="button" variant="secondary">
<Link to="/mobile/approvals">查看全部待办</Link>
<Link to="/mobile/approvals">查看全部审批</Link>
</Button>
</section>
) : null}
@@ -327,10 +313,6 @@ export function MobilePortalPage() {
<div className="text-xs text-zinc-500">异常</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{financeExceptionCount}</div>
</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">处理</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">OA</div>
</div>
</div>
{paymentBatchEnabled ? (
<Button asChild className="mt-3 w-full" size="sm" type="button" variant="secondary">
@@ -353,26 +335,6 @@ export function MobilePortalPage() {
</section>
) : null}
<section className="mobile-card mobile-page-stack" data-testid="mobile-attendance-summary-card">
<div className="mobile-section-header">
<div className="mobile-section-title">考勤</div>
<span className="badge-neutral">企业微信打卡</span>
</div>
<div className="text-sm leading-6 text-zinc-600">
日常打卡、请假和补卡在企业微信完成;OA 保留已归档历史和考勤汇总。
</div>
<div className="mt-3 grid gap-2">
{canAccessWecomApprovalArchive ? (
<Button asChild className="w-full" size="sm" type="button" variant="secondary">
<Link to="/mobile/wecom-approval-archive">查看企微审批历史</Link>
</Button>
) : null}
<Button asChild className="w-full" size="sm" type="button" variant="secondary">
<Link to="/mobile/attendance">查看考勤汇总</Link>
</Button>
</div>
</section>
<section className="mobile-card mobile-page-stack" data-testid="mobile-notifications">
<div className="mobile-section-header">
<div className="mobile-section-title">通知</div>
+2 -8
查看文件
@@ -1,8 +1,8 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { Link, useSearchParams } from "react-router-dom";
import { fetchWecomConfig, startWecomBind, unbindWecom, updateNotificationPreferences } from "../api/auth";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { PageHeader } from "../components/common/PageHeader";
import { PageScaffold } from "../components/patterns/PageScaffold";
@@ -51,13 +51,7 @@ function roleLabel(roleCode: string) {
}
function resolveProfileError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${error.message}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function resolveBinding(
@@ -49,6 +49,7 @@ import { isTravelReimbursement } from "../lib/reimbursementTravel";
import { ReimbursementOperationalHints } from "../components/reimbursements/ReimbursementOperationalHints";
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { MOBILE_STICKY_ACTION_PAGE_CLASS, MobileStickyActionBar } from "../components/patterns/MobileStickyActionBar";
import { Button } from "../components/ui/button";
import {
Dialog,
@@ -881,10 +882,7 @@ export function MobileReimbursementDetailPage() {
<PageScaffold
className={cn(
"space-y-4",
showStickyActions ? "mobile-fixed-action-page" : "",
showStickyActions
? "pb-[calc(var(--mobile-sticky-action-height,78px)+env(safe-area-inset-bottom,0px)+1.25rem)]"
: "pb-6",
showStickyActions ? MOBILE_STICKY_ACTION_PAGE_CLASS : "pb-6",
)}
data-testid="mobile-reimbursement-detail"
>
@@ -1333,11 +1331,7 @@ export function MobileReimbursementDetailPage() {
) : null}
{showStickyActions ? (
<div
className="mobile-sticky-action-surface fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur"
data-testid="mobile-reimbursement-sticky-actions"
>
<div className="mobile-sticky-action-grid">
<MobileStickyActionBar testId="mobile-reimbursement-sticky-actions">
{showSubmitAction ? (
<Button
className="min-h-11 min-w-0 px-2 text-sm whitespace-nowrap"
@@ -1349,8 +1343,7 @@ export function MobileReimbursementDetailPage() {
{submitMutation.isPending ? "提交中..." : "提交审批"}
</Button>
) : null}
</div>
</div>
</MobileStickyActionBar>
) : null}
<Dialog open={isInvoiceDialogOpen} onOpenChange={closeInvoiceDialog}>
+3 -10
查看文件
@@ -1,7 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
createNotificationChannel,
fetchNotificationDeliveries,
@@ -15,7 +15,6 @@ import {
type NotificationChannelTemplate,
type NotificationChannelWriteConfig,
} from "../api/notificationChannels";
import { isApiError } from "../api/types";
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { Button } from "../components/ui/button";
@@ -75,7 +74,7 @@ const ROLE_OPTIONS = [
] as const;
const DOMAIN_SCOPE_OPTIONS = [
{ value: "workflow", label: "待我处理" },
{ value: "workflow", label: "待我审批" },
{ value: "reimbursement", label: "报销" },
{ value: "leave", label: "请假" },
{ value: "procurement", label: "采购" },
@@ -103,13 +102,7 @@ const NOTIFICATION_TYPE_OPTIONS = [
] as const;
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function buildInitialForm(): ChannelFormState {
+1 -9
查看文件
@@ -2,8 +2,6 @@ import { Link, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchNotifications, markAllNotificationsRead, markNotificationRead } from "../api/notifications";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { Pagination } from "../components/common/Pagination";
import { Button } from "../components/ui/button";
@@ -150,13 +148,7 @@ export function NotificationsPage() {
{listQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(listQuery.error)
? `${listQuery.error.code}: ${mapApiErrorMessage(
listQuery.error.code,
listQuery.error.details,
listQuery.error.message,
)}`
: "加载通知失败"}
{resolveErrorMessage(listQuery.error, "加载通知失败")}
</p>
) : null}
+2 -2
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -13,7 +14,6 @@ import {
type PaymentAccount,
} from "../api/paymentAccounts";
import { fetchLegalEntities } from "../api/masterData";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
@@ -27,7 +27,7 @@ import { Label } from "../components/ui/label";
import { Textarea } from "../components/ui/textarea";
function errorText(error: unknown) {
return isApiError(error) ? `${error.code}: ${error.message}` : error instanceof Error ? error.message : "操作失败";
return resolveUserErrorMessage(error, "操作失败");
}
export function PaymentAccountsPage() {
+2 -7
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useSearchParams } from "react-router-dom";
@@ -20,8 +21,6 @@ import {
type PaymentBatchReceiptAttachment,
type PaymentChannel,
} from "../api/paymentBatches";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { HeaderActionGroup, ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
@@ -109,11 +108,7 @@ function formatFileSize(value: number) {
}
function resolveError(error: unknown) {
return isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
: error instanceof Error
? error.message
: "操作失败";
return resolveUserErrorMessage(error, "操作失败");
}
function paymentOrderBlockerLabel(blocker: { code: string; entityCode?: string }) {
+2 -6
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -14,8 +15,6 @@ import {
type SalaryItemType,
} from "../api/payroll";
import { exportPayslips } from "../api/exports";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
@@ -58,10 +57,7 @@ function currentMonthString() {
}
function errorMessage(error: unknown, fallback: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
function moneyText(value: number) {
+2 -7
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -22,8 +23,6 @@ import {
} from "../api/employees";
import { exportEmployees } from "../api/exports";
import { fetchDepartments } from "../api/departments";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
@@ -55,11 +54,7 @@ const EMPLOYMENT_STATUS_OPTIONS: Array<{ value: EmployeeEmploymentStatus; label:
];
function mapApiMessage(error: unknown, fallback: string) {
return isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
: error instanceof Error
? error.message
: fallback;
return resolveUserErrorMessage(error, fallback);
}
function employmentStatusLabel(status: EmployeeEmploymentStatus | null) {
+2 -6
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -8,8 +9,6 @@ import {
patchPerformanceCycle,
type PerformanceReviewCycleType,
} from "../api/performance";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
@@ -48,10 +47,7 @@ function buildEmptyCycleForm(): CycleFormState {
}
function errorMessage(error: unknown, fallback: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
function cycleTypeLabel(value: PerformanceReviewCycleType) {
+84 -18
查看文件
@@ -1,13 +1,11 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchPortalHome, type PortalHomePayload } from "../api/portal";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, isManagerRole } from "../auth/permissions";
import { ErrorBoundary } from "../components/common/ErrorBoundary";
import { WelcomeDialog } from "../components/onboarding/WelcomeDialog";
import { WelcomeDialog, type WelcomeLinkItem } from "../components/onboarding/WelcomeDialog";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
@@ -15,6 +13,7 @@ import {
clearWelcomeDialogFlag,
shouldShowWelcomeDialog,
} from "../lib/onboardingGuide";
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
type RoleCapabilities = NonNullable<PortalHomePayload["roleCapabilities"]>;
@@ -50,7 +49,7 @@ const LEAVE_PORTAL_PERMISSIONS = ["leave.view_own", "leave.view_all", "leave.cre
const EXPENSE_PORTAL_PERMISSIONS = ["expense.view_own", "expense.view_all", "expense.create", "expense.proxy_manage"];
const PROCUREMENT_PORTAL_PERMISSIONS = ["procurement.view_own", "procurement.view_all", "procurement.create", "procurement.submit"];
const TRAVEL_PORTAL_PERMISSIONS = ["travel.view_own", "travel.view_all", "travel.create", "travel.submit"];
const APPROVAL_PORTAL_PERMISSIONS = ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"];
const APPROVAL_PORTAL_PERMISSIONS = ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"];
const FINANCE_PORTAL_PERMISSIONS = ["payment.batch.manage", "risk.review.manage", "expense.finance.pay"];
const SYSTEM_PORTAL_PERMISSIONS = ["system.users", "system.settings", "system.audit.view", "notification.channel.manage", "payment.account.read", "payment.account.manage"];
const CONTRACT_PORTAL_PERMISSIONS = ["project.view_all", "project.manage", "system.settings"];
@@ -112,9 +111,9 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
if (payload.pendingTasks.count > 0) {
items.push({
key: "workflow-pending",
title: `待我处理 ${payload.pendingTasks.count} 条`,
title: `待我审批 ${payload.pendingTasks.count} 条`,
reason: "审批、确认或流程处理正在等你。",
meta: "流程待办",
meta: "审批任务",
href: payload.pendingTasks.href,
actionLabel: "去处理",
severity: "warning",
@@ -129,13 +128,13 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
const highPriorityCount = payload.todoSummary?.highPriorityCount ?? 0;
items.push({
key: "todo-open",
title: `待办事项 ${openTodoCount} 条`,
title: `我的事项 ${openTodoCount} 条`,
reason: highPriorityCount > 0
? `其中 ${highPriorityCount} 条是高优先级,需要先确认。`
: "这些事项已分配给你,需要继续推进。",
meta: highPriorityCount > 0 ? "高优先级" : "个人待办",
meta: highPriorityCount > 0 ? "高优先级" : "个人事项",
href: payload.todoSummary?.href ?? "/todos",
actionLabel: "查看待办",
actionLabel: "查看事项",
severity: highPriorityCount > 0 ? "high" : "normal",
icon: "check_circle",
priority: 90,
@@ -497,6 +496,39 @@ function buildQuickEntries(permissions: string[], roleCapabilities: RoleCapabili
return entries.slice(0, 6);
}
function buildWelcomeLinks(permissions: string[], roleCapabilities: RoleCapabilities): WelcomeLinkItem[] {
const links: WelcomeLinkItem[] = [];
if (roleCapabilities.canApproveTasks && hasAnyPermission(permissions, APPROVAL_PORTAL_PERMISSIONS)) {
links.push({
icon: "fact_check",
label: "待我审批",
description: "查看今天需要审批的流程任务",
to: "/workflow/tasks",
});
}
if (!roleCapabilities.isAdminOnly && hasAnyPermission(permissions, EXPENSE_PORTAL_PERMISSIONS)) {
links.push({
icon: "request_quote",
label: "报销",
description: "录入票据和报销说明,查看处理进度",
to: "/reimbursements",
});
}
if (!roleCapabilities.isAdminOnly && hasAnyPermission(permissions, LEAVE_PORTAL_PERMISSIONS)) {
links.push({
icon: "event_available",
label: "请假记录",
description: "日常请假走企业微信,OA 查看记录和状态",
to: "/leave-requests",
});
}
return links;
}
function sumWorkItemCount(items: WorkItem[]) {
return items.reduce((total, item) => total + Math.max(1, item.count), 0);
}
@@ -538,7 +570,10 @@ function WorkItemRow({ item }: { item: WorkItem }) {
: "portal-task-row";
return (
<Link to={item.href} className={severityClass}>
<Link
to={item.href}
className={`${severityClass} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2`}
>
<span className="portal-task-row__icon" aria-hidden="true">
<span className="material-symbols-outlined text-[18px]">{item.icon}</span>
</span>
@@ -570,7 +605,11 @@ function QuickEntryPanel({ entries }: { entries: QuickEntry[] }) {
<SectionPanel title="常用入口">
<div className="portal-quick-entry-grid" data-testid="portal-quick-entries">
{entries.map((entry) => (
<Link className="portal-quick-entry" key={entry.key} to={entry.href}>
<Link
className="portal-quick-entry focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
key={entry.key}
to={entry.href}
>
<span className="portal-quick-entry__icon" aria-hidden="true">
<span className="material-symbols-outlined text-[18px]">{entry.icon}</span>
</span>
@@ -653,10 +692,32 @@ export function PortalHomePage() {
}
if (portalQuery.isError || !portalQuery.data) {
const message = isApiError(portalQuery.error)
? `${portalQuery.error.code}: ${mapApiErrorMessage(portalQuery.error.code, portalQuery.error.details, portalQuery.error.message)}`
: "工作台加载失败";
return <div className="card-std p-5 text-sm text-red-600">{message}</div>;
const message = resolveUserErrorMessage(portalQuery.error, "工作台加载失败,请稍后重试");
return (
<PageScaffold>
<section
aria-busy={portalQuery.isFetching}
aria-live="polite"
className="card-std space-y-3 p-5"
data-testid="portal-load-error"
role="alert"
>
<div>
<p className="text-sm font-medium text-red-700">{message}</p>
<p className="mt-1 text-sm text-zinc-600">工作台内容暂时不可用,重新加载不会清除当前登录状态。</p>
</div>
<Button
data-testid="portal-load-retry"
disabled={portalQuery.isFetching}
onClick={() => void portalQuery.refetch()}
type="button"
variant="secondary"
>
{portalQuery.isFetching ? "重新加载中..." : "重新加载"}
</Button>
</section>
</PageScaffold>
);
}
const payload = portalQuery.data;
@@ -669,6 +730,7 @@ export function PortalHomePage() {
isManagerRole(permissions);
const todayActionItems = buildTodayActionItems(payload, roleCapabilities);
const quickEntries = buildQuickEntries(permissions, roleCapabilities);
const welcomeLinks = buildWelcomeLinks(permissions, roleCapabilities);
const progressItems = buildProgressItems(payload);
const teamBlockers = isManagerView ? buildTeamBlockers(payload, roleCapabilities) : [];
const managementReminders = buildManagementReminders(payload, roleCapabilities, canSetupWorkspace);
@@ -686,7 +748,11 @@ export function PortalHomePage() {
return (
<PageScaffold>
<ErrorBoundary fallback={null}>
<WelcomeDialog open={showWelcome} onClose={handleCloseWelcome} />
<WelcomeDialog
links={welcomeLinks}
open={showWelcome && welcomeLinks.length > 0}
onClose={handleCloseWelcome}
/>
</ErrorBoundary>
<SummaryStrip
@@ -697,10 +763,10 @@ export function PortalHomePage() {
reminderCount={reminderCount}
/>
<QuickEntryPanel entries={quickEntries} />
<TodayActionPanel items={todayActionItems} emptyActions={emptyActions} />
<QuickEntryPanel entries={quickEntries} />
<div className="portal-task-secondary-grid">
<ConditionalWorkPanel title="我的进度" items={progressItems} />
<ConditionalWorkPanel title="团队卡点" items={teamBlockers} />
+2 -8
查看文件
@@ -1,8 +1,8 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { Link, useSearchParams } from "react-router-dom";
import { fetchWecomConfig, startWecomBind, unbindWecom, updateNotificationPreferences } from "../api/auth";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { PageHeader } from "../components/common/PageHeader";
import { SectionPanel } from "../components/patterns/SectionPanel";
@@ -58,13 +58,7 @@ function roleLabel(roleCode: string) {
}
function resolveProfileError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${error.message}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
export function ProfilePage() {
+3 -7
查看文件
@@ -1,17 +1,16 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchEmployees } from "../api/employees";
import { archiveProject, createConfidentialDocumentRef, createProjectCommunicationLog, createProjectDelivery, createProjectMember, createProjectMilestone, createProjectRisk, createProjectTask, fetchProject, fetchProjectClosureChecklist, fetchProjectCommunicationLogs, fetchProjectDeliveries, fetchProjectDocumentRefs, fetchProjectExpensesSummary, fetchProjectMembers, fetchProjectMilestones, fetchProjectPurchaseSummary, fetchProjectRisks, fetchProjectTasks, fetchProjectTravelSummary, fetchProjectWeeklyReports, patchProject, patchProjectClosureChecklistItem, patchProjectCommunicationLog, patchProjectMember, patchProjectMilestone, patchProjectRisk, patchProjectTask, type ConfidentialityLevel, type DocumentStorageType, type ProjectCommunicationChannel, type ProjectClosureChecklistStatus, type ProjectDeliveryRecordType, type ProjectDetail, type ProjectMilestoneStatus, type ProjectRiskLevel, type ProjectStatus, type ProjectTaskPriority, type ProjectTaskStatus } from "../api/projects";
import { fetchContractsRegistry } from "../api/contractsRegistry";
import { mapApiErrorMessage } from "../api/errorMessages";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import { MetricCard } from "../components/patterns/MetricCard";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { isApiError } from "../api/types";
import { DateTextInput } from "../components/common/DateTextInput";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
@@ -89,10 +88,7 @@ function riskStatusLabel(value: string) {
}
function resolveError(error: unknown, fallback = "操作失败") {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
function confidentialityLabel(value: ConfidentialityLevel) {
@@ -1094,7 +1090,7 @@ export function ProjectDetailPage() {
<div>交付记录:{counts?.deliveries ?? 0}</div>
<div>资料引用:{counts?.documentRefs ?? 0}</div>
<div className="rounded-lg border border-zinc-200 p-3 text-zinc-600">
项目相关的出差、报销、待办、周报都可以在当前项目下汇总查看;涉密正文与附件仍不进 OA。
项目相关的出差、报销、事项、周报都可以在当前项目下汇总查看;涉密正文与附件仍不进 OA。
</div>
</DetailSideMeta>
+2 -6
查看文件
@@ -1,15 +1,14 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { fetchEmployees } from "../api/employees";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
createProject,
fetchProjects,
type ProjectListItem,
type ProjectStatus,
} from "../api/projects";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions";
import { DateTextInput } from "../components/common/DateTextInput";
@@ -45,10 +44,7 @@ const STATUS_OPTIONS: Array<{ value: "all" | ProjectStatus; label: string }> = [
];
function resolveError(error: unknown, fallback = "操作失败") {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
function summaryLine(item: ProjectListItem) {
+3 -10
查看文件
@@ -1,9 +1,8 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
archivePurchaseOrder,
createPurchaseRequest,
@@ -95,13 +94,7 @@ function purchaseDraftSignature(value: {
}
function resolveError(error: unknown) {
if (isApiError(error)) {
return mapApiErrorMessage(error.code, error.details, error.message);
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function sanitizePurchaseAttachmentDownloadName(value?: string) {
@@ -1430,7 +1423,7 @@ export function PurchaseRequestsPage() {
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-600">
<div>预算池:{detail.budgetPool?.name ?? "未匹配"}</div>
<div>关联项目:{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联项目"}</div>
<div>待办节点:{detail.pendingWorkflow?.task?.stepName ?? "-"}</div>
<div>当前审批节点:{detail.pendingWorkflow?.task?.stepName ?? "-"}</div>
<div>总金额:<span className="font-semibold text-zinc-900">{totalAmountPreview}</span></div>
<div
className={`mt-2 rounded-md border px-2 py-1 ${
@@ -1,7 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchDepartments } from "../api/departments";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
fetchReimbursementApprovalBottleneck,
fetchReimbursementStatsSummary,
@@ -9,7 +9,6 @@ import {
fetchReimbursementTopExpenseTypes,
type ReimbursementStatsQuery,
} from "../api/reimbursements";
import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
@@ -26,13 +25,7 @@ function defaultDateFromString() {
}
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 "加载失败,请稍后重试。";
return resolveUserErrorMessage(error, "加载失败,请稍后重试。");
}
function formatCurrency(value: string | number | null | undefined) {
+16 -83
查看文件
@@ -1,9 +1,8 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useCallback, useEffect, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, ChevronRight } from "lucide-react";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchExpenseTypes } from "../api/expenseTypes";
import type { ExpenseStandardCheck, PrecheckResponse } from "../api/feeControl";
import { fetchProjects } from "../api/projects";
@@ -100,15 +99,7 @@ type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | n
type SelectedInvoiceMap = Record<string, string>;
function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return fallbackMessage;
return resolveUserErrorMessage(error, fallbackMessage);
}
export function ReimbursementDetailPage() {
@@ -247,21 +238,9 @@ export function ReimbursementDetailPage() {
toast.success(actionMessages[params.action] ?? "操作成功");
},
onError: (error) => {
if (isApiError(error)) {
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
setActionError(msg);
toast.error(msg);
return;
}
if (error instanceof Error) {
setActionError(error.message);
toast.error(error.message);
return;
}
setActionError("操作失败,请稍后重试");
toast.error("操作失败,请稍后重试");
const message = buildActionErrorMessage(error, "操作失败,请稍后重试");
setActionError(message);
toast.error(message);
},
});
@@ -315,21 +294,9 @@ export function ReimbursementDetailPage() {
toast.success("明细已保存");
},
onError: (error) => {
if (isApiError(error)) {
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
setItemError(msg);
toast.error(msg);
return;
}
if (error instanceof Error) {
setItemError(error.message);
toast.error(error.message);
return;
}
setItemError("保存明细失败,请稍后重试");
toast.error("保存明细失败,请稍后重试");
const message = buildActionErrorMessage(error, "保存明细失败,请稍后重试");
setItemError(message);
toast.error(message);
},
});
@@ -351,21 +318,9 @@ export function ReimbursementDetailPage() {
toast.success("明细已删除");
},
onError: (error) => {
if (isApiError(error)) {
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
setItemError(msg);
toast.error(msg);
return;
}
if (error instanceof Error) {
setItemError(error.message);
toast.error(error.message);
return;
}
setItemError("删除明细失败,请稍后重试");
toast.error("删除明细失败,请稍后重试");
const message = buildActionErrorMessage(error, "删除明细失败,请稍后重试");
setItemError(message);
toast.error(message);
},
});
@@ -401,11 +356,7 @@ export function ReimbursementDetailPage() {
toast.success("已更新关联项目");
},
onError: (error) => {
const message = isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
: error instanceof Error
? error.message
: "关联项目失败";
const message = buildActionErrorMessage(error, "关联项目失败");
toast.error(message);
},
});
@@ -756,13 +707,7 @@ export function ReimbursementDetailPage() {
}
if (detailQuery.isError || !detailQuery.data) {
const message = isApiError(detailQuery.error)
? `${detailQuery.error.code}: ${mapApiErrorMessage(
detailQuery.error.code,
detailQuery.error.details,
detailQuery.error.message,
)}`
: "加载报销详情失败";
const message = resolveUserErrorMessage(detailQuery.error, "加载报销详情失败");
return (
<div className="card-std space-y-3 p-5">
@@ -893,21 +838,9 @@ export function ReimbursementDetailPage() {
mutation.mutate({ action: "submit" });
} catch (error) {
if (isApiError(error)) {
const message = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
setActionError(message);
toast.error(message);
return;
}
if (error instanceof Error) {
setActionError(error.message);
toast.error(error.message);
return;
}
setActionError("提交前校验失败,请稍后重试");
toast.error("提交前校验失败,请稍后重试");
const message = buildActionErrorMessage(error, "提交前校验失败,请稍后重试");
setActionError(message);
toast.error(message);
}
};
+2 -9
查看文件
@@ -1,10 +1,9 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { QRCodeSVG } from "qrcode.react";
import { fetchReimbursementDetail } from "../api/reimbursements";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { useAuth } from "../auth/AuthContext";
import { CardSkeleton } from "../components/common/CardSkeleton";
import { Button } from "../components/ui/button";
@@ -65,13 +64,7 @@ export function ReimbursementPrintPage() {
}
if (detailQuery.isError || !detailQuery.data) {
const message = isApiError(detailQuery.error)
? `${detailQuery.error.code}: ${mapApiErrorMessage(
detailQuery.error.code,
detailQuery.error.details,
detailQuery.error.message,
)}`
: "加载打印视图失败";
const message = resolveUserErrorMessage(detailQuery.error, "加载打印视图失败");
return (
<div className="card-std space-y-3 p-6">
@@ -1,7 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchExpenseTypes } from "../api/expenseTypes";
import {
createReimbursementTemplate,
@@ -11,7 +11,6 @@ import {
type ReimbursementTemplate,
type ReimbursementTemplateItem,
} from "../api/reimbursementTemplates";
import { isApiError } from "../api/types";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
@@ -35,13 +34,7 @@ type FormState = {
};
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 "请求失败,请稍后重试";
return resolveUserErrorMessage(error, "请求失败,请稍后重试");
}
function emptyForm(expenseTypeId = ""): FormState {
+5 -20
查看文件
@@ -1,8 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { Link, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchMyDelegations } from "../api/delegations";
import { exportReimbursements } from "../api/exports";
import { fetchReimbursementTemplates } from "../api/reimbursementTemplates";
@@ -61,10 +60,7 @@ function formatCurrency(value: number) {
}
function resolveErrorMessage(error: unknown, fallback: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
function resolveEmptyStateCopy(status: ReimbursementStatus) {
@@ -344,14 +340,9 @@ export function ReimbursementsPage() {
}
/>
{createMutation.error && isApiError(createMutation.error) ? (
{createMutation.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{createMutation.error.code}:
{mapApiErrorMessage(
createMutation.error.code,
createMutation.error.details,
createMutation.error.message,
)}
{resolveUserErrorMessage(createMutation.error, "创建报销单失败")}
</p>
) : null}
@@ -500,13 +491,7 @@ export function ReimbursementsPage() {
{listQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(listQuery.error)
? `${listQuery.error.code}: ${mapApiErrorMessage(
listQuery.error.code,
listQuery.error.details,
listQuery.error.message,
)}`
: "加载报销列表失败"}
{resolveUserErrorMessage(listQuery.error, "加载报销列表失败")}
</p>
) : null}
+2 -11
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchDepartments } from "../api/departments";
@@ -15,8 +16,6 @@ import {
type TeamPresenceHistoryItem,
upsertMyPresence,
} from "../api/presence";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
@@ -79,15 +78,7 @@ function offsetDateString(offsetDays: number) {
}
function resolveErrorMessage(error: unknown, fallback = "加载失败") {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
return resolveUserErrorMessage(error, fallback);
}
function resolveEditableStatus(item: TeamPresenceHistoryItem | null): EditablePresenceStatus {
+36 -29
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { cancelTodo, completeTodo, createTodo, fetchTodos } from "../api/todos";
@@ -8,19 +9,22 @@ import { MetricCard } from "../components/patterns/MetricCard";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { toast } from "sonner";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { TEST_IDS } from "../lib/testIds";
function errorMessage(error: unknown, fallback: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
const TODO_STATUS_LABELS = {
todo: "待处理",
doing: "进行中",
done: "已完成",
cancelled: "已取消",
} as const;
export function TodosPage() {
const queryClient = useQueryClient();
const [title, setTitle] = useState("");
@@ -60,9 +64,9 @@ export function TodosPage() {
queryClient.invalidateQueries({ queryKey: ["todos"] }),
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
]);
toast.success("待办已创建");
toast.success("事项已创建");
},
onError: (error) => toast.error(errorMessage(error, "创建待办失败")),
onError: (error) => toast.error(errorMessage(error, "创建事项失败")),
});
const completeMutation = useMutation({
@@ -72,9 +76,9 @@ export function TodosPage() {
queryClient.invalidateQueries({ queryKey: ["todos"] }),
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
]);
toast.success("待办已完成");
toast.success("事项已完成");
},
onError: (error) => toast.error(errorMessage(error, "完成待办失败")),
onError: (error) => toast.error(errorMessage(error, "完成事项失败")),
});
const cancelMutation = useMutation({
@@ -84,17 +88,17 @@ export function TodosPage() {
queryClient.invalidateQueries({ queryKey: ["todos"] }),
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
]);
toast.success("待办已取消");
toast.success("事项已取消");
},
onError: (error) => toast.error(errorMessage(error, "取消待办失败")),
onError: (error) => toast.error(errorMessage(error, "取消事项失败")),
});
return (
<ListWorkspace>
<PageHeader title="待办" subtitle="员工维护个人待办,老板看高优先级和未完成项。支持直接挂到项目。" />
<PageHeader title="我的事项" subtitle="维护个人事项,集中查看高优先级和未完成项,并可关联到项目。" />
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard title="全部待办" value={todoStats.total} note="当前筛选结果" />
<MetricCard title="全部事项" value={todoStats.total} note="当前筛选结果" />
<MetricCard title="待处理" value={todoStats.todo} note="建议优先清空新增事项" tone={todoStats.todo > 0 ? "warning" : "neutral"} />
<MetricCard title="进行中" value={todoStats.doing} note="重点跟进阻塞项" />
<MetricCard title="已完成" value={todoStats.done} note="已完成项会持续留痕" />
@@ -105,12 +109,12 @@ export function TodosPage() {
<FilterBar className="flex flex-wrap items-center justify-between gap-3">
<div className="text-sm text-zinc-500">按状态筛选后,列表只保留当前关注的工作项。</div>
<div className="flex flex-wrap items-center gap-2">
<select className="select-std w-[180px]" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as typeof statusFilter)}>
<Label className="sr-only" htmlFor="todo-status-filter">事项状态</Label>
<select id="todo-status-filter" className="select-std w-[180px]" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as typeof statusFilter)}>
<option value="">全部状态</option>
<option value="todo">todo</option>
<option value="doing">doing</option>
<option value="done">done</option>
<option value="cancelled">cancelled</option>
{Object.entries(TODO_STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<Button type="button" variant="secondary" onClick={() => queryClient.invalidateQueries({ queryKey: ["todos"] })}>
刷新列表
@@ -118,8 +122,8 @@ export function TodosPage() {
</div>
</FilterBar>
<SectionPanel title="待办列表" description={todoItems.length ? `当前共 ${todoItems.length} 条待办` : "暂无待办,建议从当前周计划或项目动作拆出任务。"}>
{todosQuery.isError ? <div className="mb-4 text-sm text-red-600">{errorMessage(todosQuery.error, "加载待办失败")}</div> : null}
<SectionPanel title="事项列表" description={todoItems.length ? `当前共 ${todoItems.length} 条事项` : "暂无事项,建议从当前周计划或项目动作拆出任务。"}>
{todosQuery.isError ? <div className="mb-4 text-sm text-red-600">{errorMessage(todosQuery.error, "加载事项失败")}</div> : null}
<PrimaryTable>
<Table>
<TableHeader>
@@ -136,7 +140,7 @@ export function TodosPage() {
{todosQuery.isLoading ? (
<TableRow>
<TableCell className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
待办加载中...
事项加载中...
</TableCell>
</TableRow>
) : todoItems.length ? (
@@ -149,7 +153,7 @@ export function TodosPage() {
<TableCell>{item.project ? `${item.project.projectNo} · ${item.project.name}` : "-"}</TableCell>
<TableCell>{item.assignee?.name ?? "-"}</TableCell>
<TableCell>{item.priority}</TableCell>
<TableCell>{item.status}</TableCell>
<TableCell>{TODO_STATUS_LABELS[item.status as keyof typeof TODO_STATUS_LABELS] ?? "未知状态"}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
@@ -171,7 +175,7 @@ export function TodosPage() {
) : (
<TableRow>
<TableCell className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
当前没有符合条件的待办。
当前没有符合条件的事项。
</TableCell>
</TableRow>
)}
@@ -181,16 +185,19 @@ export function TodosPage() {
</SectionPanel>
</div>
<SectionPanel title="新增待办" description="建议把周报计划、项目跟进或临时事项都拆成清晰动作。">
<SectionPanel title="新增事项" description="建议把周报计划、项目跟进或临时事项都拆成清晰动作。">
<div className="space-y-4">
<div className="space-y-2">
<Input data-testid={TEST_IDS.todo.createTitle} placeholder="待办标题" value={title} onChange={(event) => setTitle(event.target.value)} />
<Label htmlFor="todo-title">事项标题</Label>
<Input id="todo-title" data-testid={TEST_IDS.todo.createTitle} placeholder="请输入事项标题" value={title} onChange={(event) => setTitle(event.target.value)} />
</div>
<div className="space-y-2">
<Input placeholder="补充说明(可选)" value={description} onChange={(event) => setDescription(event.target.value)} />
<Label htmlFor="todo-description">补充说明(可选)</Label>
<Input id="todo-description" placeholder="补充背景或下一步" value={description} onChange={(event) => setDescription(event.target.value)} />
</div>
<div className="space-y-2">
<select className="select-std" value={projectId} onChange={(event) => setProjectId(event.target.value)}>
<Label htmlFor="todo-project">关联项目(可选)</Label>
<select id="todo-project" className="select-std" value={projectId} onChange={(event) => setProjectId(event.target.value)}>
<option value="">不关联项目</option>
{(projectsQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
@@ -200,7 +207,7 @@ export function TodosPage() {
</select>
</div>
<Button data-testid={TEST_IDS.todo.createSubmit} type="button" onClick={() => createMutation.mutate()} disabled={createMutation.isPending || !title.trim()}>
{createMutation.isPending ? "新建中..." : "新建待办"}
{createMutation.isPending ? "新建中..." : "新建事项"}
</Button>
</div>
</SectionPanel>
+2 -22
查看文件
@@ -1,10 +1,9 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { fetchProjects } from "../api/projects";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import {
createTravelRequest,
fetchTravelRequestDetail,
@@ -54,13 +53,7 @@ type DraftSegment = {
};
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function emptySegment(): DraftSegment {
@@ -306,19 +299,6 @@ export function TravelRequestsPage() {
}
}, [detailQuery.data, selectedId]);
useEffect(() => {
if (activeTab !== "requests" || selectedId || canCreate) {
return;
}
const nextId = listQuery.data?.items?.[0]?.id ?? "";
if (!nextId) {
return;
}
const next = new URLSearchParams(searchParams);
next.set("id", nextId);
setSearchParams(next);
}, [activeTab, canCreate, listQuery.data?.items, searchParams, selectedId, setSearchParams]);
const detail = detailQuery.data ?? null;
const isDraftEditing = Boolean(detail && detail.status === "draft" && detail.permissions.canEditDraft);
+6 -13
查看文件
@@ -1,7 +1,6 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { fetchDepartments } from "../api/departments";
import { fetchEmployees } from "../api/employees";
import {
@@ -123,7 +122,7 @@ export function UserManagementPage() {
toast.success("用户已创建");
},
onError: (err) => {
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "创建用户失败";
const msg = resolveUserErrorMessage(err, "创建用户失败");
setError(msg);
toast.error(msg);
},
@@ -142,7 +141,7 @@ export function UserManagementPage() {
toast.success("状态已更新");
},
onError: (err) => {
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "更新用户状态失败";
const msg = resolveUserErrorMessage(err, "更新用户状态失败");
setError(msg);
toast.error(msg);
},
@@ -157,7 +156,7 @@ export function UserManagementPage() {
toast.success("密码已重置");
},
onError: (err) => {
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "重置密码失败";
const msg = resolveUserErrorMessage(err, "重置密码失败");
setError(msg);
toast.error(msg);
},
@@ -174,7 +173,7 @@ export function UserManagementPage() {
toast.success("角色已更新");
},
onError: (err) => {
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "更新角色失败";
const msg = resolveUserErrorMessage(err, "更新角色失败");
setError(msg);
toast.error(msg);
},
@@ -281,13 +280,7 @@ export function UserManagementPage() {
{listQuery.isLoading ? <TableSkeleton columns={5} rows={5} /> : null}
{listQuery.isError ? (
<p className="text-sm text-red-600">
{isApiError(listQuery.error)
? `${listQuery.error.code}: ${mapApiErrorMessage(
listQuery.error.code,
listQuery.error.details,
listQuery.error.message,
)}`
: "加载用户列表失败"}
{resolveUserErrorMessage(listQuery.error, "加载用户列表失败")}
</p>
) : null}
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
@@ -16,8 +17,6 @@ import { TableSkeleton } from "../components/common/TableSkeleton";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { Label } from "../components/ui/label";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
const TYPE_OPTIONS: Array<{ value: "" | WecomApprovalArchiveType; label: string }> = [
{ value: "", label: "全部类型" },
@@ -43,10 +42,7 @@ const FINDING_OPTIONS: Array<{ value: "" | WecomApprovalArchiveFinding; label: s
];
function resolveError(error: unknown, fallback = "读取企微审批历史失败") {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
function typeLabel(value: string) {
+5 -9
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createTodo } from "../api/todos";
@@ -13,15 +14,10 @@ import { Button } from "../components/ui/button";
import { Textarea } from "../components/ui/textarea";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { toast } from "sonner";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { TEST_IDS } from "../lib/testIds";
function errorMessage(error: unknown, fallback: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
type DraftProjectLink = {
@@ -122,9 +118,9 @@ export function WeeklyReportsPage() {
queryClient.invalidateQueries({ queryKey: ["todos"] }),
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
]);
toast.success("已从周报生成待办");
toast.success("已从周报生成事项");
},
onError: (error) => toast.error(errorMessage(error, "生成待办失败")),
onError: (error) => toast.error(errorMessage(error, "生成事项失败")),
});
return (
@@ -200,7 +196,7 @@ export function WeeklyReportsPage() {
</div>
))}
<div className="flex flex-wrap justify-end gap-2">
<Button data-testid={TEST_IDS.weeklyReport.createTodo} type="button" variant="secondary" onClick={() => createTodoMutation.mutate()} disabled={createTodoMutation.isPending}>转成待办</Button>
<Button data-testid={TEST_IDS.weeklyReport.createTodo} type="button" variant="secondary" onClick={() => createTodoMutation.mutate()} disabled={createTodoMutation.isPending}>转成事项</Button>
<Button data-testid={TEST_IDS.weeklyReport.save} type="button" variant="secondary" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存草稿</Button>
<Button data-testid={TEST_IDS.weeklyReport.submit} type="button" onClick={() => submitMutation.mutate()} disabled={submitMutation.isPending}>提交周报</Button>
</div>
+40 -19
查看文件
@@ -1,9 +1,9 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { Link, Navigate, useLocation, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
approveWorkflowTask,
fetchWorkflowApprovers,
@@ -31,6 +31,7 @@ import { MetricCard } from "../components/patterns/MetricCard";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { Label } from "../components/ui/label";
import { Textarea } from "../components/ui/textarea";
import { approvalRoleLabel, resolvePurchaseDisplayCode, workflowActionLabel } from "../lib/approvalDisplay";
import { formatDisplayDateTime } from "../lib/displayFormat";
@@ -177,14 +178,7 @@ export function WorkflowTaskDetailPage() {
queryClient.invalidateQueries({ queryKey: ["workflow", "task", taskId] }),
]);
}
let msg: string;
if (error instanceof Error && !isApiError(error)) {
msg = error.message;
} else if (isApiError(error)) {
msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
} else {
msg = "动作执行失败,请稍后重试";
}
const msg = resolveUserErrorMessage(error, "动作执行失败,请稍后重试");
setActionError(msg);
toast.error(msg);
},
@@ -238,13 +232,7 @@ export function WorkflowTaskDetailPage() {
}
if (detailQuery.isError || !detailQuery.data) {
const message = isApiError(detailQuery.error)
? `${detailQuery.error.code}: ${mapApiErrorMessage(
detailQuery.error.code,
detailQuery.error.details,
detailQuery.error.message,
)}`
: "加载失败,请返回列表重试";
const message = resolveUserErrorMessage(detailQuery.error, "加载失败,请返回列表重试");
return (
<div className="card-std space-y-3 p-5">
@@ -265,6 +253,11 @@ export function WorkflowTaskDetailPage() {
const canReturn = allowedActions.has("return");
const canReject = allowedActions.has("reject");
const canTransfer = allowedActions.has("transfer");
const transferDisabledReason = !selectedApprover
? "请先选择目标审批人。"
: transferReason.trim().length < 10
? `请填写转审理由,至少 10 个字(还差 ${10 - transferReason.trim().length} 个字)。`
: "转审理由已满足要求。";
const allowedActionLabels = detail.allowedActions.map(workflowActionLabel).join(" / ");
const businessStatusLabel = workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus);
@@ -420,7 +413,11 @@ export function WorkflowTaskDetailPage() {
当前 ¥{item.amount} / 标准 ¥{item.maxAmount}/{item.perUnit}
</div>
<div>超出 ¥{item.exceededAmount} · {item.standardName}</div>
<div>管控模式:{item.controlMode === "block" ? "阻断" : "预警"}</div>
<div>
{item.controlMode === "block"
? "处理影响:当前单据不应继续通过,请申请人调整金额或补充合规处理依据。"
: "处理影响:可继续审批,但请核对超标原因并在审批意见中记录判断。"}
</div>
</div>
))}
</div>
@@ -510,6 +507,13 @@ export function WorkflowTaskDetailPage() {
)}
testId="workflow-task-action-panel"
>
{canReturn || canReject ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs leading-5 text-zinc-600" data-testid="workflow-action-consequence-help">
<div><span className="font-medium text-zinc-800">退回:</span>原单回到申请人修改,保留单号和历史,修改后可重新提交。</div>
<div><span className="font-medium text-zinc-800">驳回:</span>本次流程终止或进入驳回态,申请人通常需要复制或重新发起。</div>
</div>
) : null}
{actionError ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
@@ -521,8 +525,10 @@ export function WorkflowTaskDetailPage() {
{activeSecondaryAction === "reject" ? (
<div className="detail-action-card space-y-2">
<p className="text-sm font-medium text-zinc-800">驳回意见</p>
<Label className="text-sm font-medium text-zinc-800" htmlFor="workflow-task-reject-comment">驳回意见</Label>
<p className="text-xs leading-5 text-zinc-500">驳回会终止本次流程或进入驳回态,申请人通常需要重新发起。</p>
<Textarea
id="workflow-task-reject-comment"
data-testid="workflow-task-reject-comment"
onChange={(event) => setRejectComment(event.target.value)}
rows={3}
@@ -542,8 +548,10 @@ export function WorkflowTaskDetailPage() {
{activeSecondaryAction === "return" ? (
<div className="detail-action-card space-y-2">
<p className="text-sm font-medium text-zinc-800">退回意见</p>
<Label className="text-sm font-medium text-zinc-800" htmlFor="workflow-task-return-comment">退回意见</Label>
<p className="text-xs leading-5 text-zinc-500">退回后原单可修改并重新提交,单号和审批历史会保留。</p>
<Textarea
id="workflow-task-return-comment"
data-testid="workflow-task-return-comment"
onChange={(event) => setReturnComment(event.target.value)}
rows={3}
@@ -564,7 +572,9 @@ export function WorkflowTaskDetailPage() {
{activeSecondaryAction === "transfer" && canTransfer ? (
<div className="detail-action-card space-y-2">
<p className="text-sm font-medium text-zinc-800">转审</p>
<Label className="text-xs text-zinc-600" htmlFor="workflow-task-transfer-approver">目标审批人</Label>
<select
id="workflow-task-transfer-approver"
className="select-std"
data-testid="workflow-task-transfer-approver"
onChange={(event) => setSelectedApprover(event.target.value)}
@@ -577,13 +587,24 @@ export function WorkflowTaskDetailPage() {
</option>
))}
</select>
<Label className="text-xs text-zinc-600" htmlFor="workflow-task-transfer-reason">转审理由</Label>
<Textarea
id="workflow-task-transfer-reason"
aria-describedby="workflow-task-transfer-help"
data-testid="workflow-task-transfer-reason"
onChange={(event) => setTransferReason(event.target.value)}
rows={3}
value={transferReason}
/>
<p
id="workflow-task-transfer-help"
className={transferReason.trim().length >= 10 && selectedApprover ? "text-xs text-emerald-700" : "text-xs text-amber-700"}
data-testid="workflow-task-transfer-disabled-reason"
>
{transferDisabledReason}
</p>
<Button
aria-describedby="workflow-task-transfer-help"
data-testid="workflow-task-transfer"
variant="secondary"
disabled={
+3 -10
查看文件
@@ -1,7 +1,6 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { Link, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
fetchWorkflowTasks,
fetchWorkflowTaskSummary,
@@ -257,7 +256,7 @@ export function WorkflowTasksPage() {
return (
<ListWorkspace>
<WorkspaceHeader title="待我处理" />
<WorkspaceHeader title="待我审批" />
<FilterBar className="ops-command-bar !p-3 md:!p-3.5">
<div className="ops-command-bar__row">
@@ -339,13 +338,7 @@ export function WorkflowTasksPage() {
{query.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(query.error)
? `${query.error.code}: ${mapApiErrorMessage(
query.error.code,
query.error.details,
query.error.message,
)}`
: "加载待办失败,请稍后重试"}
{resolveUserErrorMessage(query.error, "加载审批任务失败,请稍后重试")}
</p>
) : null}
+4 -9
查看文件
@@ -1,8 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchSystemNavigation, fetchWorkspaceSettings, updateWorkspaceSettings } from "../api/workspace";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
@@ -16,7 +15,7 @@ const MODULE_LABELS: Record<string, string> = {
reimbursements: "报销",
assets: "资产",
weeklyReports: "周报",
todos: "待办",
todos: "我的事项",
announcements: "公告",
projects: "项目",
workspace: "工作区设置",
@@ -32,7 +31,7 @@ const MODULE_LABELS: Record<string, string> = {
departments: "部门管理",
masterData: "主数据",
notificationChannels: "通知渠道",
workflowTasks: "待我处理",
workflowTasks: "待我审批",
dashboard: "仪表盘",
purchaseRequests: "采购",
travelRequests: "差旅申请",
@@ -71,11 +70,7 @@ export function WorkspaceSetupPage() {
toast.success("工作区设置已保存");
},
onError: (error) => {
const message = isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
: error instanceof Error
? error.message
: "保存失败";
const message = resolveUserErrorMessage(error, "保存失败");
toast.error(message);
},
});
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
import { useEffect, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {
@@ -6,7 +7,6 @@ import {
type UserImportApplyResult,
type UserImportDryRunResult,
} from "../../api/users";
import { mapApiErrorMessage } from "../../api/errorMessages";
import { isApiError } from "../../api/types";
import { downloadBlob } from "../../lib/download";
import { Button } from "../../components/ui/button";
@@ -42,10 +42,7 @@ function toCsvCell(value: string) {
}
function formatErrorMessage(error: unknown, fallback: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
function parsePreviewFromError(error: unknown, sourceFilename: string): UserImportDryRunResult | null {
@@ -99,7 +99,7 @@ test("E2E 涉密边界:敏感摘要不可搜索,编号可命中", async ({ p
await expect(page.getByText(sensitivePhrase)).toHaveCount(0);
await page.getByTestId("global-search-trigger").click();
const searchInput = page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、待办");
const searchInput = page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、事项");
await searchInput.fill(sensitivePhrase);
await expect(page.getByRole("button", { name: new RegExp(contractNo) })).toHaveCount(0);
@@ -960,7 +960,7 @@ test.describe("MIF7 移动信息架构整改", () => {
}
});
test("审批人首页只保留轻待办摘要,审批页按待我处理和已处理分组", async ({ browser }) => {
test("审批人首页只保留轻待办摘要,审批页按待我审批和已处理分组", async ({ browser }) => {
const notifications = buildNotifications();
const mobile = await newMobilePage(browser, 390);
try {
@@ -968,7 +968,7 @@ test.describe("MIF7 移动信息架构整改", () => {
await loginForMobile(mobile.page, "manager");
await mobile.page.goto("/mobile");
await expect(mobile.page.getByTestId("mobile-nav-approvals-label")).toHaveText("待办");
await expect(mobile.page.getByTestId("mobile-nav-approvals-label")).toHaveText("待我审批");
await expect(mobile.page.getByTestId("mobile-pending-summary")).toHaveCount(1);
await expect(mobile.page.getByTestId("mobile-notifications")).not.toContainText("报销待审批");
await expect(mobile.page.getByTestId("mobile-action-approvals")).toHaveCount(0);
@@ -976,7 +976,7 @@ test.describe("MIF7 移动信息架构整改", () => {
await expect(mobile.page.getByTestId("mobile-pending-summary-count")).toHaveText("1");
await expect(mobile.page.getByTestId("mobile-pending-summary-primary")).toHaveCount(1);
await expect(mobile.page.getByTestId("mobile-pending-summary-primary")).not.toContainText("待处理");
await expect(mobile.page.getByRole("link", { name: "查看全部待办" })).toBeVisible();
await expect(mobile.page.getByRole("link", { name: "查看全部审批" })).toBeVisible();
await mobile.page.screenshot({ path: screenshotPath("home-manager-pending-summary"), fullPage: true });
const badgeMetrics = await mobile.page.evaluate(() => {
@@ -1026,7 +1026,7 @@ test.describe("MIF7 移动信息架构整改", () => {
await expectApprovalActionsStable(mobile.page);
await mobile.page.goto("/mobile/approvals");
await expect(mobile.page.getByRole("tab", { name: "待我处理" })).toBeVisible();
await expect(mobile.page.getByRole("tab", { name: "待我审批" })).toBeVisible();
await expect(mobile.page.getByRole("tab", { name: "已处理" })).toBeVisible();
await expect(mobile.page.getByTestId("mobile-approvals-queue")).toHaveCount(0);
await mobile.page.getByRole("tab", { name: "已处理" }).click();
+2 -2
查看文件
@@ -42,7 +42,7 @@ test("E2E 全局搜索:按钮打开后按分组展示结果并支持点击跳
await page.getByTestId("global-search-trigger").click();
await expect(page.getByRole("heading", { name: "全局搜索" })).toBeVisible();
const searchResponse = page.waitForResponse((response) => response.url().includes("/api/search?"));
await page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、待办").fill("海音");
await page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、事项").fill("海音");
await searchResponse;
await expect(page.getByRole("heading", { name: "人员" })).toBeVisible();
@@ -69,7 +69,7 @@ test("E2E 全局搜索:查看更多结果会关闭弹窗并跳到分组列表
await expect(page.getByRole("heading", { name: "全局搜索" })).toBeVisible();
const searchResponse = page.waitForResponse((response) => response.url().includes("/api/search?"));
await page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、待办").fill("海音");
await page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、事项").fill("海音");
await searchResponse;
const documentsSection = page.locator("section").filter({ has: page.getByRole("heading", { name: "业务单据" }) });
@@ -0,0 +1,101 @@
import { readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
import { ApiError } from "../../src/api/types";
import { resolveErrorMessage } from "../../src/lib/resolveErrorMessage";
function source(relativePath: string) {
return readFileSync(path.resolve(process.cwd(), relativePath), "utf8");
}
function sourceFiles(relativeDirectory: string): string[] {
const absoluteDirectory = path.resolve(process.cwd(), relativeDirectory);
return readdirSync(absoluteDirectory, { withFileTypes: true }).flatMap((entry) => {
const relativePath = path.join(relativeDirectory, entry.name);
if (entry.isDirectory()) {
return sourceFiles(relativePath);
}
return /\.(ts|tsx)$/.test(entry.name) ? [relativePath] : [];
});
}
test("R12 普通用户错误消息只经过共享解析入口且不显示内部 code", () => {
const resolver = source("src/lib/resolveErrorMessage.ts");
expect(resolver).toContain("mapApiErrorMessage(error.code, error.details, error.message)");
expect(resolver).not.toMatch(/`\$\{error\.code\}:/);
const ordinaryFiles = ["src/pages", "src/components", "src/lib"]
.flatMap(sourceFiles)
.filter((file) => file !== "src/pages/AuditLogsPage.tsx");
const residuals = ordinaryFiles.filter((file) => {
const text = source(file);
return /\$\{[^}]*\.code\}:|\{[^}\n]*\.code\}:/.test(text);
});
const directMapperConsumers = ordinaryFiles.filter((file) => {
if (file === "src/lib/resolveErrorMessage.ts") return false;
return source(file).includes("mapApiErrorMessage");
});
const rawThrownMessageConsumers = ordinaryFiles.filter((file) => {
if (file === "src/lib/resolveErrorMessage.ts") return false;
return /\b(?:error|err)\.message\b/.test(source(file));
});
expect(residuals).toEqual([]);
expect(directMapperConsumers).toEqual([]);
expect(rawThrownMessageConsumers).toEqual([]);
expect(resolveErrorMessage(new ApiError({ status: 400, code: "VALID_001", message: "VALID_001: 请补充审批意见" }))).toBe("请补充审批意见");
expect(resolveErrorMessage(new Error("500: 保存失败"), "保存失败,请稍后重试")).toBe("保存失败");
expect(resolveErrorMessage(new Error("Failed to fetch /api/workflow/tasks"), "加载审批任务失败")).toBe("加载审批任务失败");
expect(resolveErrorMessage(new Error("读取失败 /tmp/private/payload.json"), "读取资料失败")).toBe("读取资料失败");
});
test("R10 移动审批与移动报销复用同一底部动作容器", () => {
const sharedActionBar = source("src/components/patterns/MobileStickyActionBar.tsx");
const businessEditTemplate = source("src/components/patterns/BusinessEditTemplate.tsx");
const mobileApprovals = source("src/pages/MobileApprovalsPage.tsx");
const mobileReimbursement = source("src/pages/MobileReimbursementDetailPage.tsx");
expect(sharedActionBar).toContain("mobile-sticky-action-surface fixed inset-x-0 bottom-0");
expect(sharedActionBar).toContain("env(safe-area-inset-bottom,0px)");
expect(businessEditTemplate).toContain("MOBILE_STICKY_ACTION_PAGE_CLASS");
expect(businessEditTemplate).toContain("<MobileStickyActionBar");
expect(mobileApprovals).toContain("<MobileStickyActionBar");
expect(mobileReimbursement).toContain("<MobileStickyActionBar");
expect(mobileApprovals).not.toContain('className="mobile-sticky-action-surface fixed');
expect(mobileReimbursement).not.toContain('className="mobile-sticky-action-surface fixed');
});
test("R1/R6 未知状态和个人事项状态不回落到技术枚举", () => {
const statusBadge = source("src/components/common/StatusBadge.tsx");
const todosPage = source("src/pages/TodosPage.tsx");
expect(statusBadge).toContain('label: "未知状态"');
expect(statusBadge).not.toContain('label: status, className: "badge-neutral"');
expect(todosPage).toContain('todo: "待处理"');
expect(todosPage).toContain('doing: "进行中"');
expect(todosPage).toContain('done: "已完成"');
expect(todosPage).toContain('cancelled: "已取消"');
});
test("R4 WelcomeDialog 只在存在权限过滤后的入口时打开", () => {
const portalHome = source("src/pages/PortalHomePage.tsx");
expect(portalHome).toContain("open={showWelcome && welcomeLinks.length > 0}");
expect(portalHome).toContain("if (roleCapabilities.canApproveTasks && hasAnyPermission(permissions, APPROVAL_PORTAL_PERMISSIONS))");
});
test("R9/R11 工作台加载失败提供重试且主要入口具有焦点样式", () => {
const portalHome = source("src/pages/PortalHomePage.tsx");
expect(portalHome).toContain('data-testid="portal-load-error"');
expect(portalHome).toContain('data-testid="portal-load-retry"');
expect(portalHome).toContain("portalQuery.refetch()");
expect(portalHome).toContain("focus-visible:ring-2 focus-visible:ring-blue-600");
});
test("R14 差旅列表不再自动选择第一条记录", () => {
const travelRequests = source("src/pages/TravelRequestsPage.tsx");
expect(travelRequests).not.toContain("listQuery.data?.items?.[0]?.id");
expect(travelRequests).toContain('const [searchParams, setSearchParams] = useSearchParams()');
});
+67 -9
查看文件
@@ -189,7 +189,7 @@ function mockNavigation(page: import("@playwright/test").Page, mode: "employee"
? {
main: [
{ key: "portal", href: "/portal", label: "工作台", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "workflow", href: "/workflow/tasks", label: "待我处理", icon: "fact_check", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "workflow", href: "/workflow/tasks", label: "待我审批", icon: "fact_check", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "expense", href: "/reimbursements", label: "报销", icon: "request_quote", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "purchase", href: "/purchase-requests", label: "采购", icon: "shopping_cart", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "contracts", href: "/contracts-registry", label: "合同", icon: "description", placement: "main", permissionCodes: [], featureFlagKey: null },
@@ -262,14 +262,37 @@ function mockAttendanceToday(page: import("@playwright/test").Page) {
});
}
function mockPortalHome(page: import("@playwright/test").Page, mode: "employee" | "manager") {
function mockPortalHome(
page: import("@playwright/test").Page,
mode: "employee" | "manager",
options: { failuresBeforeSuccess?: number } = {},
) {
let requestCount = 0;
return page.route("**/api/portal/home", async (route) => {
requestCount += 1;
if (requestCount <= (options.failuresBeforeSuccess ?? 0)) {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({
requestId: "mock-request",
error: {
code: "SYS_001",
message: "GET /api/portal/home failed",
details: {},
},
}),
});
return;
}
const data = mode === "manager"
? {
workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" },
greeting: { name: "李研发" },
cards: [
{ key: "pending_approvals", title: "待我处理", value: 3, href: "/workflow/tasks", severity: "warning" },
{ key: "pending_approvals", title: "待我审批", value: 3, href: "/workflow/tasks", severity: "warning" },
{ key: "team_presence", title: "今日团队状态", value: "6/8 在岗", href: "/team", severity: "warning" },
{ key: "weekly_missing", title: "未提交周报", value: 2, href: "/weekly-reports", severity: "warning" },
{ key: "weekly_help", title: "需协助员工", value: 1, href: "/weekly-reports", severity: "high" },
@@ -307,7 +330,7 @@ function mockPortalHome(page: import("@playwright/test").Page, mode: "employee"
workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" },
greeting: { name: "陈工程" },
cards: [
{ key: "my_todos", title: "我的待办", value: 4, href: "/todos", severity: "warning" },
{ key: "my_todos", title: "我的事项", value: 4, href: "/todos", severity: "warning" },
{ key: "today_presence", title: "今日状态", value: "在岗", href: "/team", severity: "normal" },
{ key: "my_weekly_report", title: "本周周报", value: "待填写", href: "/weekly-reports", severity: "warning" },
{ key: "my_projects", title: "我的项目", value: 2, href: "/projects", severity: "warning" },
@@ -524,12 +547,47 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
await expect(page.getByTestId("portal-work-summary")).toContainText("待处理 7");
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expect(page.getByRole("link", { name: /待办事项 4 条.*查看待办/ })).toBeVisible();
await expect(page.getByRole("link", { name: /我的事项 4 条.*查看事项/ })).toBeVisible();
await expect(page.getByRole("link", { name: /本周周报待填写.*填写周报/ })).toBeVisible();
await expect(page.getByText("当前焦点")).not.toBeVisible();
await expect(page.getByText("下一步", { exact: true })).not.toBeVisible();
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible();
await expect(page.locator("summary").filter({ hasText: "更多入口" })).not.toBeVisible();
const portalSectionOrder = await page.getByRole("heading", { name: /今日待处理|常用入口/ }).evaluateAll((headings) =>
headings.map((heading) => heading.textContent?.trim()),
);
expect(portalSectionOrder).toEqual(["今日待处理", "常用入口"]);
const todoLink = page.getByRole("link", { name: /我的事项 4 条.*查看事项/ });
const weeklyLink = page.getByRole("link", { name: /本周周报待填写.*填写周报/ });
await todoLink.focus();
await expect(todoLink).toBeFocused();
await page.keyboard.press("Tab");
await expect(weeklyLink).toBeFocused();
});
test("Portal 加载失败持久显示自然中文并可重新加载", async ({ page }) => {
await mockPasswordLogin(page);
await mockNavigation(page, "employee");
await mockPortalHome(page, "employee", { failuresBeforeSuccess: 2 });
await mockAttendanceToday(page);
await mockNotificationList(page);
await mockMe(page, ["employee"], "陈工程");
await login(page, EMPLOYEE);
const errorRegion = page.getByTestId("portal-load-error");
await expect(errorRegion).toContainText("服务暂不可用,请稍后重试或联系管理员");
await expect(errorRegion).not.toContainText("SYS_001");
await expect(errorRegion).not.toContainText("/api/portal/home");
const retryButton = page.getByTestId("portal-load-retry");
await retryButton.focus();
await expect(retryButton).toBeFocused();
await retryButton.click();
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expect(errorRegion).toHaveCount(0);
});
test("Portal 管理视角按角色分发待处理和团队卡点", async ({ page }) => {
@@ -543,7 +601,7 @@ test("Portal 管理视角按角色分发待处理和团队卡点", async ({ page
await login(page, MANAGER);
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expect(page.getByRole("link", { name: /待我处理 3 条.*去处理/ })).toBeVisible();
await expect(page.getByRole("link", { name: /待我审批 3 条.*去处理/ })).toBeVisible();
await expect(page.getByRole("heading", { name: "团队卡点" })).toBeVisible();
await expect(page.getByRole("link", { name: /项目风险未关闭.*处理风险/ })).toBeVisible();
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible();
@@ -653,9 +711,9 @@ test("Portal 新员工只显示轻量欢迎入口,不再显示快速上手清
const welcomeDialog = page.getByTestId(TEST_IDS.onboarding.welcomeDialog);
await expect(welcomeDialog).toBeVisible();
await expect(welcomeDialog.getByRole("heading", { name: "常用入口" })).toBeVisible();
await expect(welcomeDialog.getByRole("link")).toHaveCount(3);
await expect(welcomeDialog.getByRole("link", { name: /处理待办/ })).toBeVisible();
await expect(welcomeDialog.getByRole("link", { name: /提交报销/ })).toBeVisible();
await expect(welcomeDialog.getByRole("link")).toHaveCount(2);
await expect(welcomeDialog.getByRole("link", { name: /待我审批/ })).toHaveCount(0);
await expect(welcomeDialog.getByRole("link", { name: /报销/ })).toBeVisible();
await expect(welcomeDialog.getByRole("link", { name: /请假/ })).toBeVisible();
await expect(welcomeDialog.getByRole("link", { name: /个人设置/ })).not.toBeVisible();
await expect(welcomeDialog.getByRole("link", { name: /通知中心/ })).not.toBeVisible();
+2 -2
查看文件
@@ -81,7 +81,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
await logout(page, EMPLOYEE.name);
await login(page, MANAGER);
await (await openNavLink(page, "待我处理")).click();
await (await openNavLink(page, "待我审批")).click();
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
const managerTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementId }).first();
await expect(managerTaskRow).toBeVisible();
@@ -94,7 +94,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
// 6) finance approve, then pay from the payment batch context
await login(page, FINANCE);
await (await openNavLink(page, "待我处理")).click();
await (await openNavLink(page, "待我审批")).click();
const financeTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementId }).first();
await expect(financeTaskRow).toBeVisible();
await financeTaskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
@@ -225,10 +225,10 @@ test("R70 自动辅助试运行:员工、经理、管理员路径巡检", asyn
}
});
await runStep("manager", "待我处理与快捷审批入口", async () => {
await runStep("manager", "待我审批与快捷审批入口", async () => {
await logout(page, EMPLOYEE.name);
await login(page, MANAGER);
await (await openNavLink(page, "待我处理")).click();
await (await openNavLink(page, "待我审批")).click();
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
if (managerTaskId) {
await expect(page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: managerTaskId }).first()).toBeVisible();
@@ -163,7 +163,7 @@ test("R70 深层自动辅助试运行:周报待办、公告团队状态、真
}, { timeout: 30_000 });
await page.getByTestId(TEST_IDS.weeklyReport.createTodo).click();
await createTodoResponse;
await expect(page.getByText("已从周报生成待办")).toBeVisible();
await expect(page.getByText("已从周报生成事项")).toBeVisible();
const submitResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
@@ -177,7 +177,7 @@ test("R70 深层自动辅助试运行:周报待办、公告团队状态、真
const todoRow = page.getByTestId(TEST_IDS.todo.row).filter({ hasText: uniqueToken }).first();
await expect(todoRow).toBeVisible({ timeout: 30_000 });
await todoRow.getByTestId(TEST_IDS.todo.complete).click();
await expect(todoRow).toContainText("done", { timeout: 30_000 });
await expect(todoRow).toContainText("已完成", { timeout: 30_000 });
return `weekStart=${weekStart}; token=${uniqueToken}`;
});
@@ -209,7 +209,7 @@ test("R70 深层自动辅助试运行:周报待办、公告团队状态、真
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname === "/api/search" && response.ok();
}, { timeout: 30_000 });
await page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、待办").fill(String(suffix));
await page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、事项").fill(String(suffix));
await searchResponse;
const resultButton = page.getByRole("button", { name: new RegExp(announcementTitle) }).first();
@@ -609,7 +609,7 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
async function approveTaskViaBrowser(role: LiveSmokeRole, taskId: string, expectedText: RegExp | string) {
const user = await loginRole(page, role);
try {
await (await openNavLink(page, "待我处理")).click();
await (await openNavLink(page, "待我审批")).click();
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
await page.goto(`/workflow/tasks/${taskId}`);
await workflowDetail.assertLoaded();
@@ -178,7 +178,7 @@ async function mockWave2Apis(page: Page) {
sections: {
main: [
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("workflow", "/workflow/tasks", "待我处理", "fact_check"),
navigationItem("workflow", "/workflow/tasks", "待我审批", "fact_check"),
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
],
admin: [],
@@ -206,7 +206,7 @@ async function mockWave2Apis(page: Page) {
workspace: { id: "workspace-wave2", persisted: true, profile: "small_team_internal" },
greeting: { name: visualMe.name },
cards: [
{ key: "pending_approvals", title: "待我处理", value: 3, href: "/workflow/tasks", severity: "warning" },
{ key: "pending_approvals", title: "待我审批", value: 3, href: "/workflow/tasks", severity: "warning" },
{ key: "team_presence", title: "团队状态", value: "7/9 在岗", href: "/team", severity: "normal" },
{ key: "weekly_missing", title: "周报缺口", value: 1, href: "/weekly-reports", severity: "warning" },
{ key: "weekly_help", title: "需协助", value: 1, href: "/weekly-reports", severity: "normal" },
@@ -317,7 +317,7 @@ test("PC 核心工作流 Wave2 视觉截图", async ({ page }) => {
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(/\/portal/);
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expect(page.getByRole("link", { name: /待我处理.*去处理/ })).toBeVisible();
await expect(page.getByRole("link", { name: /待我审批.*去处理/ })).toBeVisible();
await capture(page, "02-portal");
await page.goto("/reimbursements?status=reviewing&scope=all");
@@ -680,7 +680,7 @@ async function mockWave3Apis(page: Page) {
sections: {
main: [
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("workflow", "/workflow/tasks", "待我处理", "fact_check"),
navigationItem("workflow", "/workflow/tasks", "待我审批", "fact_check"),
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
navigationItem("notifications", "/notifications", "通知", "notifications"),
],
@@ -1274,6 +1274,12 @@ test("报销详情固定使用上海时区并收敛只读信息", async ({ page
await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
const pcInvoicePreview = page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看发票原件/ });
const pcAttachmentPreview = page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看补充附件/ });
await pcInvoicePreview.focus();
await expect(pcInvoicePreview).toBeFocused();
await page.keyboard.press("Tab");
await expect(pcAttachmentPreview).toBeFocused();
await expect(page.getByTestId("reimbursement-workflow-progress")).toContainText("负责人复核");
await expect(page.getByTestId("reimbursement-workflow-progress")).toContainText("付款复核");
await expect(page.getByTestId("reimbursement-workflow-progress")).not.toContainText(/第 \d+ 步/);
@@ -1294,6 +1300,12 @@ test("报销详情固定使用上海时区并收敛只读信息", async ({ page
await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1);
await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
const mobileInvoicePreview = page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看发票原件/ });
const mobileAttachmentPreview = page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看补充附件/ });
await mobileInvoicePreview.focus();
await expect(mobileInvoicePreview).toBeFocused();
await page.keyboard.press("Tab");
await expect(mobileAttachmentPreview).toBeFocused();
await expect(page.getByTestId("mobile-reimbursement-processing-progress")).toContainText("付款完成");
await expect(page.getByTestId("mobile-reimbursement-actions")).toHaveCount(0);
await expect(page.getByText("PDF/JPG/PNG/WebP,单次最多 5 个")).toHaveCount(0);
@@ -1486,7 +1498,7 @@ test("Wave3 移动关键页视觉截图", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/mobile");
await expect(page.getByRole("heading", { name: "今天待办" })).toBeVisible();
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expect(page.getByTestId("mobile-action-grid")).toBeVisible();
await expect(page.locator("table")).toHaveCount(0);
await expectNoHorizontalOverflow(page);
@@ -1514,7 +1526,7 @@ test("Wave3 移动关键页视觉截图", async ({ page }) => {
await page.screenshot({ path: screenshotPath("06-mobile-reimbursement-detail.png"), fullPage: true });
await page.goto("/mobile/approvals");
await expect(page.locator("h1", { hasText: "待我处理" })).toBeVisible();
await expect(page.locator("h1", { hasText: "待我审批" })).toBeVisible();
await expect(page.getByTestId("mobile-approvals-focus")).toBeVisible();
await expect(page.getByTestId("mobile-approvals-list")).toBeVisible();
await expect(page.locator("table")).toHaveCount(0);
@@ -244,7 +244,7 @@ async function expectMobileApprovalEntry(browser: Browser, user: E2EUser, canApp
const approvalTab = page.getByTestId("mobile-nav-approvals");
if (canApprove) {
await expect(approvalTab).toHaveAttribute("href", "/mobile/approvals");
await expect(page.getByTestId("mobile-nav-approvals-label")).toHaveText("待办");
await expect(page.getByTestId("mobile-nav-approvals-label")).toHaveText("待我审批");
await expect(page.getByTestId("mobile-action-grid").locator('a[href="/mobile/approvals"]')).toHaveCount(0);
await page.goto("/mobile/approvals");
await expect(page).not.toHaveURL(/\/403/);
+1 -1
查看文件
@@ -86,7 +86,7 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
await logout(page, EMPLOYEE.name);
await login(page, MANAGER);
await (await openNavLink(page, "待我处理")).click();
await (await openNavLink(page, "待我审批")).click();
await expect(page.locator(`a[href="/workflow/tasks/${currentTaskId}"]`).first()).toHaveCount(1, { timeout: 30_000 });
await page.goto(`/workflow/tasks/${currentTaskId}`);
await expect(page.getByTestId("workflow-task-overview")).toContainText("采购申请");
@@ -526,7 +526,7 @@ test.describe("R124 企业微信移动入口矩阵", () => {
await mobile.page.getByTestId("mobile-nav-approvals").click();
await expect(mobile.page).toHaveURL(/\/mobile\/approvals$/);
await expect(mobile.page.getByRole("heading", { level: 1, name: "待我处理", exact: true })).toBeVisible();
await expect(mobile.page.getByRole("heading", { level: 1, name: "待我审批", exact: true })).toBeVisible();
await expect(mobile.page.locator(".mobile-status-slot--approval").first()).toContainText("待处理");
await expectMobilePageContract(mobile.page);
@@ -537,7 +537,7 @@ test.describe("R6 移动响应式体系", () => {
await expect(page.getByTestId("mobile-action-grid")).toBeVisible();
const homeMetrics = await expectMobilePageStable(page, device.label, `${device.key}-mobile-home`);
expect(homeMetrics.actionItems.length, `${device.label} home action count`).toBeGreaterThanOrEqual(2);
const bottomTabTexts = new Set(["首页", "待办", "进度", "报销", "我的"]);
const bottomTabTexts = new Set(["首页", "待我审批", "进度", "报销", "我的"]);
for (const action of homeMetrics.actionItems) {
expect(bottomTabTexts.has(action.text), `${device.label} ${action.text} not duplicate bottom tab`).toBe(false);
expect(action.width, `${device.label} ${action.text} action width`).toBeGreaterThanOrEqual(44);
@@ -526,7 +526,7 @@ async function exerciseMobileRendering(page: Page, key: string, label: string) {
await expect(page.getByTestId("mobile-action-grid")).toBeVisible();
const homeMetrics = await expectRenderingStable(page, label, `${key}-mobile-home`);
expect(homeMetrics.actionItems.length, `${label} action count`).toBeGreaterThanOrEqual(2);
const bottomTabLabels = new Set(["首页", "待办", "进度", "报销", "我的"]);
const bottomTabLabels = new Set(["首页", "待我审批", "进度", "报销", "我的"]);
for (const action of homeMetrics.actionItems) {
expect(action.visible, `${label} ${action.key} action visible`).toBe(true);
expect(bottomTabLabels.has(action.key), `${label} ${action.key} not bottom tab key`).toBe(false);
@@ -214,7 +214,7 @@ async function mockR89Apis(page: Page) {
navigationItem("team", "/team", "团队", "groups"),
navigationItem("leave", "/leave-requests", "请假记录", "event_available"),
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
navigationItem("workflow", "/workflow/tasks", "待我处理", "fact_check"),
navigationItem("workflow", "/workflow/tasks", "待我审批", "fact_check"),
navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"),
],
admin: [],
@@ -73,7 +73,7 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
await logout(page, EMPLOYEE.name);
await login(page, MANAGER);
await (await openNavLink(page, "待我处理")).click();
await (await openNavLink(page, "待我审批")).click();
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
const managerTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementDisplayCode }).first();
await expect(managerTaskRow).toBeVisible();
@@ -86,7 +86,7 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
await logout(page, MANAGER.name);
await login(page, secondApprovalUser);
await (await openNavLink(page, "待我处理")).click();
await (await openNavLink(page, "待我审批")).click();
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
const financeTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementDisplayCode }).first();
await expect(financeTaskRow).toBeVisible();
+4 -4
查看文件
@@ -117,7 +117,7 @@ async function mockVisualApis(page: Page) {
sections: {
main: [
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("approvals", "/workflow/tasks", "待我处理", "fact_check"),
navigationItem("approvals", "/workflow/tasks", "待我审批", "fact_check"),
navigationItem("reimbursements", "/reimbursements", "报销", "request_quote"),
navigationItem("purchase", "/purchase-requests", "采购", "shopping_cart"),
navigationItem("contracts", "/contracts-registry", "合同", "description"),
@@ -410,15 +410,15 @@ test.describe("visual shell", () => {
await loginVisualUser(page);
await page.goto("/mobile");
await expect(page.getByRole("heading", { name: "今天" })).toBeVisible();
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expect(page.getByTestId("mobile-today-focus")).toBeVisible();
await expect(page.getByTestId("mobile-action-capture")).toBeVisible();
await expect(page.getByTestId("mobile-attendance-summary-card")).toBeVisible();
await expect(page.getByTestId("mobile-attendance-summary-card")).toHaveCount(0);
await attachScreenshot(page, testInfo, "mobile-today.png");
});
test("待我处理手机卡片布局基线", async ({ page }, testInfo) => {
test("待我审批手机卡片布局基线", async ({ page }, testInfo) => {
await mockVisualApis(page);
await page.setViewportSize({ width: 390, height: 844 });
await loginVisualUser(page);
+3 -3
查看文件
@@ -9,7 +9,7 @@ function formatWeekStart(offsetDays: number) {
return value.toISOString().slice(0, 10);
}
test("E2E 周报待办联动:保存 → 生成待办 → 提交 → 完成待办", async ({ page }) => {
test("E2E 周报事项联动:保存 → 生成事项 → 提交 → 完成事项", async ({ page }) => {
const uniqueToken = `Playwright 周报联动 ${Date.now()}`;
const weekStart = formatWeekStart(35 + Number(String(Date.now()).slice(-1)));
@@ -41,7 +41,7 @@ test("E2E 周报待办联动:保存 → 生成待办 → 提交 → 完成待
});
await page.getByTestId(TEST_IDS.weeklyReport.createTodo).click();
await createTodoResponse;
await expect(page.getByText("已从周报生成待办")).toBeVisible();
await expect(page.getByText("已从周报生成事项")).toBeVisible();
const submitResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
@@ -56,7 +56,7 @@ test("E2E 周报待办联动:保存 → 生成待办 → 提交 → 完成待
const todoRow = page.getByTestId(TEST_IDS.todo.row).filter({ hasText: uniqueToken }).first();
await expect(todoRow).toBeVisible({ timeout: 30_000 });
await todoRow.getByTestId(TEST_IDS.todo.complete).click();
await expect(todoRow).toContainText("done", { timeout: 30_000 });
await expect(todoRow).toContainText("已完成", { timeout: 30_000 });
await logout(page, EMPLOYEE.name);
});
@@ -60,7 +60,7 @@ const pendingTask = {
duration: "1 天",
businessStatus: "reviewing",
},
allowedActions: ["approve", "reject", "return"],
allowedActions: ["approve", "reject", "return", "transfer"],
};
const pendingTaskDetail = {
@@ -190,7 +190,7 @@ async function mockWorkflowConfirmApis(page: Page) {
sections: {
main: [
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("workflow", "/workflow/tasks", "待我处理", "fact_check"),
navigationItem("workflow", "/workflow/tasks", "待我审批", "fact_check"),
],
admin: [],
backup: [],
@@ -262,7 +262,12 @@ async function mockWorkflowConfirmApis(page: Page) {
}
if (path === "/api/workflow/approvers") {
await fulfillJson(route, { page: 1, pageSize: 100, total: 0, items: [] });
await fulfillJson(route, {
page: 1,
pageSize: 100,
total: 1,
items: [{ id: "employee-transfer-1", employeeNo: "E-transfer", name: "周审批" }],
});
return;
}
@@ -293,6 +298,8 @@ test.describe("R7E 审批动作二次确认", () => {
await login(page, MANAGER);
await page.goto(`/workflow/tasks/${pendingTask.id}`);
await expect(page.getByTestId("workflow-task-overview")).toBeVisible();
await expect(page.getByTestId("workflow-action-consequence-help")).toContainText("退回:");
await expect(page.getByTestId("workflow-action-consequence-help")).toContainText("驳回:");
await page.getByTestId("workflow-task-approve").click();
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
@@ -306,6 +313,27 @@ test.describe("R7E 审批动作二次确认", () => {
await page.getByTestId("workflow-action-confirm-cancel").click();
expect(actionCalls.reject).toBe(0);
await page.locator("summary", { hasText: "更多" }).click();
await page.getByTestId("workflow-task-transfer-menu-item").click();
await expect(page.getByTestId("workflow-task-transfer-disabled-reason")).toHaveText("请先选择目标审批人。");
await expect(page.getByTestId("workflow-task-transfer")).toBeDisabled();
const transferApprover = page.getByTestId("workflow-task-transfer-approver");
const transferReason = page.getByTestId("workflow-task-transfer-reason");
await transferApprover.focus();
await expect(transferApprover).toBeFocused();
await page.keyboard.press("Tab");
await expect(transferReason).toBeFocused();
await transferApprover.selectOption("employee-transfer-1");
await transferReason.fill("理由太短");
await expect(page.getByTestId("workflow-task-transfer-disabled-reason")).toContainText("至少 10 个字");
await expect(page.getByTestId("workflow-task-transfer")).toBeDisabled();
await transferReason.fill("当前事项需转交对应审批人继续处理");
await expect(page.getByTestId("workflow-task-transfer-disabled-reason")).toHaveText("转审理由已满足要求。");
await expect(page.getByTestId("workflow-task-transfer")).toBeEnabled();
await transferReason.focus();
await page.keyboard.press("Tab");
await expect(page.getByTestId("workflow-task-transfer")).toBeFocused();
await page.getByTestId("workflow-task-return-toggle").click();
await page.getByTestId("workflow-task-return-comment").fill("请补充附件后重新提交。");
await page.getByTestId("workflow-task-return").click();
@@ -141,7 +141,7 @@ async function mockWorkflowBoundaryApis(page: Page) {
sections: {
main: [
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("workflow", "/workflow/tasks", "待我处理", "fact_check"),
navigationItem("workflow", "/workflow/tasks", "待我审批", "fact_check"),
navigationItem("leave", "/leave-requests", "请假记录", "event_available"),
],
admin: [],