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

这个提交包含在:
2026-08-07 06:50:40 +08:00
父节点 d4e9597b37
当前提交 94f1bad336
共修改 97 个文件,包含 784 行新增和 836 行删除
+2 -7
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -14,7 +15,6 @@ import {
type FinanceLedgerPaymentStatus,
type FinanceLedgerStatus,
} from "../api/apOperations";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
dismissRiskFlag,
fetchRiskFlags,
@@ -37,7 +37,6 @@ import {
fetchTravelReconciliationBatchDetail,
type TravelReconciliationItem,
} from "../api/travelReconciliation";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
@@ -87,11 +86,7 @@ type FailedPaymentItemRow = PaymentBatchItem & {
const BUDGET_RISK_CODES = new Set(["budget_exceeded", "budget_threshold_exceeded"]);
function resolveErrorMessage(error: unknown, fallback = "操作失败") {
return isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
: error instanceof Error
? error.message
: fallback;
return resolveUserErrorMessage(error, fallback);
}
function formatDateTime(value: string | null) {
+5 -12
查看文件
@@ -1,8 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import {
createApprovalRule,
fetchApprovalRules,
@@ -112,7 +111,7 @@ export function ApprovalRulesPage() {
toast.success("规则已创建");
},
onError: (err) => {
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "创建审批规则失败";
const msg = resolveUserErrorMessage(err, "创建审批规则失败");
setError(msg);
toast.error(msg);
},
@@ -126,7 +125,7 @@ export function ApprovalRulesPage() {
toast.success("状态已更新");
},
onError: (err) => {
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "更新审批规则失败";
const msg = resolveUserErrorMessage(err, "更新审批规则失败");
setError(msg);
toast.error(msg);
},
@@ -148,7 +147,7 @@ export function ApprovalRulesPage() {
toast.success("规则已更新");
},
onError: (err) => {
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "更新审批规则失败";
const msg = resolveUserErrorMessage(err, "更新审批规则失败");
setError(msg);
toast.error(msg);
},
@@ -382,13 +381,7 @@ export function ApprovalRulesPage() {
{listQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(listQuery.error)
? `${listQuery.error.code}: ${mapApiErrorMessage(
listQuery.error.code,
listQuery.error.details,
listQuery.error.message,
)}`
: "加载审批规则失败"}
{resolveUserErrorMessage(listQuery.error, "加载审批规则失败")}
</p>
) : null}
+2 -9
查看文件
@@ -1,8 +1,8 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
assignAsset,
createAsset,
@@ -23,7 +23,6 @@ import {
} from "../api/assets";
import { fetchPurchaseRequests } from "../api/purchaseRequests";
import { fetchCostCenters, fetchDocumentCategories, fetchLegalEntities } from "../api/masterData";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { DateTextInput } from "../components/common/DateTextInput";
@@ -51,13 +50,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
import { formatCompactIdentifier, formatDisplayDateTime, formatTextWithCompactLongNumbers, isCompactedIdentifier } from "../lib/displayFormat";
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function initialCategoryForm() {
+2 -8
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { type ReactNode, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -13,7 +14,6 @@ import {
type AttendanceDailyStatus,
type AttendanceMonthlyDay,
} from "../api/attendance";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
@@ -90,13 +90,7 @@ function formatTime(value: string | null) {
}
function resolveErrorMessage(error: unknown, fallback = "请求失败,请稍后重试") {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
return resolveUserErrorMessage(error, fallback);
}
function isUnifiedDisabledError(error: unknown) {
+2 -9
查看文件
@@ -1,6 +1,6 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { mapApiErrorMessage } from "../api/errorMessages";
import { archiveBudget, createBudget, fetchBudgets, type BudgetListQuery, type BudgetPool, type BudgetScopeType } from "../api/budgets";
import {
archiveBudgetControlRule,
@@ -14,7 +14,6 @@ import {
import { fetchDepartments } from "../api/departments";
import { fetchCostCenters, fetchLegalEntities } from "../api/masterData";
import { fetchExpenseTypes } from "../api/expenseTypes";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
@@ -59,13 +58,7 @@ function emptyRuleForm(): RuleFormState {
}
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function describeScope(item: BudgetPool) {
+2 -2
查看文件
@@ -1,7 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { useAuth } from "../auth/AuthContext";
import { AuthPanel } from "../components/auth/AuthPanel";
import { Button } from "../components/ui/button";
@@ -41,7 +41,7 @@ export function ChangePasswordPage() {
navigate("/portal", { replace: true });
} catch (err) {
if (isApiError(err)) {
setError(mapApiErrorMessage(err.code, err.details, err.message));
setError(resolveUserErrorMessage(err));
} else {
setError("修改密码失败,请稍后重试");
}
+2 -6
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -16,9 +17,7 @@ import {
type ContractRegistryType,
} from "../api/contractsRegistry";
import { fetchEmployees } from "../api/employees";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchProjects, type ConfidentialityLevel } from "../api/projects";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
@@ -92,10 +91,7 @@ type EditorState = {
};
function resolveError(error: unknown, fallback = "操作失败") {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
function confidentialityLabel(value: ConfidentialityLevel) {
+2 -4
查看文件
@@ -26,6 +26,7 @@ import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
const SCOPE_TYPE_OPTIONS: Array<{ value: CutoverScope["scopeType"]; label: string }> = [
{ value: "department", label: "department" },
@@ -44,10 +45,7 @@ type ScopeFormState = {
};
function resolveErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function formatDateTime(value: string | null) {
+3 -12
查看文件
@@ -1,6 +1,6 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { mapApiErrorMessage } from "../api/errorMessages";
import { exportDashboardReimbursements } from "../api/exports";
import {
fetchDashboardApprovalEfficiency,
@@ -10,7 +10,6 @@ import {
type DashboardQuery,
} from "../api/dashboard";
import { fetchDepartments } from "../api/departments";
import { isApiError } from "../api/types";
import { toast } from "sonner";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
@@ -36,15 +35,7 @@ function toCurrency(value: string | number | null | undefined) {
}
function resolveErrorMessage(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "加载失败,请稍后重试。";
return resolveUserErrorMessage(error, "加载失败,请稍后重试。");
}
export function DashboardPage() {
@@ -107,7 +98,7 @@ export function DashboardPage() {
toast.success("导出完成");
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "导出失败");
toast.error(resolveUserErrorMessage(err, "导出失败"));
},
});
+4 -12
查看文件
@@ -1,8 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { createDelegation, fetchMyDelegations, revokeDelegation } from "../api/delegations";
import { fetchWorkflowApprovers } from "../api/workflow";
import { PageHeader } from "../components/common/PageHeader";
@@ -50,7 +49,7 @@ export function DelegationsPage() {
toast.success("委派已创建");
},
onError: (err) => {
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "创建委派失败";
const msg = resolveUserErrorMessage(err, "创建委派失败");
setError(msg);
toast.error(msg);
},
@@ -64,7 +63,7 @@ export function DelegationsPage() {
toast.success("已撤销");
},
onError: (err) => {
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "撤销委派失败";
const msg = resolveUserErrorMessage(err, "撤销委派失败");
setError(msg);
toast.error(msg);
},
@@ -161,13 +160,7 @@ export function DelegationsPage() {
{listQuery.isLoading ? <TableSkeleton columns={5} rows={3} /> : null}
{listQuery.isError ? (
<p className="text-sm text-red-600">
{isApiError(listQuery.error)
? `${listQuery.error.code}: ${mapApiErrorMessage(
listQuery.error.code,
listQuery.error.details,
listQuery.error.message,
)}`
: "加载委派列表失败"}
{resolveUserErrorMessage(listQuery.error, "加载委派列表失败")}
</p>
) : null}
@@ -259,4 +252,3 @@ export function DelegationsPage() {
</div>
);
}
+11 -17
查看文件
@@ -1,7 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import {
archiveDepartment,
@@ -31,34 +31,34 @@ import { ConfirmDialog } from "../components/common/ConfirmDialog";
function mapDepartmentErrorMessage(error: unknown, fallback: string) {
if (!isApiError(error)) {
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
if (error.code === "VALID_001") {
if (error.details?.parentId === "not_found") {
return `${error.code}: 上级部门不存在。`;
return "上级部门不存在。";
}
if (error.details?.parentId === "archived") {
return `${error.code}: 上级部门已归档,无法继续使用。`;
return "上级部门已归档,无法继续使用。";
}
if (error.details?.parentId === "self_not_allowed") {
return `${error.code}: 上级部门不能选择自身。`;
return "上级部门不能选择自身。";
}
if (error.details?.parentId === "cycle_not_allowed") {
return `${error.code}: 上级部门不能形成循环层级。`;
return "上级部门不能形成循环层级。";
}
if (error.details?.status === "archived") {
return `${error.code}: 已归档部门不可编辑。`;
return "已归档部门不可编辑。";
}
if (error.details?.department === "has_children") {
return `${error.code}: 当前部门存在下级节点,无法归档。`;
return "当前部门存在下级节点,无法归档。";
}
if (error.details?.department === "has_active_employees") {
return `${error.code}: 当前部门仍有在职员工,无法归档。`;
return "当前部门仍有在职员工,无法归档。";
}
}
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
return resolveUserErrorMessage(error);
}
function flattenDepartmentTree(nodes: DepartmentTreeNode[], depth = 0): Array<{ id: string; label: string }> {
@@ -288,13 +288,7 @@ export function DepartmentsPage() {
{listQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(listQuery.error)
? `${listQuery.error.code}: ${mapApiErrorMessage(
listQuery.error.code,
listQuery.error.details,
listQuery.error.message,
)}`
: "加载部门列表失败"}
{resolveUserErrorMessage(listQuery.error, "加载部门列表失败")}
</p>
) : null}
+16 -7
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useState } from "react";
import { Link, useParams, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -27,6 +28,7 @@ import {
type EmployeeCertificate,
type EmployeeContract,
type EmployeeContractType,
type EmployeeEmploymentStatus,
type EmployeeTraining,
type EmployeeTrainingStatus,
type EmployeeTrainingType,
@@ -50,12 +52,19 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import { Textarea } from "../components/ui/textarea";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { downloadBlob } from "../lib/download";
type ProfileTab = "profile" | "contracts" | "trainingCertificates" | "performance" | "salary";
const EMPLOYMENT_STATUS_LABELS: Record<EmployeeEmploymentStatus, string> = {
onboarding: "待入职",
probation: "试用期",
active: "在职",
offboarding: "离职办理中",
inactive: "已离职",
};
type ContractFormState = {
contractNo: string;
contractType: EmployeeContractType;
@@ -90,10 +99,7 @@ const CONTRACT_TYPE_OPTIONS: Array<{ value: EmployeeContractType; label: string
];
function errorMessage(error: unknown, fallback: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
function buildEmptyContractForm(): ContractFormState {
@@ -808,7 +814,10 @@ export function EmployeeProfilePage() {
</div>
<div className="mt-3 flex flex-wrap gap-2">
<StatusBadge status={profile.employee.status} />
<StatusBadge status={profile.employee.employmentStatus} />
<StatusBadge
status={profile.employee.employmentStatus}
label={EMPLOYMENT_STATUS_LABELS[profile.employee.employmentStatus] ?? "未知状态"}
/>
</div>
</div>
</div>
@@ -989,7 +998,7 @@ export function EmployeeProfilePage() {
资产未归还:<span className="font-medium text-zinc-900">{profile.lifecycle.blockers.openAssetCount}</span>
</div>
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
待办审批任务:<span className="font-medium text-zinc-900">{profile.lifecycle.blockers.pendingWorkflowTaskCount}</span>
待审批任务:<span className="font-medium text-zinc-900">{profile.lifecycle.blockers.pendingWorkflowTaskCount}</span>
</div>
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
可完成离职:<span className="font-medium text-zinc-900">{profile.lifecycle.blockers.canCompleteOffboarding ? "是" : "否"}</span>
+2 -9
查看文件
@@ -1,8 +1,8 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchDepartments } from "../api/departments";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchExpenseTypes } from "../api/expenseTypes";
import {
createExpenseStandard,
@@ -14,7 +14,6 @@ import {
type ExpenseStandardPerUnit,
type ExpenseStandardScopeType,
} from "../api/expenseStandards";
import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
@@ -77,13 +76,7 @@ function toForm(item: ExpenseStandard): FormState {
}
function resolveErrorMessage(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试";
return resolveUserErrorMessage(error, "请求失败,请稍后重试");
}
function toneClassForControlMode(mode: ExpenseStandardControlMode) {
+3 -10
查看文件
@@ -1,8 +1,8 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchDepartments } from "../api/departments";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
batchUpdateFeatureFlags,
fetchFeatureFlagChangelog,
@@ -16,7 +16,6 @@ import {
type FeatureFlagSection,
type FeatureFlagSnapshot,
} from "../api/featureFlags";
import { isApiError } from "../api/types";
import { Pagination } from "../components/common/Pagination";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { SectionPanel } from "../components/patterns/SectionPanel";
@@ -60,7 +59,7 @@ function toDisplayName(flagKey: string) {
if (flagKey === "attendance_unified_v1") return "企微考勤统一汇总";
if (flagKey === "weekly_report_v1") return "周报工作台";
if (flagKey === "employee_lifecycle_v1") return "人员生命周期";
if (flagKey === "todo_board_v1") return "待办工作台";
if (flagKey === "todo_board_v1") return "个人事项工作台";
if (flagKey === "wecom_entry_v1") return "企业微信入口";
if (flagKey === "project_domain_v1") return "项目主域";
if (flagKey === "project_linking_v1") return "项目联动";
@@ -96,13 +95,7 @@ function dedupeIds(items: string[]) {
}
function resolveErrorMessage(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function summarizeSnapshot(snapshot: FeatureFlagSnapshot | null) {
+3 -3
查看文件
@@ -1,9 +1,9 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { MailCheck } from "lucide-react";
import { Link, useNavigate } from "react-router-dom";
import { fetchAuthCapabilities, forgotPassword, resetPassword } from "../api/auth";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { AuthPanel } from "../components/auth/AuthPanel";
import { Button } from "../components/ui/button";
@@ -64,7 +64,7 @@ export function ForgotPasswordPage() {
setMessage("如账号可用,验证码将发送到企业邮箱;10 分钟内有效,请同时查看免提醒邮件。");
} catch (err) {
if (isApiError(err)) {
setError(mapApiErrorMessage(err.code, err.details, err.message));
setError(resolveUserErrorMessage(err));
} else {
setError("请求验证码失败,请稍后重试或联系管理员。");
}
@@ -103,7 +103,7 @@ export function ForgotPasswordPage() {
navigate("/login", { replace: true });
} catch (err) {
if (isApiError(err)) {
setError(mapApiErrorMessage(err.code, err.details, err.message));
setError(resolveUserErrorMessage(err));
} else {
setError("重置密码失败,请稍后重试或联系管理员。");
}
+2 -9
查看文件
@@ -1,9 +1,9 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchAssets } from "../api/assets";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
completeInventoryCheck,
createInventoryCheck,
@@ -14,7 +14,6 @@ import {
type InventoryCheckScopeType,
} from "../api/inventoryChecks";
import { fetchCostCenters, fetchLegalEntities } from "../api/masterData";
import { isApiError } from "../api/types";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { PageHeader } from "../components/common/PageHeader";
import { TableSkeleton } from "../components/common/TableSkeleton";
@@ -24,13 +23,7 @@ import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function scopeLabel(scopeType: InventoryCheckScopeType) {
+3 -5
查看文件
@@ -1,8 +1,8 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchMyDelegations } from "../api/delegations";
import { exportInvoicePool } from "../api/exports";
import {
@@ -228,9 +228,7 @@ export function InvoicePoolPage() {
// --- Helper for error handling ---
const toUserError = (err: unknown, fallback: string) => {
return isApiError(err)
? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`
: err instanceof Error ? err.message : fallback;
return resolveUserErrorMessage(err, fallback);
};
const handleError = (err: unknown, fallback: string) => {
@@ -330,7 +328,7 @@ export function InvoicePoolPage() {
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
setError(resolveUserErrorMessage(err));
} else {
setError("预览链接获取失败");
}
+2 -9
查看文件
@@ -1,7 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
createLeaveRequest,
fetchLeaveBalances,
@@ -15,7 +15,6 @@ import {
type LeaveRequestStatus,
type LeaveSession,
} from "../api/leave";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton";
@@ -149,13 +148,7 @@ function formatCalendarEntryLabel(item: LeaveRequestItem, dateKey: string) {
}
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "操作失败,请稍后重试。";
return resolveUserErrorMessage(error, "操作失败,请稍后重试。");
}
function calculateRequestedDaysPreview(
+4 -4
查看文件
@@ -1,9 +1,9 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Building2, MailCheck } from "lucide-react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { fetchAuthCapabilities, fetchSsoProviders, fetchWecomConfig, requestEmailLoginCode } from "../api/auth";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { AuthPanel } from "../components/auth/AuthPanel";
@@ -127,7 +127,7 @@ export function LoginPage() {
}
} catch (err) {
if (isApiError(err)) {
setError(mapApiErrorMessage(err.code, err.details, err.message));
setError(resolveUserErrorMessage(err));
} else if (err instanceof TypeError) {
setError("无法连接后端服务,请稍后再试或联系管理员。");
} else {
@@ -179,7 +179,7 @@ export function LoginPage() {
setEmailCodeMessage("如账号可用,验证码将发送到企业邮箱;10 分钟内有效,请同时查看免提醒邮件。");
} catch (err) {
if (isApiError(err)) {
setEmailCodeError(mapApiErrorMessage(err.code, err.details, err.message));
setEmailCodeError(resolveUserErrorMessage(err));
} else if (err instanceof TypeError) {
setEmailCodeError("无法连接后端服务,请稍后再试或联系管理员。");
} else {
@@ -215,7 +215,7 @@ export function LoginPage() {
}
} catch (err) {
if (isApiError(err)) {
setEmailCodeError(mapApiErrorMessage(err.code, err.details, err.message));
setEmailCodeError(resolveUserErrorMessage(err));
} else if (err instanceof TypeError) {
setEmailCodeError("无法连接后端服务,请稍后再试或联系管理员。");
} else {
+2 -9
查看文件
@@ -1,7 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
archiveCostCenter,
archiveDocumentCategory,
@@ -16,7 +16,6 @@ import {
type DocumentCategoryEntityType,
type DocumentResetPeriod,
} from "../api/masterData";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
@@ -48,13 +47,7 @@ const RESET_PERIOD_OPTIONS: Array<{ value: DocumentResetPeriod; label: string }>
];
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "操作失败,请稍后重试。";
return resolveUserErrorMessage(error, "操作失败,请稍后重试。");
}
function toEnabledFilter(value: "" | "true" | "false") {
+51 -29
查看文件
@@ -1,8 +1,8 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchLeaveRequests, type LeaveRequestItem } from "../api/leave";
import { fetchPurchaseRequests, type PurchaseRequestSummary } from "../api/purchaseRequests";
import { fetchReimbursements, type ReimbursementListItem } from "../api/reimbursements";
@@ -25,8 +25,10 @@ import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { MOBILE_STICKY_ACTION_PAGE_CLASS, MobileStickyActionBar } from "../components/patterns/MobileStickyActionBar";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Spinner } from "../components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { Textarea } from "../components/ui/textarea";
@@ -171,13 +173,7 @@ function publicReference(value: string | null | undefined) {
}
function resolveErrorMessage(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "动作执行失败";
return resolveUserErrorMessage(error, "动作执行失败");
}
function isAlreadyProcessedTaskError(error: unknown) {
@@ -643,10 +639,10 @@ export function MobileApprovalsPage() {
throw new Error("请选择任务");
}
if (!selectedTask) {
throw new Error("当前待办尚未缓存,请联网加载后再操作");
throw new Error("当前审批任务尚未缓存,请联网加载后再操作");
}
if (!selectedTask.allowedActions.includes(action)) {
throw new Error("当前待办不允许该动作");
throw new Error("当前审批任务不允许该动作");
}
if (action === "reject" && !comment.trim()) {
throw new Error("驳回必须填写意见");
@@ -685,10 +681,10 @@ export function MobileApprovalsPage() {
throw new Error("请选择任务");
}
if (!selectedTask) {
throw new Error("当前待办尚未缓存,请联网加载后再操作");
throw new Error("当前审批任务尚未缓存,请联网加载后再操作");
}
if (!selectedTask.allowedActions.includes(action)) {
throw new Error("当前待办不允许该动作");
throw new Error("当前审批任务不允许该动作");
}
if (action === "approve") {
@@ -744,6 +740,11 @@ export function MobileApprovalsPage() {
});
const isActionSubmitting = actionMutation.isPending || offlineDraftSubmitting;
const transferDisabledReason = !transferToApproverId
? "请先选择目标审批人。"
: transferReason.trim().length < 10
? `请填写转审理由,至少 10 个字(还差 ${10 - transferReason.trim().length} 个字)。`
: "转审理由已满足要求。";
const syncMutation = useMutation({
mutationFn: async (items: WorkflowDraftQueueItem[]) => {
@@ -831,7 +832,7 @@ export function MobileApprovalsPage() {
}
}, [canUseRemote, retryableQueueItems, syncMutation]);
const listTitle = taskTab === "pending" ? "待我处理" : "已处理";
const listTitle = taskTab === "pending" ? "待我审批" : "已处理";
const shouldShowDraftQueue = !isOnline || currentQueueItems.length > 0;
const showMobileApprovalActions = Boolean(isPushDetail && selectedTask && selectedTaskAllowsActions);
const approvalFocusText = taskTab === "pending"
@@ -852,9 +853,7 @@ export function MobileApprovalsPage() {
<PageScaffold
className={cn(
"space-y-4",
showMobileApprovalActions
? "mobile-fixed-action-page pb-[calc(var(--mobile-sticky-action-height,78px)+env(safe-area-inset-bottom,0px)+1.25rem)]"
: "",
showMobileApprovalActions ? MOBILE_STICKY_ACTION_PAGE_CLASS : "",
)}
>
{isPushDetail ? (
@@ -871,14 +870,14 @@ export function MobileApprovalsPage() {
/>
) : (
<PageHeader
title={canAccessRemoteApprovals ? "待我处理" : "进度"}
subtitle={canAccessRemoteApprovals ? "处理待办,查看已处理。" : "查看我发起的申请进度。"}
title={canAccessRemoteApprovals ? "待我审批" : "进度"}
subtitle={canAccessRemoteApprovals ? "处理待审批任务,查看已处理记录。" : "查看我发起的申请进度。"}
/>
)}
{!isOnline && canAccessRemoteApprovals ? (
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">
当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存待办,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。
当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存审批任务,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。
</p>
) : null}
@@ -985,7 +984,7 @@ export function MobileApprovalsPage() {
<div className="mobile-command-surface">
<Tabs value={taskTab} onValueChange={handleTaskTabChange}>
<TabsList className="flex w-full justify-start gap-1">
<TabsTrigger value="pending">待我处理</TabsTrigger>
<TabsTrigger value="pending">待我审批</TabsTrigger>
<TabsTrigger value="completed" disabled={!canUseRemote}>已处理</TabsTrigger>
</TabsList>
</Tabs>
@@ -1041,7 +1040,7 @@ export function MobileApprovalsPage() {
{taskTab === "completed"
? "最近 30 天暂无符合筛选条件的已处理任务。"
: !canAccessRemoteApprovals && isOnline
? "暂无待我处理;收到企业微信审批消息后可从这里查看。"
? "暂无待我审批;收到企业微信审批消息后可从这里查看。"
: canUseRemote
? "暂无符合筛选条件的审批任务。"
: "暂无符合筛选条件的已缓存审批。"}
@@ -1102,7 +1101,7 @@ export function MobileApprovalsPage() {
{isOfflineUncachedDeepLink ? (
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">
离线未缓存该待办,请联网后重试。
离线未缓存该审批任务,请联网后重试。
</p>
) : null}
@@ -1223,7 +1222,14 @@ export function MobileApprovalsPage() {
</p>
) : (
<>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs leading-5 text-zinc-600" data-testid="mobile-workflow-action-consequence-help">
<div><span className="font-medium text-zinc-800">退回:</span>原单回到申请人修改,保留单号和历史,修改后可重新提交。</div>
<div><span className="font-medium text-zinc-800">驳回:</span>本次流程终止或进入驳回态,申请人通常需要重新发起。</div>
</div>
<Label className="sr-only" htmlFor="mobile-approvals-comment">审批意见</Label>
<Textarea
id="mobile-approvals-comment"
data-testid="mobile-approvals-comment"
onChange={(event) => setComment(event.target.value)}
placeholder="意见(驳回/退回必填)"
@@ -1235,11 +1241,10 @@ export function MobileApprovalsPage() {
<p className="text-xs text-zinc-500">离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。</p>
) : null}
<div
className="mobile-sticky-action-surface fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur"
data-testid="mobile-approvals-sticky-actions"
<MobileStickyActionBar
contentClassName="mobile-approval-action-grid"
testId="mobile-approvals-sticky-actions"
>
<div className="mobile-sticky-action-grid mobile-approval-action-grid">
<Button
className="min-h-12 w-full"
data-testid="mobile-approvals-approve"
@@ -1269,13 +1274,14 @@ export function MobileApprovalsPage() {
>
退回
</Button>
</div>
</div>
</MobileStickyActionBar>
{canTransfer ? (
<div className="mobile-list-card space-y-3 text-sm">
<p className="font-medium text-zinc-800">转审</p>
<Label htmlFor="mobile-approvals-transfer-to">目标审批人</Label>
<select
id="mobile-approvals-transfer-to"
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
data-testid="mobile-approvals-transfer-to"
onChange={(event) => setTransferToApproverId(event.target.value)}
@@ -1289,18 +1295,34 @@ export function MobileApprovalsPage() {
))}
</select>
<Label htmlFor="mobile-approvals-transfer-reason">转审理由</Label>
<Textarea
id="mobile-approvals-transfer-reason"
aria-describedby="mobile-approvals-transfer-help"
data-testid="mobile-approvals-transfer-reason"
onChange={(event) => setTransferReason(event.target.value)}
placeholder="转审理由(至少10字)"
rows={2}
value={transferReason}
/>
<p
id="mobile-approvals-transfer-help"
className={transferReason.trim().length >= 10 && transferToApproverId ? "text-xs text-emerald-700" : "text-xs text-amber-700"}
data-testid="mobile-approvals-transfer-disabled-reason"
>
{transferDisabledReason}
</p>
<Button
aria-describedby="mobile-approvals-transfer-help"
className="min-h-12 w-full"
data-testid="mobile-approvals-transfer"
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("transfer")}
disabled={
isActionSubmitting ||
!selectedTask.allowedActions.includes("transfer") ||
!transferToApproverId ||
transferReason.trim().length < 10
}
onClick={() => actionMutation.mutate("transfer")}
type="button"
variant="secondary"
+2 -9
查看文件
@@ -1,9 +1,9 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { fetchAssets, type AssetItem } from "../api/assets";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
fetchInventoryChecks,
recordInventoryItems,
@@ -11,7 +11,6 @@ import {
type InventoryCheck,
type InventoryCheckResult,
} from "../api/inventoryChecks";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
@@ -29,13 +28,7 @@ import {
} from "../mobile/inventoryScanQueue";
function resolveErrorMessage(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function statusLabel(status: string) {
+4 -13
查看文件
@@ -1,8 +1,8 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { uploadInvoice } from "../api/invoicePool";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader";
import { MobileIcon } from "../components/mobile/MobileIcon";
@@ -70,9 +70,7 @@ export function MobileCapturePage() {
} else {
result.failed.push({
file,
message: isApiError(err)
? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`
: err instanceof Error ? err.message : "上传失败",
message: resolveUserErrorMessage(err, "上传失败"),
});
}
}
@@ -99,14 +97,7 @@ export function MobileCapturePage() {
toast.success(`上传完成:成功 ${result.succeeded},待重试 ${result.queued}`);
},
onError: (err) => {
if (isApiError(err)) {
const msg = `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`;
setError(msg);
toast.error(msg);
return;
}
const msg = err instanceof Error ? err.message : "上传失败";
const msg = resolveUserErrorMessage(err, "上传失败");
setError(msg);
toast.error(msg);
},
@@ -138,7 +129,7 @@ export function MobileCapturePage() {
toast.success(`同步完成:成功 ${result.succeeded},失败 ${result.failed}`);
},
onError: (err) => {
const msg = err instanceof Error ? err.message : "队列同步失败";
const msg = resolveUserErrorMessage(err, "队列同步失败");
setError(msg);
toast.error(msg);
},
+3 -10
查看文件
@@ -1,10 +1,9 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { Link } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateNotificationPreferences } from "../api/auth";
import { fetchEmployeeProfile } from "../api/employees";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasBusinessApproverRole } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
@@ -16,13 +15,7 @@ import { Button } from "../components/ui/button";
import { MOBILE_PURCHASE_PERMISSIONS } from "../features/mobile/navigation";
function resolveErrorMessage(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function MobileActionLink({
@@ -115,7 +108,7 @@ export function MobileMePage() {
<MobileActionLink to="/mobile/leave" label="请假记录" icon="event_available" />
<MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" />
{canAccessPurchase ? <MobileActionLink to="/mobile/purchase-requests" label="采购" icon="shopping_cart" /> : null}
<MobileActionLink to="/mobile/approvals" label={canAccessApprovals ? "待我处理" : "进度"} icon="fact_check" />
<MobileActionLink to="/mobile/approvals" label={canAccessApprovals ? "待我审批" : "进度"} icon="fact_check" />
{canAccessAssets ? <MobileActionLink to="/mobile/assets/scan" label="资产扫码" icon="inventory_2" /> : null}
</div>
</SectionPanel>
+2 -2
查看文件
@@ -1,9 +1,9 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchNotifications, markAllNotificationsRead, markNotificationRead, type NotificationItem } from "../api/notifications";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { PageHeader } from "../components/common/PageHeader";
import { Pagination } from "../components/common/Pagination";
import { PageScaffold } from "../components/patterns/PageScaffold";
@@ -27,7 +27,7 @@ const NOTIFICATION_TYPE_OPTIONS = [
function resolveNotificationError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
return resolveUserErrorMessage(error);
}
return "加载通知失败";
}
+6 -44
查看文件
@@ -1,11 +1,10 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchNotifications, markNotificationRead, type NotificationItem } from "../api/notifications";
import { fetchPortalHome } from "../api/portal";
import { isApiError } from "../api/types";
import { fetchWorkflowTasks, fetchWorkflowTaskSummary } from "../api/workflow";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasBusinessApproverRole, hasFinanceRole } from "../auth/permissions";
@@ -34,13 +33,7 @@ type MobileAction = {
};
function resolveErrorMessage(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function formatDateTime(value: string | null) {
@@ -114,13 +107,6 @@ export function MobilePortalPage() {
const canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]);
const canAccessPurchase = hasAnyPermission(permissions, [...MOBILE_PURCHASE_PERMISSIONS]);
const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]);
const canAccessWecomApprovalArchive = hasAnyPermission(permissions, [
"leave.view_own",
"attendance.view_own",
"leave.view_all",
"attendance.view_all",
"*",
]);
const canCreateExpense = hasAnyPermission(permissions, ["expense.create"]);
const currentMobileSource = `${location.pathname}${location.search}`;
@@ -259,7 +245,7 @@ export function MobilePortalPage() {
return (
<PageScaffold className="mobile-page-stack">
<PageHeader
title="今天待办"
title="今日待处理"
meta={<span className="badge-neutral">移动工作台</span>}
/>
@@ -267,8 +253,8 @@ export function MobilePortalPage() {
<section className="mobile-card mobile-focus-card mobile-page-stack" data-testid="mobile-pending-summary">
<div className="mobile-section-header mobile-section-header--top">
<div className="min-w-0">
<div className="mobile-section-title">待处理</div>
<div className="mobile-list-card__subtitle mt-1">待我处理</div>
<div className="mobile-section-title">待我审批</div>
<div className="mobile-list-card__subtitle mt-1">今日待处理</div>
</div>
<div className="text-right">
<div className="text-3xl font-semibold leading-none text-zinc-900" data-testid="mobile-pending-summary-count">
@@ -307,7 +293,7 @@ export function MobilePortalPage() {
</div>
) : null}
<Button asChild className="mt-3 w-full" size="sm" type="button" variant="secondary">
<Link to="/mobile/approvals">查看全部待办</Link>
<Link to="/mobile/approvals">查看全部审批</Link>
</Button>
</section>
) : null}
@@ -327,10 +313,6 @@ export function MobilePortalPage() {
<div className="text-xs text-zinc-500">异常</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{financeExceptionCount}</div>
</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">处理</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">OA</div>
</div>
</div>
{paymentBatchEnabled ? (
<Button asChild className="mt-3 w-full" size="sm" type="button" variant="secondary">
@@ -353,26 +335,6 @@ export function MobilePortalPage() {
</section>
) : null}
<section className="mobile-card mobile-page-stack" data-testid="mobile-attendance-summary-card">
<div className="mobile-section-header">
<div className="mobile-section-title">考勤</div>
<span className="badge-neutral">企业微信打卡</span>
</div>
<div className="text-sm leading-6 text-zinc-600">
日常打卡、请假和补卡在企业微信完成;OA 保留已归档历史和考勤汇总。
</div>
<div className="mt-3 grid gap-2">
{canAccessWecomApprovalArchive ? (
<Button asChild className="w-full" size="sm" type="button" variant="secondary">
<Link to="/mobile/wecom-approval-archive">查看企微审批历史</Link>
</Button>
) : null}
<Button asChild className="w-full" size="sm" type="button" variant="secondary">
<Link to="/mobile/attendance">查看考勤汇总</Link>
</Button>
</div>
</section>
<section className="mobile-card mobile-page-stack" data-testid="mobile-notifications">
<div className="mobile-section-header">
<div className="mobile-section-title">通知</div>
+2 -8
查看文件
@@ -1,8 +1,8 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { Link, useSearchParams } from "react-router-dom";
import { fetchWecomConfig, startWecomBind, unbindWecom, updateNotificationPreferences } from "../api/auth";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { PageHeader } from "../components/common/PageHeader";
import { PageScaffold } from "../components/patterns/PageScaffold";
@@ -51,13 +51,7 @@ function roleLabel(roleCode: string) {
}
function resolveProfileError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${error.message}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function resolveBinding(
@@ -49,6 +49,7 @@ import { isTravelReimbursement } from "../lib/reimbursementTravel";
import { ReimbursementOperationalHints } from "../components/reimbursements/ReimbursementOperationalHints";
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { MOBILE_STICKY_ACTION_PAGE_CLASS, MobileStickyActionBar } from "../components/patterns/MobileStickyActionBar";
import { Button } from "../components/ui/button";
import {
Dialog,
@@ -881,10 +882,7 @@ export function MobileReimbursementDetailPage() {
<PageScaffold
className={cn(
"space-y-4",
showStickyActions ? "mobile-fixed-action-page" : "",
showStickyActions
? "pb-[calc(var(--mobile-sticky-action-height,78px)+env(safe-area-inset-bottom,0px)+1.25rem)]"
: "pb-6",
showStickyActions ? MOBILE_STICKY_ACTION_PAGE_CLASS : "pb-6",
)}
data-testid="mobile-reimbursement-detail"
>
@@ -1333,11 +1331,7 @@ export function MobileReimbursementDetailPage() {
) : null}
{showStickyActions ? (
<div
className="mobile-sticky-action-surface fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur"
data-testid="mobile-reimbursement-sticky-actions"
>
<div className="mobile-sticky-action-grid">
<MobileStickyActionBar testId="mobile-reimbursement-sticky-actions">
{showSubmitAction ? (
<Button
className="min-h-11 min-w-0 px-2 text-sm whitespace-nowrap"
@@ -1349,8 +1343,7 @@ export function MobileReimbursementDetailPage() {
{submitMutation.isPending ? "提交中..." : "提交审批"}
</Button>
) : null}
</div>
</div>
</MobileStickyActionBar>
) : null}
<Dialog open={isInvoiceDialogOpen} onOpenChange={closeInvoiceDialog}>
+3 -10
查看文件
@@ -1,7 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
createNotificationChannel,
fetchNotificationDeliveries,
@@ -15,7 +15,6 @@ import {
type NotificationChannelTemplate,
type NotificationChannelWriteConfig,
} from "../api/notificationChannels";
import { isApiError } from "../api/types";
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { Button } from "../components/ui/button";
@@ -75,7 +74,7 @@ const ROLE_OPTIONS = [
] as const;
const DOMAIN_SCOPE_OPTIONS = [
{ value: "workflow", label: "待我处理" },
{ value: "workflow", label: "待我审批" },
{ value: "reimbursement", label: "报销" },
{ value: "leave", label: "请假" },
{ value: "procurement", label: "采购" },
@@ -103,13 +102,7 @@ const NOTIFICATION_TYPE_OPTIONS = [
] as const;
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function buildInitialForm(): ChannelFormState {
+1 -9
查看文件
@@ -2,8 +2,6 @@ import { Link, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchNotifications, markAllNotificationsRead, markNotificationRead } from "../api/notifications";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { Pagination } from "../components/common/Pagination";
import { Button } from "../components/ui/button";
@@ -150,13 +148,7 @@ export function NotificationsPage() {
{listQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(listQuery.error)
? `${listQuery.error.code}: ${mapApiErrorMessage(
listQuery.error.code,
listQuery.error.details,
listQuery.error.message,
)}`
: "加载通知失败"}
{resolveErrorMessage(listQuery.error, "加载通知失败")}
</p>
) : null}
+2 -2
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -13,7 +14,6 @@ import {
type PaymentAccount,
} from "../api/paymentAccounts";
import { fetchLegalEntities } from "../api/masterData";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
@@ -27,7 +27,7 @@ import { Label } from "../components/ui/label";
import { Textarea } from "../components/ui/textarea";
function errorText(error: unknown) {
return isApiError(error) ? `${error.code}: ${error.message}` : error instanceof Error ? error.message : "操作失败";
return resolveUserErrorMessage(error, "操作失败");
}
export function PaymentAccountsPage() {
+2 -7
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useSearchParams } from "react-router-dom";
@@ -20,8 +21,6 @@ import {
type PaymentBatchReceiptAttachment,
type PaymentChannel,
} from "../api/paymentBatches";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { HeaderActionGroup, ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
@@ -109,11 +108,7 @@ function formatFileSize(value: number) {
}
function resolveError(error: unknown) {
return isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
: error instanceof Error
? error.message
: "操作失败";
return resolveUserErrorMessage(error, "操作失败");
}
function paymentOrderBlockerLabel(blocker: { code: string; entityCode?: string }) {
+2 -6
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -14,8 +15,6 @@ import {
type SalaryItemType,
} from "../api/payroll";
import { exportPayslips } from "../api/exports";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
@@ -58,10 +57,7 @@ function currentMonthString() {
}
function errorMessage(error: unknown, fallback: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
function moneyText(value: number) {
+2 -7
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -22,8 +23,6 @@ import {
} from "../api/employees";
import { exportEmployees } from "../api/exports";
import { fetchDepartments } from "../api/departments";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
@@ -55,11 +54,7 @@ const EMPLOYMENT_STATUS_OPTIONS: Array<{ value: EmployeeEmploymentStatus; label:
];
function mapApiMessage(error: unknown, fallback: string) {
return isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
: error instanceof Error
? error.message
: fallback;
return resolveUserErrorMessage(error, fallback);
}
function employmentStatusLabel(status: EmployeeEmploymentStatus | null) {
+2 -6
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -8,8 +9,6 @@ import {
patchPerformanceCycle,
type PerformanceReviewCycleType,
} from "../api/performance";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
@@ -48,10 +47,7 @@ function buildEmptyCycleForm(): CycleFormState {
}
function errorMessage(error: unknown, fallback: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
function cycleTypeLabel(value: PerformanceReviewCycleType) {
+84 -18
查看文件
@@ -1,13 +1,11 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchPortalHome, type PortalHomePayload } from "../api/portal";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, isManagerRole } from "../auth/permissions";
import { ErrorBoundary } from "../components/common/ErrorBoundary";
import { WelcomeDialog } from "../components/onboarding/WelcomeDialog";
import { WelcomeDialog, type WelcomeLinkItem } from "../components/onboarding/WelcomeDialog";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
@@ -15,6 +13,7 @@ import {
clearWelcomeDialogFlag,
shouldShowWelcomeDialog,
} from "../lib/onboardingGuide";
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
type RoleCapabilities = NonNullable<PortalHomePayload["roleCapabilities"]>;
@@ -50,7 +49,7 @@ const LEAVE_PORTAL_PERMISSIONS = ["leave.view_own", "leave.view_all", "leave.cre
const EXPENSE_PORTAL_PERMISSIONS = ["expense.view_own", "expense.view_all", "expense.create", "expense.proxy_manage"];
const PROCUREMENT_PORTAL_PERMISSIONS = ["procurement.view_own", "procurement.view_all", "procurement.create", "procurement.submit"];
const TRAVEL_PORTAL_PERMISSIONS = ["travel.view_own", "travel.view_all", "travel.create", "travel.submit"];
const APPROVAL_PORTAL_PERMISSIONS = ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"];
const APPROVAL_PORTAL_PERMISSIONS = ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"];
const FINANCE_PORTAL_PERMISSIONS = ["payment.batch.manage", "risk.review.manage", "expense.finance.pay"];
const SYSTEM_PORTAL_PERMISSIONS = ["system.users", "system.settings", "system.audit.view", "notification.channel.manage", "payment.account.read", "payment.account.manage"];
const CONTRACT_PORTAL_PERMISSIONS = ["project.view_all", "project.manage", "system.settings"];
@@ -112,9 +111,9 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
if (payload.pendingTasks.count > 0) {
items.push({
key: "workflow-pending",
title: `待我处理 ${payload.pendingTasks.count} 条`,
title: `待我审批 ${payload.pendingTasks.count} 条`,
reason: "审批、确认或流程处理正在等你。",
meta: "流程待办",
meta: "审批任务",
href: payload.pendingTasks.href,
actionLabel: "去处理",
severity: "warning",
@@ -129,13 +128,13 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
const highPriorityCount = payload.todoSummary?.highPriorityCount ?? 0;
items.push({
key: "todo-open",
title: `待办事项 ${openTodoCount} 条`,
title: `我的事项 ${openTodoCount} 条`,
reason: highPriorityCount > 0
? `其中 ${highPriorityCount} 条是高优先级,需要先确认。`
: "这些事项已分配给你,需要继续推进。",
meta: highPriorityCount > 0 ? "高优先级" : "个人待办",
meta: highPriorityCount > 0 ? "高优先级" : "个人事项",
href: payload.todoSummary?.href ?? "/todos",
actionLabel: "查看待办",
actionLabel: "查看事项",
severity: highPriorityCount > 0 ? "high" : "normal",
icon: "check_circle",
priority: 90,
@@ -497,6 +496,39 @@ function buildQuickEntries(permissions: string[], roleCapabilities: RoleCapabili
return entries.slice(0, 6);
}
function buildWelcomeLinks(permissions: string[], roleCapabilities: RoleCapabilities): WelcomeLinkItem[] {
const links: WelcomeLinkItem[] = [];
if (roleCapabilities.canApproveTasks && hasAnyPermission(permissions, APPROVAL_PORTAL_PERMISSIONS)) {
links.push({
icon: "fact_check",
label: "待我审批",
description: "查看今天需要审批的流程任务",
to: "/workflow/tasks",
});
}
if (!roleCapabilities.isAdminOnly && hasAnyPermission(permissions, EXPENSE_PORTAL_PERMISSIONS)) {
links.push({
icon: "request_quote",
label: "报销",
description: "录入票据和报销说明,查看处理进度",
to: "/reimbursements",
});
}
if (!roleCapabilities.isAdminOnly && hasAnyPermission(permissions, LEAVE_PORTAL_PERMISSIONS)) {
links.push({
icon: "event_available",
label: "请假记录",
description: "日常请假走企业微信,OA 查看记录和状态",
to: "/leave-requests",
});
}
return links;
}
function sumWorkItemCount(items: WorkItem[]) {
return items.reduce((total, item) => total + Math.max(1, item.count), 0);
}
@@ -538,7 +570,10 @@ function WorkItemRow({ item }: { item: WorkItem }) {
: "portal-task-row";
return (
<Link to={item.href} className={severityClass}>
<Link
to={item.href}
className={`${severityClass} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2`}
>
<span className="portal-task-row__icon" aria-hidden="true">
<span className="material-symbols-outlined text-[18px]">{item.icon}</span>
</span>
@@ -570,7 +605,11 @@ function QuickEntryPanel({ entries }: { entries: QuickEntry[] }) {
<SectionPanel title="常用入口">
<div className="portal-quick-entry-grid" data-testid="portal-quick-entries">
{entries.map((entry) => (
<Link className="portal-quick-entry" key={entry.key} to={entry.href}>
<Link
className="portal-quick-entry focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
key={entry.key}
to={entry.href}
>
<span className="portal-quick-entry__icon" aria-hidden="true">
<span className="material-symbols-outlined text-[18px]">{entry.icon}</span>
</span>
@@ -653,10 +692,32 @@ export function PortalHomePage() {
}
if (portalQuery.isError || !portalQuery.data) {
const message = isApiError(portalQuery.error)
? `${portalQuery.error.code}: ${mapApiErrorMessage(portalQuery.error.code, portalQuery.error.details, portalQuery.error.message)}`
: "工作台加载失败";
return <div className="card-std p-5 text-sm text-red-600">{message}</div>;
const message = resolveUserErrorMessage(portalQuery.error, "工作台加载失败,请稍后重试");
return (
<PageScaffold>
<section
aria-busy={portalQuery.isFetching}
aria-live="polite"
className="card-std space-y-3 p-5"
data-testid="portal-load-error"
role="alert"
>
<div>
<p className="text-sm font-medium text-red-700">{message}</p>
<p className="mt-1 text-sm text-zinc-600">工作台内容暂时不可用,重新加载不会清除当前登录状态。</p>
</div>
<Button
data-testid="portal-load-retry"
disabled={portalQuery.isFetching}
onClick={() => void portalQuery.refetch()}
type="button"
variant="secondary"
>
{portalQuery.isFetching ? "重新加载中..." : "重新加载"}
</Button>
</section>
</PageScaffold>
);
}
const payload = portalQuery.data;
@@ -669,6 +730,7 @@ export function PortalHomePage() {
isManagerRole(permissions);
const todayActionItems = buildTodayActionItems(payload, roleCapabilities);
const quickEntries = buildQuickEntries(permissions, roleCapabilities);
const welcomeLinks = buildWelcomeLinks(permissions, roleCapabilities);
const progressItems = buildProgressItems(payload);
const teamBlockers = isManagerView ? buildTeamBlockers(payload, roleCapabilities) : [];
const managementReminders = buildManagementReminders(payload, roleCapabilities, canSetupWorkspace);
@@ -686,7 +748,11 @@ export function PortalHomePage() {
return (
<PageScaffold>
<ErrorBoundary fallback={null}>
<WelcomeDialog open={showWelcome} onClose={handleCloseWelcome} />
<WelcomeDialog
links={welcomeLinks}
open={showWelcome && welcomeLinks.length > 0}
onClose={handleCloseWelcome}
/>
</ErrorBoundary>
<SummaryStrip
@@ -697,10 +763,10 @@ export function PortalHomePage() {
reminderCount={reminderCount}
/>
<QuickEntryPanel entries={quickEntries} />
<TodayActionPanel items={todayActionItems} emptyActions={emptyActions} />
<QuickEntryPanel entries={quickEntries} />
<div className="portal-task-secondary-grid">
<ConditionalWorkPanel title="我的进度" items={progressItems} />
<ConditionalWorkPanel title="团队卡点" items={teamBlockers} />
+2 -8
查看文件
@@ -1,8 +1,8 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { Link, useSearchParams } from "react-router-dom";
import { fetchWecomConfig, startWecomBind, unbindWecom, updateNotificationPreferences } from "../api/auth";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { PageHeader } from "../components/common/PageHeader";
import { SectionPanel } from "../components/patterns/SectionPanel";
@@ -58,13 +58,7 @@ function roleLabel(roleCode: string) {
}
function resolveProfileError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${error.message}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
export function ProfilePage() {
+3 -7
查看文件
@@ -1,17 +1,16 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchEmployees } from "../api/employees";
import { archiveProject, createConfidentialDocumentRef, createProjectCommunicationLog, createProjectDelivery, createProjectMember, createProjectMilestone, createProjectRisk, createProjectTask, fetchProject, fetchProjectClosureChecklist, fetchProjectCommunicationLogs, fetchProjectDeliveries, fetchProjectDocumentRefs, fetchProjectExpensesSummary, fetchProjectMembers, fetchProjectMilestones, fetchProjectPurchaseSummary, fetchProjectRisks, fetchProjectTasks, fetchProjectTravelSummary, fetchProjectWeeklyReports, patchProject, patchProjectClosureChecklistItem, patchProjectCommunicationLog, patchProjectMember, patchProjectMilestone, patchProjectRisk, patchProjectTask, type ConfidentialityLevel, type DocumentStorageType, type ProjectCommunicationChannel, type ProjectClosureChecklistStatus, type ProjectDeliveryRecordType, type ProjectDetail, type ProjectMilestoneStatus, type ProjectRiskLevel, type ProjectStatus, type ProjectTaskPriority, type ProjectTaskStatus } from "../api/projects";
import { fetchContractsRegistry } from "../api/contractsRegistry";
import { mapApiErrorMessage } from "../api/errorMessages";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import { MetricCard } from "../components/patterns/MetricCard";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { isApiError } from "../api/types";
import { DateTextInput } from "../components/common/DateTextInput";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
@@ -89,10 +88,7 @@ function riskStatusLabel(value: string) {
}
function resolveError(error: unknown, fallback = "操作失败") {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
function confidentialityLabel(value: ConfidentialityLevel) {
@@ -1094,7 +1090,7 @@ export function ProjectDetailPage() {
<div>交付记录:{counts?.deliveries ?? 0}</div>
<div>资料引用:{counts?.documentRefs ?? 0}</div>
<div className="rounded-lg border border-zinc-200 p-3 text-zinc-600">
项目相关的出差、报销、待办、周报都可以在当前项目下汇总查看;涉密正文与附件仍不进 OA。
项目相关的出差、报销、事项、周报都可以在当前项目下汇总查看;涉密正文与附件仍不进 OA。
</div>
</DetailSideMeta>
+2 -6
查看文件
@@ -1,15 +1,14 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { fetchEmployees } from "../api/employees";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
createProject,
fetchProjects,
type ProjectListItem,
type ProjectStatus,
} from "../api/projects";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions";
import { DateTextInput } from "../components/common/DateTextInput";
@@ -45,10 +44,7 @@ const STATUS_OPTIONS: Array<{ value: "all" | ProjectStatus; label: string }> = [
];
function resolveError(error: unknown, fallback = "操作失败") {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
function summaryLine(item: ProjectListItem) {
+3 -10
查看文件
@@ -1,9 +1,8 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
archivePurchaseOrder,
createPurchaseRequest,
@@ -95,13 +94,7 @@ function purchaseDraftSignature(value: {
}
function resolveError(error: unknown) {
if (isApiError(error)) {
return mapApiErrorMessage(error.code, error.details, error.message);
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function sanitizePurchaseAttachmentDownloadName(value?: string) {
@@ -1430,7 +1423,7 @@ export function PurchaseRequestsPage() {
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-600">
<div>预算池:{detail.budgetPool?.name ?? "未匹配"}</div>
<div>关联项目:{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联项目"}</div>
<div>待办节点:{detail.pendingWorkflow?.task?.stepName ?? "-"}</div>
<div>当前审批节点:{detail.pendingWorkflow?.task?.stepName ?? "-"}</div>
<div>总金额:<span className="font-semibold text-zinc-900">{totalAmountPreview}</span></div>
<div
className={`mt-2 rounded-md border px-2 py-1 ${
@@ -1,7 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchDepartments } from "../api/departments";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
fetchReimbursementApprovalBottleneck,
fetchReimbursementStatsSummary,
@@ -9,7 +9,6 @@ import {
fetchReimbursementTopExpenseTypes,
type ReimbursementStatsQuery,
} from "../api/reimbursements";
import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
@@ -26,13 +25,7 @@ function defaultDateFromString() {
}
function resolveErrorMessage(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "加载失败,请稍后重试。";
return resolveUserErrorMessage(error, "加载失败,请稍后重试。");
}
function formatCurrency(value: string | number | null | undefined) {
+16 -83
查看文件
@@ -1,9 +1,8 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useCallback, useEffect, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, ChevronRight } from "lucide-react";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchExpenseTypes } from "../api/expenseTypes";
import type { ExpenseStandardCheck, PrecheckResponse } from "../api/feeControl";
import { fetchProjects } from "../api/projects";
@@ -100,15 +99,7 @@ type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | n
type SelectedInvoiceMap = Record<string, string>;
function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return fallbackMessage;
return resolveUserErrorMessage(error, fallbackMessage);
}
export function ReimbursementDetailPage() {
@@ -247,21 +238,9 @@ export function ReimbursementDetailPage() {
toast.success(actionMessages[params.action] ?? "操作成功");
},
onError: (error) => {
if (isApiError(error)) {
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
setActionError(msg);
toast.error(msg);
return;
}
if (error instanceof Error) {
setActionError(error.message);
toast.error(error.message);
return;
}
setActionError("操作失败,请稍后重试");
toast.error("操作失败,请稍后重试");
const message = buildActionErrorMessage(error, "操作失败,请稍后重试");
setActionError(message);
toast.error(message);
},
});
@@ -315,21 +294,9 @@ export function ReimbursementDetailPage() {
toast.success("明细已保存");
},
onError: (error) => {
if (isApiError(error)) {
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
setItemError(msg);
toast.error(msg);
return;
}
if (error instanceof Error) {
setItemError(error.message);
toast.error(error.message);
return;
}
setItemError("保存明细失败,请稍后重试");
toast.error("保存明细失败,请稍后重试");
const message = buildActionErrorMessage(error, "保存明细失败,请稍后重试");
setItemError(message);
toast.error(message);
},
});
@@ -351,21 +318,9 @@ export function ReimbursementDetailPage() {
toast.success("明细已删除");
},
onError: (error) => {
if (isApiError(error)) {
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
setItemError(msg);
toast.error(msg);
return;
}
if (error instanceof Error) {
setItemError(error.message);
toast.error(error.message);
return;
}
setItemError("删除明细失败,请稍后重试");
toast.error("删除明细失败,请稍后重试");
const message = buildActionErrorMessage(error, "删除明细失败,请稍后重试");
setItemError(message);
toast.error(message);
},
});
@@ -401,11 +356,7 @@ export function ReimbursementDetailPage() {
toast.success("已更新关联项目");
},
onError: (error) => {
const message = isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
: error instanceof Error
? error.message
: "关联项目失败";
const message = buildActionErrorMessage(error, "关联项目失败");
toast.error(message);
},
});
@@ -756,13 +707,7 @@ export function ReimbursementDetailPage() {
}
if (detailQuery.isError || !detailQuery.data) {
const message = isApiError(detailQuery.error)
? `${detailQuery.error.code}: ${mapApiErrorMessage(
detailQuery.error.code,
detailQuery.error.details,
detailQuery.error.message,
)}`
: "加载报销详情失败";
const message = resolveUserErrorMessage(detailQuery.error, "加载报销详情失败");
return (
<div className="card-std space-y-3 p-5">
@@ -893,21 +838,9 @@ export function ReimbursementDetailPage() {
mutation.mutate({ action: "submit" });
} catch (error) {
if (isApiError(error)) {
const message = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
setActionError(message);
toast.error(message);
return;
}
if (error instanceof Error) {
setActionError(error.message);
toast.error(error.message);
return;
}
setActionError("提交前校验失败,请稍后重试");
toast.error("提交前校验失败,请稍后重试");
const message = buildActionErrorMessage(error, "提交前校验失败,请稍后重试");
setActionError(message);
toast.error(message);
}
};
+2 -9
查看文件
@@ -1,10 +1,9 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { QRCodeSVG } from "qrcode.react";
import { fetchReimbursementDetail } from "../api/reimbursements";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { useAuth } from "../auth/AuthContext";
import { CardSkeleton } from "../components/common/CardSkeleton";
import { Button } from "../components/ui/button";
@@ -65,13 +64,7 @@ export function ReimbursementPrintPage() {
}
if (detailQuery.isError || !detailQuery.data) {
const message = isApiError(detailQuery.error)
? `${detailQuery.error.code}: ${mapApiErrorMessage(
detailQuery.error.code,
detailQuery.error.details,
detailQuery.error.message,
)}`
: "加载打印视图失败";
const message = resolveUserErrorMessage(detailQuery.error, "加载打印视图失败");
return (
<div className="card-std space-y-3 p-6">
@@ -1,7 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchExpenseTypes } from "../api/expenseTypes";
import {
createReimbursementTemplate,
@@ -11,7 +11,6 @@ import {
type ReimbursementTemplate,
type ReimbursementTemplateItem,
} from "../api/reimbursementTemplates";
import { isApiError } from "../api/types";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
@@ -35,13 +34,7 @@ type FormState = {
};
function resolveErrorMessage(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试";
return resolveUserErrorMessage(error, "请求失败,请稍后重试");
}
function emptyForm(expenseTypeId = ""): FormState {
+5 -20
查看文件
@@ -1,8 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { Link, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchMyDelegations } from "../api/delegations";
import { exportReimbursements } from "../api/exports";
import { fetchReimbursementTemplates } from "../api/reimbursementTemplates";
@@ -61,10 +60,7 @@ function formatCurrency(value: number) {
}
function resolveErrorMessage(error: unknown, fallback: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
function resolveEmptyStateCopy(status: ReimbursementStatus) {
@@ -344,14 +340,9 @@ export function ReimbursementsPage() {
}
/>
{createMutation.error && isApiError(createMutation.error) ? (
{createMutation.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{createMutation.error.code}:
{mapApiErrorMessage(
createMutation.error.code,
createMutation.error.details,
createMutation.error.message,
)}
{resolveUserErrorMessage(createMutation.error, "创建报销单失败")}
</p>
) : null}
@@ -500,13 +491,7 @@ export function ReimbursementsPage() {
{listQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(listQuery.error)
? `${listQuery.error.code}: ${mapApiErrorMessage(
listQuery.error.code,
listQuery.error.details,
listQuery.error.message,
)}`
: "加载报销列表失败"}
{resolveUserErrorMessage(listQuery.error, "加载报销列表失败")}
</p>
) : null}
+2 -11
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchDepartments } from "../api/departments";
@@ -15,8 +16,6 @@ import {
type TeamPresenceHistoryItem,
upsertMyPresence,
} from "../api/presence";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
@@ -79,15 +78,7 @@ function offsetDateString(offsetDays: number) {
}
function resolveErrorMessage(error: unknown, fallback = "加载失败") {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
return resolveUserErrorMessage(error, fallback);
}
function resolveEditableStatus(item: TeamPresenceHistoryItem | null): EditablePresenceStatus {
+36 -29
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { cancelTodo, completeTodo, createTodo, fetchTodos } from "../api/todos";
@@ -8,19 +9,22 @@ import { MetricCard } from "../components/patterns/MetricCard";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { toast } from "sonner";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { TEST_IDS } from "../lib/testIds";
function errorMessage(error: unknown, fallback: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
const TODO_STATUS_LABELS = {
todo: "待处理",
doing: "进行中",
done: "已完成",
cancelled: "已取消",
} as const;
export function TodosPage() {
const queryClient = useQueryClient();
const [title, setTitle] = useState("");
@@ -60,9 +64,9 @@ export function TodosPage() {
queryClient.invalidateQueries({ queryKey: ["todos"] }),
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
]);
toast.success("待办已创建");
toast.success("事项已创建");
},
onError: (error) => toast.error(errorMessage(error, "创建待办失败")),
onError: (error) => toast.error(errorMessage(error, "创建事项失败")),
});
const completeMutation = useMutation({
@@ -72,9 +76,9 @@ export function TodosPage() {
queryClient.invalidateQueries({ queryKey: ["todos"] }),
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
]);
toast.success("待办已完成");
toast.success("事项已完成");
},
onError: (error) => toast.error(errorMessage(error, "完成待办失败")),
onError: (error) => toast.error(errorMessage(error, "完成事项失败")),
});
const cancelMutation = useMutation({
@@ -84,17 +88,17 @@ export function TodosPage() {
queryClient.invalidateQueries({ queryKey: ["todos"] }),
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
]);
toast.success("待办已取消");
toast.success("事项已取消");
},
onError: (error) => toast.error(errorMessage(error, "取消待办失败")),
onError: (error) => toast.error(errorMessage(error, "取消事项失败")),
});
return (
<ListWorkspace>
<PageHeader title="待办" subtitle="员工维护个人待办,老板看高优先级和未完成项。支持直接挂到项目。" />
<PageHeader title="我的事项" subtitle="维护个人事项,集中查看高优先级和未完成项,并可关联到项目。" />
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard title="全部待办" value={todoStats.total} note="当前筛选结果" />
<MetricCard title="全部事项" value={todoStats.total} note="当前筛选结果" />
<MetricCard title="待处理" value={todoStats.todo} note="建议优先清空新增事项" tone={todoStats.todo > 0 ? "warning" : "neutral"} />
<MetricCard title="进行中" value={todoStats.doing} note="重点跟进阻塞项" />
<MetricCard title="已完成" value={todoStats.done} note="已完成项会持续留痕" />
@@ -105,12 +109,12 @@ export function TodosPage() {
<FilterBar className="flex flex-wrap items-center justify-between gap-3">
<div className="text-sm text-zinc-500">按状态筛选后,列表只保留当前关注的工作项。</div>
<div className="flex flex-wrap items-center gap-2">
<select className="select-std w-[180px]" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as typeof statusFilter)}>
<Label className="sr-only" htmlFor="todo-status-filter">事项状态</Label>
<select id="todo-status-filter" className="select-std w-[180px]" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as typeof statusFilter)}>
<option value="">全部状态</option>
<option value="todo">todo</option>
<option value="doing">doing</option>
<option value="done">done</option>
<option value="cancelled">cancelled</option>
{Object.entries(TODO_STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<Button type="button" variant="secondary" onClick={() => queryClient.invalidateQueries({ queryKey: ["todos"] })}>
刷新列表
@@ -118,8 +122,8 @@ export function TodosPage() {
</div>
</FilterBar>
<SectionPanel title="待办列表" description={todoItems.length ? `当前共 ${todoItems.length} 条待办` : "暂无待办,建议从当前周计划或项目动作拆出任务。"}>
{todosQuery.isError ? <div className="mb-4 text-sm text-red-600">{errorMessage(todosQuery.error, "加载待办失败")}</div> : null}
<SectionPanel title="事项列表" description={todoItems.length ? `当前共 ${todoItems.length} 条事项` : "暂无事项,建议从当前周计划或项目动作拆出任务。"}>
{todosQuery.isError ? <div className="mb-4 text-sm text-red-600">{errorMessage(todosQuery.error, "加载事项失败")}</div> : null}
<PrimaryTable>
<Table>
<TableHeader>
@@ -136,7 +140,7 @@ export function TodosPage() {
{todosQuery.isLoading ? (
<TableRow>
<TableCell className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
待办加载中...
事项加载中...
</TableCell>
</TableRow>
) : todoItems.length ? (
@@ -149,7 +153,7 @@ export function TodosPage() {
<TableCell>{item.project ? `${item.project.projectNo} · ${item.project.name}` : "-"}</TableCell>
<TableCell>{item.assignee?.name ?? "-"}</TableCell>
<TableCell>{item.priority}</TableCell>
<TableCell>{item.status}</TableCell>
<TableCell>{TODO_STATUS_LABELS[item.status as keyof typeof TODO_STATUS_LABELS] ?? "未知状态"}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
@@ -171,7 +175,7 @@ export function TodosPage() {
) : (
<TableRow>
<TableCell className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
当前没有符合条件的待办。
当前没有符合条件的事项。
</TableCell>
</TableRow>
)}
@@ -181,16 +185,19 @@ export function TodosPage() {
</SectionPanel>
</div>
<SectionPanel title="新增待办" description="建议把周报计划、项目跟进或临时事项都拆成清晰动作。">
<SectionPanel title="新增事项" description="建议把周报计划、项目跟进或临时事项都拆成清晰动作。">
<div className="space-y-4">
<div className="space-y-2">
<Input data-testid={TEST_IDS.todo.createTitle} placeholder="待办标题" value={title} onChange={(event) => setTitle(event.target.value)} />
<Label htmlFor="todo-title">事项标题</Label>
<Input id="todo-title" data-testid={TEST_IDS.todo.createTitle} placeholder="请输入事项标题" value={title} onChange={(event) => setTitle(event.target.value)} />
</div>
<div className="space-y-2">
<Input placeholder="补充说明(可选)" value={description} onChange={(event) => setDescription(event.target.value)} />
<Label htmlFor="todo-description">补充说明(可选)</Label>
<Input id="todo-description" placeholder="补充背景或下一步" value={description} onChange={(event) => setDescription(event.target.value)} />
</div>
<div className="space-y-2">
<select className="select-std" value={projectId} onChange={(event) => setProjectId(event.target.value)}>
<Label htmlFor="todo-project">关联项目(可选)</Label>
<select id="todo-project" className="select-std" value={projectId} onChange={(event) => setProjectId(event.target.value)}>
<option value="">不关联项目</option>
{(projectsQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
@@ -200,7 +207,7 @@ export function TodosPage() {
</select>
</div>
<Button data-testid={TEST_IDS.todo.createSubmit} type="button" onClick={() => createMutation.mutate()} disabled={createMutation.isPending || !title.trim()}>
{createMutation.isPending ? "新建中..." : "新建待办"}
{createMutation.isPending ? "新建中..." : "新建事项"}
</Button>
</div>
</SectionPanel>
+2 -22
查看文件
@@ -1,10 +1,9 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { fetchProjects } from "../api/projects";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import {
createTravelRequest,
fetchTravelRequestDetail,
@@ -54,13 +53,7 @@ type DraftSegment = {
};
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function emptySegment(): DraftSegment {
@@ -306,19 +299,6 @@ export function TravelRequestsPage() {
}
}, [detailQuery.data, selectedId]);
useEffect(() => {
if (activeTab !== "requests" || selectedId || canCreate) {
return;
}
const nextId = listQuery.data?.items?.[0]?.id ?? "";
if (!nextId) {
return;
}
const next = new URLSearchParams(searchParams);
next.set("id", nextId);
setSearchParams(next);
}, [activeTab, canCreate, listQuery.data?.items, searchParams, selectedId, setSearchParams]);
const detail = detailQuery.data ?? null;
const isDraftEditing = Boolean(detail && detail.status === "draft" && detail.permissions.canEditDraft);
+6 -13
查看文件
@@ -1,7 +1,6 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { fetchDepartments } from "../api/departments";
import { fetchEmployees } from "../api/employees";
import {
@@ -123,7 +122,7 @@ export function UserManagementPage() {
toast.success("用户已创建");
},
onError: (err) => {
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "创建用户失败";
const msg = resolveUserErrorMessage(err, "创建用户失败");
setError(msg);
toast.error(msg);
},
@@ -142,7 +141,7 @@ export function UserManagementPage() {
toast.success("状态已更新");
},
onError: (err) => {
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "更新用户状态失败";
const msg = resolveUserErrorMessage(err, "更新用户状态失败");
setError(msg);
toast.error(msg);
},
@@ -157,7 +156,7 @@ export function UserManagementPage() {
toast.success("密码已重置");
},
onError: (err) => {
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "重置密码失败";
const msg = resolveUserErrorMessage(err, "重置密码失败");
setError(msg);
toast.error(msg);
},
@@ -174,7 +173,7 @@ export function UserManagementPage() {
toast.success("角色已更新");
},
onError: (err) => {
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "更新角色失败";
const msg = resolveUserErrorMessage(err, "更新角色失败");
setError(msg);
toast.error(msg);
},
@@ -281,13 +280,7 @@ export function UserManagementPage() {
{listQuery.isLoading ? <TableSkeleton columns={5} rows={5} /> : null}
{listQuery.isError ? (
<p className="text-sm text-red-600">
{isApiError(listQuery.error)
? `${listQuery.error.code}: ${mapApiErrorMessage(
listQuery.error.code,
listQuery.error.details,
listQuery.error.message,
)}`
: "加载用户列表失败"}
{resolveUserErrorMessage(listQuery.error, "加载用户列表失败")}
</p>
) : null}
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
@@ -16,8 +17,6 @@ import { TableSkeleton } from "../components/common/TableSkeleton";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { Label } from "../components/ui/label";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
const TYPE_OPTIONS: Array<{ value: "" | WecomApprovalArchiveType; label: string }> = [
{ value: "", label: "全部类型" },
@@ -43,10 +42,7 @@ const FINDING_OPTIONS: Array<{ value: "" | WecomApprovalArchiveFinding; label: s
];
function resolveError(error: unknown, fallback = "读取企微审批历史失败") {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
function typeLabel(value: string) {
+5 -9
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createTodo } from "../api/todos";
@@ -13,15 +14,10 @@ import { Button } from "../components/ui/button";
import { Textarea } from "../components/ui/textarea";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { toast } from "sonner";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { TEST_IDS } from "../lib/testIds";
function errorMessage(error: unknown, fallback: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
type DraftProjectLink = {
@@ -122,9 +118,9 @@ export function WeeklyReportsPage() {
queryClient.invalidateQueries({ queryKey: ["todos"] }),
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
]);
toast.success("已从周报生成待办");
toast.success("已从周报生成事项");
},
onError: (error) => toast.error(errorMessage(error, "生成待办失败")),
onError: (error) => toast.error(errorMessage(error, "生成事项失败")),
});
return (
@@ -200,7 +196,7 @@ export function WeeklyReportsPage() {
</div>
))}
<div className="flex flex-wrap justify-end gap-2">
<Button data-testid={TEST_IDS.weeklyReport.createTodo} type="button" variant="secondary" onClick={() => createTodoMutation.mutate()} disabled={createTodoMutation.isPending}>转成待办</Button>
<Button data-testid={TEST_IDS.weeklyReport.createTodo} type="button" variant="secondary" onClick={() => createTodoMutation.mutate()} disabled={createTodoMutation.isPending}>转成事项</Button>
<Button data-testid={TEST_IDS.weeklyReport.save} type="button" variant="secondary" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存草稿</Button>
<Button data-testid={TEST_IDS.weeklyReport.submit} type="button" onClick={() => submitMutation.mutate()} disabled={submitMutation.isPending}>提交周报</Button>
</div>
+40 -19
查看文件
@@ -1,9 +1,9 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { Link, Navigate, useLocation, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
approveWorkflowTask,
fetchWorkflowApprovers,
@@ -31,6 +31,7 @@ import { MetricCard } from "../components/patterns/MetricCard";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { Label } from "../components/ui/label";
import { Textarea } from "../components/ui/textarea";
import { approvalRoleLabel, resolvePurchaseDisplayCode, workflowActionLabel } from "../lib/approvalDisplay";
import { formatDisplayDateTime } from "../lib/displayFormat";
@@ -177,14 +178,7 @@ export function WorkflowTaskDetailPage() {
queryClient.invalidateQueries({ queryKey: ["workflow", "task", taskId] }),
]);
}
let msg: string;
if (error instanceof Error && !isApiError(error)) {
msg = error.message;
} else if (isApiError(error)) {
msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
} else {
msg = "动作执行失败,请稍后重试";
}
const msg = resolveUserErrorMessage(error, "动作执行失败,请稍后重试");
setActionError(msg);
toast.error(msg);
},
@@ -238,13 +232,7 @@ export function WorkflowTaskDetailPage() {
}
if (detailQuery.isError || !detailQuery.data) {
const message = isApiError(detailQuery.error)
? `${detailQuery.error.code}: ${mapApiErrorMessage(
detailQuery.error.code,
detailQuery.error.details,
detailQuery.error.message,
)}`
: "加载失败,请返回列表重试";
const message = resolveUserErrorMessage(detailQuery.error, "加载失败,请返回列表重试");
return (
<div className="card-std space-y-3 p-5">
@@ -265,6 +253,11 @@ export function WorkflowTaskDetailPage() {
const canReturn = allowedActions.has("return");
const canReject = allowedActions.has("reject");
const canTransfer = allowedActions.has("transfer");
const transferDisabledReason = !selectedApprover
? "请先选择目标审批人。"
: transferReason.trim().length < 10
? `请填写转审理由,至少 10 个字(还差 ${10 - transferReason.trim().length} 个字)。`
: "转审理由已满足要求。";
const allowedActionLabels = detail.allowedActions.map(workflowActionLabel).join(" / ");
const businessStatusLabel = workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus);
@@ -420,7 +413,11 @@ export function WorkflowTaskDetailPage() {
当前 ¥{item.amount} / 标准 ¥{item.maxAmount}/{item.perUnit}
</div>
<div>超出 ¥{item.exceededAmount} · {item.standardName}</div>
<div>管控模式:{item.controlMode === "block" ? "阻断" : "预警"}</div>
<div>
{item.controlMode === "block"
? "处理影响:当前单据不应继续通过,请申请人调整金额或补充合规处理依据。"
: "处理影响:可继续审批,但请核对超标原因并在审批意见中记录判断。"}
</div>
</div>
))}
</div>
@@ -510,6 +507,13 @@ export function WorkflowTaskDetailPage() {
)}
testId="workflow-task-action-panel"
>
{canReturn || canReject ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs leading-5 text-zinc-600" data-testid="workflow-action-consequence-help">
<div><span className="font-medium text-zinc-800">退回:</span>原单回到申请人修改,保留单号和历史,修改后可重新提交。</div>
<div><span className="font-medium text-zinc-800">驳回:</span>本次流程终止或进入驳回态,申请人通常需要复制或重新发起。</div>
</div>
) : null}
{actionError ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
@@ -521,8 +525,10 @@ export function WorkflowTaskDetailPage() {
{activeSecondaryAction === "reject" ? (
<div className="detail-action-card space-y-2">
<p className="text-sm font-medium text-zinc-800">驳回意见</p>
<Label className="text-sm font-medium text-zinc-800" htmlFor="workflow-task-reject-comment">驳回意见</Label>
<p className="text-xs leading-5 text-zinc-500">驳回会终止本次流程或进入驳回态,申请人通常需要重新发起。</p>
<Textarea
id="workflow-task-reject-comment"
data-testid="workflow-task-reject-comment"
onChange={(event) => setRejectComment(event.target.value)}
rows={3}
@@ -542,8 +548,10 @@ export function WorkflowTaskDetailPage() {
{activeSecondaryAction === "return" ? (
<div className="detail-action-card space-y-2">
<p className="text-sm font-medium text-zinc-800">退回意见</p>
<Label className="text-sm font-medium text-zinc-800" htmlFor="workflow-task-return-comment">退回意见</Label>
<p className="text-xs leading-5 text-zinc-500">退回后原单可修改并重新提交,单号和审批历史会保留。</p>
<Textarea
id="workflow-task-return-comment"
data-testid="workflow-task-return-comment"
onChange={(event) => setReturnComment(event.target.value)}
rows={3}
@@ -564,7 +572,9 @@ export function WorkflowTaskDetailPage() {
{activeSecondaryAction === "transfer" && canTransfer ? (
<div className="detail-action-card space-y-2">
<p className="text-sm font-medium text-zinc-800">转审</p>
<Label className="text-xs text-zinc-600" htmlFor="workflow-task-transfer-approver">目标审批人</Label>
<select
id="workflow-task-transfer-approver"
className="select-std"
data-testid="workflow-task-transfer-approver"
onChange={(event) => setSelectedApprover(event.target.value)}
@@ -577,13 +587,24 @@ export function WorkflowTaskDetailPage() {
</option>
))}
</select>
<Label className="text-xs text-zinc-600" htmlFor="workflow-task-transfer-reason">转审理由</Label>
<Textarea
id="workflow-task-transfer-reason"
aria-describedby="workflow-task-transfer-help"
data-testid="workflow-task-transfer-reason"
onChange={(event) => setTransferReason(event.target.value)}
rows={3}
value={transferReason}
/>
<p
id="workflow-task-transfer-help"
className={transferReason.trim().length >= 10 && selectedApprover ? "text-xs text-emerald-700" : "text-xs text-amber-700"}
data-testid="workflow-task-transfer-disabled-reason"
>
{transferDisabledReason}
</p>
<Button
aria-describedby="workflow-task-transfer-help"
data-testid="workflow-task-transfer"
variant="secondary"
disabled={
+3 -10
查看文件
@@ -1,7 +1,6 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { Link, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
fetchWorkflowTasks,
fetchWorkflowTaskSummary,
@@ -257,7 +256,7 @@ export function WorkflowTasksPage() {
return (
<ListWorkspace>
<WorkspaceHeader title="待我处理" />
<WorkspaceHeader title="待我审批" />
<FilterBar className="ops-command-bar !p-3 md:!p-3.5">
<div className="ops-command-bar__row">
@@ -339,13 +338,7 @@ export function WorkflowTasksPage() {
{query.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(query.error)
? `${query.error.code}: ${mapApiErrorMessage(
query.error.code,
query.error.details,
query.error.message,
)}`
: "加载待办失败,请稍后重试"}
{resolveUserErrorMessage(query.error, "加载审批任务失败,请稍后重试")}
</p>
) : null}
+4 -9
查看文件
@@ -1,8 +1,7 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchSystemNavigation, fetchWorkspaceSettings, updateWorkspaceSettings } from "../api/workspace";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
@@ -16,7 +15,7 @@ const MODULE_LABELS: Record<string, string> = {
reimbursements: "报销",
assets: "资产",
weeklyReports: "周报",
todos: "待办",
todos: "我的事项",
announcements: "公告",
projects: "项目",
workspace: "工作区设置",
@@ -32,7 +31,7 @@ const MODULE_LABELS: Record<string, string> = {
departments: "部门管理",
masterData: "主数据",
notificationChannels: "通知渠道",
workflowTasks: "待我处理",
workflowTasks: "待我审批",
dashboard: "仪表盘",
purchaseRequests: "采购",
travelRequests: "差旅申请",
@@ -71,11 +70,7 @@ export function WorkspaceSetupPage() {
toast.success("工作区设置已保存");
},
onError: (error) => {
const message = isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
: error instanceof Error
? error.message
: "保存失败";
const message = resolveUserErrorMessage(error, "保存失败");
toast.error(message);
},
});
@@ -1,3 +1,4 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
import { useEffect, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {
@@ -6,7 +7,6 @@ import {
type UserImportApplyResult,
type UserImportDryRunResult,
} from "../../api/users";
import { mapApiErrorMessage } from "../../api/errorMessages";
import { isApiError } from "../../api/types";
import { downloadBlob } from "../../lib/download";
import { Button } from "../../components/ui/button";
@@ -42,10 +42,7 @@ function toCsvCell(value: string) {
}
function formatErrorMessage(error: unknown, fallback: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
return resolveUserErrorMessage(error, fallback);
}
function parsePreviewFromError(error: unknown, sourceFilename: string): UserImportDryRunResult | null {