diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts index d42d02af..9e81bb75 100644 --- a/client/src/api/errorMessages.ts +++ b/client/src/api/errorMessages.ts @@ -19,7 +19,7 @@ const FEATURE_FLAG_DISABLED_MESSAGES: Record = { 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: "项目沟通回填当前未启用,请联系管理员检查环境开关与功能开关配置。", diff --git a/client/src/components/GlobalSearchDialog.tsx b/client/src/components/GlobalSearchDialog.tsx index 109551de..1f18bcd9 100644 --- a/client/src/components/GlobalSearchDialog.tsx +++ b/client/src/components/GlobalSearchDialog.tsx @@ -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
setKeyword(event.target.value)} /> diff --git a/client/src/components/common/StatusBadge.tsx b/client/src/components/common/StatusBadge.tsx index a2c16bac..252537be 100644 --- a/client/src/components/common/StatusBadge.tsx +++ b/client/src/components/common/StatusBadge.tsx @@ -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 ( diff --git a/client/src/components/master-data/HolidayCalendarTab.tsx b/client/src/components/master-data/HolidayCalendarTab.tsx index e9b8d739..012c9cad 100644 --- a/client/src/components/master-data/HolidayCalendarTab.tsx +++ b/client/src/components/master-data/HolidayCalendarTab.tsx @@ -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") { diff --git a/client/src/components/master-data/SupplierTab.tsx b/client/src/components/master-data/SupplierTab.tsx index 5df3442a..c120cae0 100644 --- a/client/src/components/master-data/SupplierTab.tsx +++ b/client/src/components/master-data/SupplierTab.tsx @@ -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") { diff --git a/client/src/components/mobile/BarcodeScannerPanel.tsx b/client/src/components/mobile/BarcodeScannerPanel.tsx index 6be666cb..2d98db8a 100644 --- a/client/src/components/mobile/BarcodeScannerPanel.tsx +++ b/client/src/components/mobile/BarcodeScannerPanel.tsx @@ -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(null); const mediaStreamRef = useRef(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); diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index 12323f82..b1d0bdd8 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -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 = { "/reimbursements": "我的报销", - "/workflow/tasks": "待我处理", + "/workflow/tasks": "待我审批", "/assets": "资产", }; +const PRODUCT_LABELS: Record = { + "/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[] }) { diff --git a/client/src/components/onboarding/WelcomeDialog.tsx b/client/src/components/onboarding/WelcomeDialog.tsx index 0f635481..c9a3eb97 100644 --- a/client/src/components/onboarding/WelcomeDialog.tsx +++ b/client/src/components/onboarding/WelcomeDialog.tsx @@ -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({ 常用入口 - 你的账号已就绪。先从工作台处理待办,也可以直接进入常用业务。 + 你的账号已就绪。下面只展示当前账号可用的常用业务入口。 -
- {WELCOME_LINKS.map((item) => ( - -
-
- {item.icon} + {links.length ? ( +
+ {links.map((item) => ( + +
+
+ {item.icon} +
+
+
{item.label}
+
{item.description}
+
-
-
{item.label}
-
{item.description}
-
-
- - ))} -
+ + ))} +
+ ) : null}
关闭后不再显示这个提示;所有功能都可以从左侧菜单进入。 diff --git a/client/src/components/patterns/BusinessEditTemplate.tsx b/client/src/components/patterns/BusinessEditTemplate.tsx index ee399268..db718527 100644 --- a/client/src/components/patterns/BusinessEditTemplate.tsx +++ b/client/src/components/patterns/BusinessEditTemplate.tsx @@ -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 ( -
+
{validation ? {validation} : null} {children} @@ -149,14 +161,5 @@ export function BusinessEditActionBar({ children: ReactNode; className?: string; }) { - return ( -
-
{children}
-
- ); + return {children}; } diff --git a/client/src/components/patterns/MobileStickyActionBar.tsx b/client/src/components/patterns/MobileStickyActionBar.tsx new file mode 100644 index 00000000..7440936d --- /dev/null +++ b/client/src/components/patterns/MobileStickyActionBar.tsx @@ -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 ( +
+
+ {children} +
+
+ ); +} diff --git a/client/src/components/people/EmployeePerformancePanel.tsx b/client/src/components/people/EmployeePerformancePanel.tsx index 53c92193..aecde97b 100644 --- a/client/src/components/people/EmployeePerformancePanel.tsx +++ b/client/src/components/people/EmployeePerformancePanel.tsx @@ -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) { diff --git a/client/src/components/people/EmployeeSalaryPanel.tsx b/client/src/components/people/EmployeeSalaryPanel.tsx index 30441e4b..b308c382 100644 --- a/client/src/components/people/EmployeeSalaryPanel.tsx +++ b/client/src/components/people/EmployeeSalaryPanel.tsx @@ -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() { diff --git a/client/src/components/reimbursements/ReimbursementHeaderEditor.tsx b/client/src/components/reimbursements/ReimbursementHeaderEditor.tsx index 758eda12..0ceba2a3 100644 --- a/client/src/components/reimbursements/ReimbursementHeaderEditor.tsx +++ b/client/src/components/reimbursements/ReimbursementHeaderEditor.tsx @@ -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) : "保存报销信息失败,请重试"); }, }); diff --git a/client/src/components/travel/TravelPaymentTrackCard.tsx b/client/src/components/travel/TravelPaymentTrackCard.tsx index e4f2056b..00c9621e 100644 --- a/client/src/components/travel/TravelPaymentTrackCard.tsx +++ b/client/src/components/travel/TravelPaymentTrackCard.tsx @@ -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" { diff --git a/client/src/components/travel/TravelReconciliationTab.tsx b/client/src/components/travel/TravelReconciliationTab.tsx index ac6a2eef..5f2b56e0 100644 --- a/client/src/components/travel/TravelReconciliationTab.tsx +++ b/client/src/components/travel/TravelReconciliationTab.tsx @@ -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() { diff --git a/client/src/features/mobile/navigation.ts b/client/src/features/mobile/navigation.ts index 216f2cdf..ee62012e 100644 --- a/client/src/features/mobile/navigation.ts +++ b/client/src/features/mobile/navigation.ts @@ -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" }, ]; diff --git a/client/src/features/navigation/schema.ts b/client/src/features/navigation/schema.ts index fe9787d0..d9cc7b5a 100644 --- a/client/src/features/navigation/schema.ts +++ b/client/src/features/navigation/schema.ts @@ -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: "移动工作台" }, diff --git a/client/src/lib/actionConfirm.ts b/client/src/lib/actionConfirm.ts index a2930312..8366ae8f 100644 --- a/client/src/lib/actionConfirm.ts +++ b/client/src/lib/actionConfirm.ts @@ -43,7 +43,7 @@ export function getActionConfirmCopy(kind: ActionConfirmKind, context: ActionCon const copies: Record = { 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", }, diff --git a/client/src/lib/precheck.ts b/client/src/lib/precheck.ts index 12cd708f..d73de72d 100644 --- a/client/src/lib/precheck.ts +++ b/client/src/lib/precheck.ts @@ -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}` : ""}`); }); diff --git a/client/src/lib/resolveErrorMessage.ts b/client/src/lib/resolveErrorMessage.ts index 4fe9a359..d4d8853d 100644 --- a/client/src/lib/resolveErrorMessage.ts +++ b/client/src/lib/resolveErrorMessage.ts @@ -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; } diff --git a/client/src/pages/ApOperationsPage.tsx b/client/src/pages/ApOperationsPage.tsx index 29f55005..fb7d465f 100644 --- a/client/src/pages/ApOperationsPage.tsx +++ b/client/src/pages/ApOperationsPage.tsx @@ -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) { diff --git a/client/src/pages/ApprovalRulesPage.tsx b/client/src/pages/ApprovalRulesPage.tsx index 6adcdb95..545b44d0 100644 --- a/client/src/pages/ApprovalRulesPage.tsx +++ b/client/src/pages/ApprovalRulesPage.tsx @@ -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 ? (

- {isApiError(listQuery.error) - ? `${listQuery.error.code}: ${mapApiErrorMessage( - listQuery.error.code, - listQuery.error.details, - listQuery.error.message, - )}` - : "加载审批规则失败"} + {resolveUserErrorMessage(listQuery.error, "加载审批规则失败")}

) : null} diff --git a/client/src/pages/AssetRegistryPage.tsx b/client/src/pages/AssetRegistryPage.tsx index 9f2afd9b..d3451ec8 100644 --- a/client/src/pages/AssetRegistryPage.tsx +++ b/client/src/pages/AssetRegistryPage.tsx @@ -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() { diff --git a/client/src/pages/AttendancePage.tsx b/client/src/pages/AttendancePage.tsx index 53b442bf..28fad038 100644 --- a/client/src/pages/AttendancePage.tsx +++ b/client/src/pages/AttendancePage.tsx @@ -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) { diff --git a/client/src/pages/BudgetsPage.tsx b/client/src/pages/BudgetsPage.tsx index cef3f037..03dbe9d5 100644 --- a/client/src/pages/BudgetsPage.tsx +++ b/client/src/pages/BudgetsPage.tsx @@ -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) { diff --git a/client/src/pages/ChangePasswordPage.tsx b/client/src/pages/ChangePasswordPage.tsx index bfe49396..29cbca1c 100644 --- a/client/src/pages/ChangePasswordPage.tsx +++ b/client/src/pages/ChangePasswordPage.tsx @@ -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("修改密码失败,请稍后重试"); } diff --git a/client/src/pages/ContractsRegistryPage.tsx b/client/src/pages/ContractsRegistryPage.tsx index fc23fb8a..5306a4a6 100644 --- a/client/src/pages/ContractsRegistryPage.tsx +++ b/client/src/pages/ContractsRegistryPage.tsx @@ -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) { diff --git a/client/src/pages/CutoverConsolePage.tsx b/client/src/pages/CutoverConsolePage.tsx index 18bff7b5..91f152e5 100644 --- a/client/src/pages/CutoverConsolePage.tsx +++ b/client/src/pages/CutoverConsolePage.tsx @@ -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) { diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index ecefb364..4ffec10a 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -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, "导出失败")); }, }); diff --git a/client/src/pages/DelegationsPage.tsx b/client/src/pages/DelegationsPage.tsx index ecfbdcd8..78a1390c 100644 --- a/client/src/pages/DelegationsPage.tsx +++ b/client/src/pages/DelegationsPage.tsx @@ -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 ? : null} {listQuery.isError ? (

- {isApiError(listQuery.error) - ? `${listQuery.error.code}: ${mapApiErrorMessage( - listQuery.error.code, - listQuery.error.details, - listQuery.error.message, - )}` - : "加载委派列表失败"} + {resolveUserErrorMessage(listQuery.error, "加载委派列表失败")}

) : null} @@ -259,4 +252,3 @@ export function DelegationsPage() {
); } - diff --git a/client/src/pages/DepartmentsPage.tsx b/client/src/pages/DepartmentsPage.tsx index 8d43a243..1e549ee2 100644 --- a/client/src/pages/DepartmentsPage.tsx +++ b/client/src/pages/DepartmentsPage.tsx @@ -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 ? (

- {isApiError(listQuery.error) - ? `${listQuery.error.code}: ${mapApiErrorMessage( - listQuery.error.code, - listQuery.error.details, - listQuery.error.message, - )}` - : "加载部门列表失败"} + {resolveUserErrorMessage(listQuery.error, "加载部门列表失败")}

) : null} diff --git a/client/src/pages/EmployeeProfilePage.tsx b/client/src/pages/EmployeeProfilePage.tsx index a4ca4554..89c9ca24 100644 --- a/client/src/pages/EmployeeProfilePage.tsx +++ b/client/src/pages/EmployeeProfilePage.tsx @@ -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 = { + 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() {
- +
@@ -989,7 +998,7 @@ export function EmployeeProfilePage() { 资产未归还:{profile.lifecycle.blockers.openAssetCount}
- 待办审批任务:{profile.lifecycle.blockers.pendingWorkflowTaskCount} + 待审批任务:{profile.lifecycle.blockers.pendingWorkflowTaskCount}
可完成离职:{profile.lifecycle.blockers.canCompleteOffboarding ? "是" : "否"} diff --git a/client/src/pages/ExpenseStandardsPage.tsx b/client/src/pages/ExpenseStandardsPage.tsx index f15a3004..cc4c0aa8 100644 --- a/client/src/pages/ExpenseStandardsPage.tsx +++ b/client/src/pages/ExpenseStandardsPage.tsx @@ -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) { diff --git a/client/src/pages/FeatureFlagsPage.tsx b/client/src/pages/FeatureFlagsPage.tsx index 8810f3f0..725693ad 100644 --- a/client/src/pages/FeatureFlagsPage.tsx +++ b/client/src/pages/FeatureFlagsPage.tsx @@ -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) { diff --git a/client/src/pages/ForgotPasswordPage.tsx b/client/src/pages/ForgotPasswordPage.tsx index 5004ebd6..c993e9a8 100644 --- a/client/src/pages/ForgotPasswordPage.tsx +++ b/client/src/pages/ForgotPasswordPage.tsx @@ -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("重置密码失败,请稍后重试或联系管理员。"); } diff --git a/client/src/pages/InventoryChecksPage.tsx b/client/src/pages/InventoryChecksPage.tsx index 2aedf120..17c62b6e 100644 --- a/client/src/pages/InventoryChecksPage.tsx +++ b/client/src/pages/InventoryChecksPage.tsx @@ -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) { diff --git a/client/src/pages/InvoicePoolPage.tsx b/client/src/pages/InvoicePoolPage.tsx index 9faa5b4d..3058b03a 100644 --- a/client/src/pages/InvoicePoolPage.tsx +++ b/client/src/pages/InvoicePoolPage.tsx @@ -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("预览链接获取失败"); } diff --git a/client/src/pages/LeaveRequestPage.tsx b/client/src/pages/LeaveRequestPage.tsx index 10af25e1..91d0070e 100644 --- a/client/src/pages/LeaveRequestPage.tsx +++ b/client/src/pages/LeaveRequestPage.tsx @@ -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( diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 3733f3a2..5de9b1d8 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -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 { diff --git a/client/src/pages/MasterDataPage.tsx b/client/src/pages/MasterDataPage.tsx index 0b6fa7ee..3cab4174 100644 --- a/client/src/pages/MasterDataPage.tsx +++ b/client/src/pages/MasterDataPage.tsx @@ -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") { diff --git a/client/src/pages/MobileApprovalsPage.tsx b/client/src/pages/MobileApprovalsPage.tsx index fcaccf4d..fc4a2ee2 100644 --- a/client/src/pages/MobileApprovalsPage.tsx +++ b/client/src/pages/MobileApprovalsPage.tsx @@ -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() { {isPushDetail ? ( @@ -871,14 +870,14 @@ export function MobileApprovalsPage() { /> ) : ( )} {!isOnline && canAccessRemoteApprovals ? (

- 当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存待办,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。 + 当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存审批任务,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。

) : null} @@ -985,7 +984,7 @@ export function MobileApprovalsPage() {
- 待我处理 + 待我审批 已处理 @@ -1041,7 +1040,7 @@ export function MobileApprovalsPage() { {taskTab === "completed" ? "最近 30 天暂无符合筛选条件的已处理任务。" : !canAccessRemoteApprovals && isOnline - ? "暂无待我处理;收到企业微信审批消息后可从这里查看。" + ? "暂无待我审批;收到企业微信审批消息后可从这里查看。" : canUseRemote ? "暂无符合筛选条件的审批任务。" : "暂无符合筛选条件的已缓存审批。"} @@ -1102,7 +1101,7 @@ export function MobileApprovalsPage() { {isOfflineUncachedDeepLink ? (

- 离线未缓存该待办,请联网后重试。 + 离线未缓存该审批任务,请联网后重试。

) : null} @@ -1223,7 +1222,14 @@ export function MobileApprovalsPage() {

) : ( <> +
+
退回:原单回到申请人修改,保留单号和历史,修改后可重新提交。
+
驳回:本次流程终止或进入驳回态,申请人通常需要重新发起。
+
+ +