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

这个提交包含在:
2026-08-07 06:50:40 +08:00
父节点 d4e9597b37
当前提交 94f1bad336
共修改 97 个文件,包含 784 行新增和 836 行删除
+5 -5
查看文件
@@ -1,9 +1,9 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { searchGlobal, type GlobalSearchHit } from "../api/search";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
@@ -21,7 +21,7 @@ function entityLabel(entityType: string) {
if (entityType === "employee") return "人员";
if (entityType === "announcement") return "公告";
if (entityType === "weekly_report") return "周报";
if (entityType === "todo") return "待办";
if (entityType === "todo") return "我的事项";
if (entityType === "reimbursement") return "报销";
if (entityType === "leave") return "请假";
if (entityType === "purchase_request") return "采购";
@@ -116,12 +116,12 @@ export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOp
}, [searchQuery.data?.items]);
const message = useMemo(() => {
if (!trimmed) return "输入姓名、公告、项目、合同、资产、请假、报销、周报或待办关键字开始搜索。";
if (!trimmed) return "输入姓名、公告、项目、合同、资产、请假、报销、周报或事项关键字开始搜索。";
if (trimmed.length < 2) return "至少输入 2 个字符。";
if (searchQuery.isLoading) return "搜索中...";
if (searchQuery.isError) {
return isApiError(searchQuery.error)
? `${searchQuery.error.code}: ${mapApiErrorMessage(searchQuery.error.code, searchQuery.error.details, searchQuery.error.message)}`
? resolveUserErrorMessage(searchQuery.error)
: "搜索失败,请稍后重试";
}
if (!groupedItems.length) return "未找到匹配结果。";
@@ -137,7 +137,7 @@ export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOp
<div className="space-y-4">
<Input
autoFocus
placeholder="搜索人员、项目、合同、公告、资产、请假、报销、周报、待办"
placeholder="搜索人员、项目、合同、公告、资产、请假、报销、周报、事项"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
+1 -1
查看文件
@@ -47,7 +47,7 @@ export function StatusBadge({
status: string;
label?: string;
}) {
const config = STATUS_MAP[status] || { label: status, className: "badge-neutral" };
const config = STATUS_MAP[status] || { label: "未知状态", className: "badge-neutral" };
return (
<span className={cn(config.className, className)} {...props}>
@@ -1,7 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../../api/errorMessages";
import {
archiveHolidayCalendar,
createHolidayCalendar,
@@ -12,7 +12,6 @@ import {
type HolidayCalendarDayType,
type LegalEntityItem,
} from "../../api/masterData";
import { isApiError } from "../../api/types";
import { ConfirmDialog } from "../common/ConfirmDialog";
import { StatusBadge } from "../common/StatusBadge";
import { TableSkeleton } from "../common/TableSkeleton";
@@ -42,13 +41,7 @@ type DraftDay = {
};
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "操作失败,请稍后重试。";
return resolveUserErrorMessage(error, "操作失败,请稍后重试。");
}
function toEnabledFilter(value: "" | "true" | "false") {
@@ -1,7 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../../api/errorMessages";
import {
archiveSupplier,
createSupplier,
@@ -9,7 +9,6 @@ import {
type LegalEntityItem,
type SupplierItem,
} from "../../api/masterData";
import { isApiError } from "../../api/types";
import { ConfirmDialog } from "../common/ConfirmDialog";
import { StatusBadge } from "../common/StatusBadge";
import { TableSkeleton } from "../common/TableSkeleton";
@@ -26,13 +25,7 @@ type SupplierTabProps = {
};
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "操作失败,请稍后重试。";
return resolveUserErrorMessage(error, "操作失败,请稍后重试。");
}
function toEnabledFilter(value: "" | "true" | "false") {
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { resolveErrorMessage } from "../../lib/resolveErrorMessage";
import { Button } from "../ui/button";
type BarcodeScannerPanelProps = {
@@ -22,13 +23,6 @@ type BrowserWithBarcodeDetector = Window & {
const SCAN_FORMATS = ["qr_code", "code_128", "code_39", "ean_13", "ean_8", "upc_a", "upc_e"];
function resolveErrorMessage(error: unknown) {
if (error instanceof Error && error.message) {
return error.message;
}
return "无法启动相机扫码,请改用手工输入。";
}
export function BarcodeScannerPanel({ disabled = false, onDetected }: BarcodeScannerPanelProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const mediaStreamRef = useRef<MediaStream | null>(null);
@@ -166,9 +160,9 @@ export function BarcodeScannerPanel({ disabled = false, onDetected }: BarcodeSca
await startWithBarcodeDetector();
setIsStarting(false);
return;
} catch (detectorError) {
} catch {
stopScanner();
setError(`BarcodeDetector 启动失败,已回退到 ZXing:${resolveErrorMessage(detectorError)}`);
setError("");
}
}
@@ -176,7 +170,7 @@ export function BarcodeScannerPanel({ disabled = false, onDetected }: BarcodeSca
await startWithZxing();
} catch (fallbackError) {
stopScanner();
setError(resolveErrorMessage(fallbackError));
setError(resolveErrorMessage(fallbackError, "无法启动相机扫码,请改用手工输入。"));
}
setIsStarting(false);
+12 -3
查看文件
@@ -32,7 +32,7 @@ export const NAV_ITEMS: NavItem[] = [
{ to: "/announcements", label: "公告", icon: "campaign" },
{ to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_own", "project.view_all", "project.manage"] },
{ to: "/system/settings", label: "管理", icon: "settings", anyOf: MANAGEMENT_ROOT_PERMISSIONS },
{ to: "/workflow/tasks", label: "待我处理", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] },
{ to: "/workflow/tasks", label: "待我审批", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] },
{ to: "/contracts-registry", label: "合同", icon: "description", anyOf: ["project.view_all", "project.manage", "system.settings"] },
{ to: "/purchase-requests", label: "采购", icon: "shopping_cart", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
{ to: "/travel-requests", label: "差旅申请", icon: "flight", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] },
@@ -46,10 +46,14 @@ export const NAV_ITEMS: NavItem[] = [
const ACCESSIBLE_LABELS: Record<string, string> = {
"/reimbursements": "我的报销",
"/workflow/tasks": "待我处理",
"/workflow/tasks": "待我审批",
"/assets": "资产",
};
const PRODUCT_LABELS: Record<string, string> = {
"/workflow/tasks": "待我审批",
};
const TEAM_NAVIGATION_ROLE_CODES = ["manager", "ceo", "finance_mgr", "hr", "admin"];
const PAYMENT_REVIEWER_ROLE_CODES = ["manager", "ceo"];
@@ -177,7 +181,12 @@ function orderItems(items: ResolvedNavItem[], order: string[]) {
}
function toResolved(items: NavigationItem[]) {
return items.map((item) => ({ key: item.key, href: item.href, label: item.label, icon: item.icon }));
return items.map((item) => ({
key: item.key,
href: item.href,
label: PRODUCT_LABELS[item.href] ?? item.label,
icon: item.icon,
}));
}
function normalizeSections(sections: { main: NavigationItem[]; admin: NavigationItem[]; backup: NavigationItem[] }) {
@@ -9,38 +9,19 @@ import {
DialogTitle,
} from "../ui/dialog";
type WelcomeLinkItem = {
export type WelcomeLinkItem = {
icon: string;
label: string;
description: string;
to: string;
};
const WELCOME_LINKS: WelcomeLinkItem[] = [
{
icon: "fact_check",
label: "处理待办",
description: "先看今天需要审批或跟进的事项",
to: "/workflow/tasks",
},
{
icon: "request_quote",
label: "提交报销",
description: "录入票据和报销说明,查看进度",
to: "/reimbursements",
},
{
icon: "event_available",
label: "请假记录",
description: "日常请假走企业微信,OA 查看记录和状态",
to: "/leave-requests",
},
];
export function WelcomeDialog({
links,
open,
onClose,
}: {
links: WelcomeLinkItem[];
open: boolean;
onClose: () => void;
}) {
@@ -50,30 +31,32 @@ export function WelcomeDialog({
<DialogHeader>
<DialogTitle>常用入口</DialogTitle>
<DialogDescription>
你的账号已就绪。先从工作台处理待办,也可以直接进入常用业务。
你的账号已就绪。下面只展示当前账号可用的常用业务入口。
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 sm:grid-cols-3">
{WELCOME_LINKS.map((item) => (
<Link
key={item.label}
to={item.to}
onClick={onClose}
className="rounded-lg border border-zinc-200 bg-white px-4 py-4 shadow-sm transition-colors hover:bg-zinc-50"
>
<div className="flex h-full flex-col gap-3">
<div className="flex size-10 items-center justify-center rounded-md bg-primary/10 text-primary">
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
{links.length ? (
<div className="grid gap-3 sm:grid-cols-3">
{links.map((item) => (
<Link
key={item.label}
to={item.to}
onClick={onClose}
className="rounded-lg border border-zinc-200 bg-white px-4 py-4 shadow-sm transition-colors hover:bg-zinc-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
>
<div className="flex h-full flex-col gap-3">
<div className="flex size-10 items-center justify-center rounded-md bg-primary/10 text-primary">
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
</div>
<div className="min-w-0">
<div className="font-medium text-zinc-900">{item.label}</div>
<div className="mt-1 text-sm leading-6 text-zinc-500">{item.description}</div>
</div>
</div>
<div className="min-w-0">
<div className="font-medium text-zinc-900">{item.label}</div>
<div className="mt-1 text-sm leading-6 text-zinc-500">{item.description}</div>
</div>
</div>
</Link>
))}
</div>
</Link>
))}
</div>
) : null}
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-600">
关闭后不再显示这个提示;所有功能都可以从左侧菜单进入。
@@ -3,6 +3,10 @@ import { Link } from "react-router-dom";
import { cn } from "../../lib/utils";
import { Button } from "../ui/button";
import { WorkspaceHeader } from "./ListWorkspace";
import {
MOBILE_STICKY_ACTION_PAGE_CLASS,
MobileStickyActionBar,
} from "./MobileStickyActionBar";
import { SectionPanel } from "./SectionPanel";
export function BusinessEditTemplate({
@@ -45,7 +49,15 @@ export function BusinessEditTemplate({
) : null;
return (
<div className={cn("page-scaffold", bottomActions && "pb-24 md:pb-0", className)} data-testid={testId}>
<div
className={cn(
"page-scaffold",
bottomActions && MOBILE_STICKY_ACTION_PAGE_CLASS,
bottomActions && "md:pb-0",
className,
)}
data-testid={testId}
>
<WorkspaceHeader eyebrow={eyebrow} title={title} meta={meta} actions={headerActions} />
{validation ? <BusinessEditValidationSummary>{validation}</BusinessEditValidationSummary> : null}
{children}
@@ -149,14 +161,5 @@ export function BusinessEditActionBar({
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"mobile-sticky-action-surface fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur md:static md:inset-auto md:mt-5 md:rounded-lg md:border md:bg-white md:p-3 md:shadow-none",
className,
)}
>
<div className="mobile-sticky-action-grid md:flex md:justify-end md:gap-2">{children}</div>
</div>
);
return <MobileStickyActionBar className={className} inlineFromMedium>{children}</MobileStickyActionBar>;
}
@@ -0,0 +1,34 @@
import type { ReactNode } from "react";
import { cn } from "../../lib/utils";
export const MOBILE_STICKY_ACTION_PAGE_CLASS =
"mobile-fixed-action-page pb-[calc(var(--mobile-sticky-action-height,78px)+env(safe-area-inset-bottom,0px)+1.25rem)]";
export function MobileStickyActionBar({
children,
className,
contentClassName,
inlineFromMedium = false,
testId,
}: {
children: ReactNode;
className?: string;
contentClassName?: string;
inlineFromMedium?: boolean;
testId?: string;
}) {
return (
<div
className={cn(
"mobile-sticky-action-surface fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur",
inlineFromMedium && "md:static md:inset-auto md:mt-5 md:rounded-lg md:border md:bg-white md:p-3 md:shadow-none",
className,
)}
data-testid={testId}
>
<div className={cn("mobile-sticky-action-grid", inlineFromMedium && "md:flex md:justify-end md:gap-2", contentClassName)}>
{children}
</div>
</div>
);
}
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
@@ -5,8 +6,6 @@ import {
type PerformanceEmployeeReviewTimeline,
type PerformanceReviewDimensionType,
} from "../../api/performance";
import { mapApiErrorMessage } from "../../api/errorMessages";
import { isApiError } from "../../api/types";
import { StatusBadge } from "../common/StatusBadge";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
@@ -57,10 +56,7 @@ function buildFormState(review: PerformanceEmployeeReviewTimeline["items"][numbe
}
function errorMessage(error: unknown, fallback: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
function cycleStatusLabel(status: string) {
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -8,8 +9,6 @@ import {
type EmployeeSalaryProfileSnapshot,
type Payslip,
} from "../../api/payroll";
import { mapApiErrorMessage } from "../../api/errorMessages";
import { isApiError } from "../../api/types";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
@@ -17,10 +16,7 @@ import { toast } from "sonner";
import { StatusBadge } from "../common/StatusBadge";
function errorMessage(error: unknown, fallback: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
function todayDateString() {
@@ -1,8 +1,8 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateReimbursement } from "../../api/reimbursements";
import { isApiError } from "../../api/types";
import { mapApiErrorMessage } from "../../api/errorMessages";
import { useUnsavedChangesGuard } from "../common/UnsavedChangesGuard";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
@@ -58,7 +58,7 @@ export function ReimbursementHeaderEditor({
},
onError: (mutationError) => {
setError(isApiError(mutationError)
? mapApiErrorMessage(mutationError.code, mutationError.details, mutationError.message)
? resolveUserErrorMessage(mutationError)
: "保存报销信息失败,请重试");
},
});
@@ -1,13 +1,12 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../../api/errorMessages";
import {
bindInvoice,
fetchInvoicePoolList,
type InvoicePoolListItem,
} from "../../api/invoicePool";
import { isApiError } from "../../api/types";
import {
fetchTravelPaymentTrack,
markTravelOrderPaid,
@@ -22,13 +21,7 @@ import { Button } from "../ui/button";
import { TravelOrderMarkPaidDialog } from "./TravelOrderMarkPaidDialog";
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function freezeReasonFromAnomaly(code: string): "amount_mismatch" | "currency_mismatch" | "missing_order" | "duplicate" | "manual_review" {
@@ -1,7 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { mapApiErrorMessage } from '../../api/errorMessages';
import { useAuth } from '../../auth/AuthContext';
import { hasPermission } from '../../auth/permissions';
import { fetchCostCenters, fetchLegalEntities } from '../../api/masterData';
@@ -17,7 +17,6 @@ import {
type TravelReconciliationBatchStatus,
type TravelReconciliationItem,
} from '../../api/travelReconciliation';
import { isApiError } from '../../api/types';
import { TravelReconciliationBatchCard } from './TravelReconciliationBatchCard';
import { TravelReconciliationItemTable } from './TravelReconciliationItemTable';
import { CardSkeleton } from '../common/CardSkeleton';
@@ -33,13 +32,7 @@ import { Textarea } from '../ui/textarea';
const PAGE_SIZE = 10;
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return '请求失败,请稍后重试。';
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function currentMonthRange() {