feat(08-05-interaction-pattern-unification): 统一前端交互与反馈模式
这个提交包含在:
@@ -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: "项目沟通回填当前未启用,请联系管理员检查环境开关与功能开关配置。",
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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: "移动工作台" },
|
||||
|
||||
@@ -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}` : ""}`);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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("修改密码失败,请稍后重试");
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, "导出失败"));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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("重置密码失败,请稍后重试或联系管理员。");
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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("预览链接获取失败");
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "加载通知失败";
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()');
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
在新工单中引用
屏蔽一个用户