From 9905c1c79d8725572ba9444f7fb8cb894a909626 Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 21 Mar 2026 18:03:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(P25-2/P25-3/P26-2/P28-1/P24-2/P29-3):=20?= =?UTF-8?q?=E8=90=BD=E5=9C=B0=E5=86=85=E9=83=A8OA=E5=A4=9F=E7=94=A8?= =?UTF-8?q?=E7=89=88=E5=89=8D=E7=AB=AF=E5=85=A5=E5=8F=A3=E4=B8=8E=E8=BF=90?= =?UTF-8?q?=E8=90=A5=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 关联 Issue: P25-2, P25-3, P25-4, P26-2, P26-4, P28-1, P28-2, P28-3, P28-4, P24-2, P29-3 --- client/src/api/announcements.ts | 56 ++++ client/src/api/apOperations.ts | 19 ++ client/src/api/auth.ts | 6 + client/src/api/cutover.ts | 72 +++++ client/src/api/featureFlags.ts | 2 +- client/src/api/feeControl.ts | 34 +++ client/src/api/paymentBatches.ts | 109 +++++++ client/src/api/portal.ts | 15 + client/src/api/purchaseRequests.ts | 11 + client/src/api/reimbursements.ts | 13 +- client/src/api/search.ts | 17 ++ client/src/api/travelRequests.ts | 11 + client/src/api/workflowDefinitions.ts | 45 +++ client/src/auth/permissions.ts | 4 + client/src/components/GlobalSearchDialog.tsx | 78 +++++ .../src/components/common/RiskFlagsCard.tsx | 34 +++ client/src/components/navigation/NavLinks.tsx | 31 ++ client/src/components/navigation/Topbar.tsx | 212 +++++++------- .../travel/TravelReconciliationTab.tsx | 7 +- client/src/layouts/AppShellLayout.tsx | 35 ++- client/src/lib/precheck.ts | 46 +++ client/src/lib/recentVisits.ts | 30 ++ client/src/mobile/indexedDbStore.ts | 42 ++- client/src/pages/ApOperationsPage.tsx | 74 +++++ client/src/pages/ApprovalRulesPage.tsx | 6 + client/src/pages/CutoverConsolePage.tsx | 115 ++++++++ client/src/pages/FeatureFlagsPage.tsx | 10 +- client/src/pages/LoginPage.tsx | 28 +- client/src/pages/MobileApprovalsPage.tsx | 118 +++++--- client/src/pages/PaymentBatchesPage.tsx | 275 ++++++++++++++++++ client/src/pages/PortalAnnouncementsPage.tsx | 84 ++++++ client/src/pages/PortalHomePage.tsx | 133 +++++++++ client/src/pages/PurchaseRequestsPage.tsx | 60 +++- client/src/pages/ReimbursementDetailPage.tsx | 47 ++- client/src/pages/TravelRequestsPage.tsx | 60 +++- client/src/pages/WorkflowDefinitionsPage.tsx | 168 +++++++++++ client/src/router/index.tsx | 79 ++++- client/src/router/lazy/ApOperationsPage.ts | 1 + client/src/router/lazy/CutoverConsolePage.ts | 1 + client/src/router/lazy/PaymentBatchesPage.ts | 1 + .../router/lazy/PortalAnnouncementsPage.ts | 1 + client/src/router/lazy/PortalHomePage.ts | 1 + .../router/lazy/WorkflowDefinitionsPage.ts | 1 + client/src/router/lazy/index.ts | 6 + 44 files changed, 2029 insertions(+), 169 deletions(-) create mode 100644 client/src/api/announcements.ts create mode 100644 client/src/api/apOperations.ts create mode 100644 client/src/api/cutover.ts create mode 100644 client/src/api/feeControl.ts create mode 100644 client/src/api/paymentBatches.ts create mode 100644 client/src/api/portal.ts create mode 100644 client/src/api/search.ts create mode 100644 client/src/api/workflowDefinitions.ts create mode 100644 client/src/components/GlobalSearchDialog.tsx create mode 100644 client/src/components/common/RiskFlagsCard.tsx create mode 100644 client/src/lib/precheck.ts create mode 100644 client/src/lib/recentVisits.ts create mode 100644 client/src/pages/ApOperationsPage.tsx create mode 100644 client/src/pages/CutoverConsolePage.tsx create mode 100644 client/src/pages/PaymentBatchesPage.tsx create mode 100644 client/src/pages/PortalAnnouncementsPage.tsx create mode 100644 client/src/pages/PortalHomePage.tsx create mode 100644 client/src/pages/WorkflowDefinitionsPage.tsx create mode 100644 client/src/router/lazy/ApOperationsPage.ts create mode 100644 client/src/router/lazy/CutoverConsolePage.ts create mode 100644 client/src/router/lazy/PaymentBatchesPage.ts create mode 100644 client/src/router/lazy/PortalAnnouncementsPage.ts create mode 100644 client/src/router/lazy/PortalHomePage.ts create mode 100644 client/src/router/lazy/WorkflowDefinitionsPage.ts diff --git a/client/src/api/announcements.ts b/client/src/api/announcements.ts new file mode 100644 index 00000000..29a341c2 --- /dev/null +++ b/client/src/api/announcements.ts @@ -0,0 +1,56 @@ +import { apiRequestData } from "./http"; + +export type PortalAnnouncement = { + id: string; + title: string; + summary: string; + linkUrl: string | null; + startsAt: string | null; + endsAt: string | null; + enabled: boolean; + priority: number; + createdAt: string | null; + updatedAt: string | null; +}; + +export async function fetchAnnouncements(manage = false) { + const suffix = manage ? "?manage=1" : ""; + return apiRequestData<{ items: PortalAnnouncement[] }>(`/api/announcements${suffix}`); +} + +export async function createAnnouncement(payload: { + title: string; + summary: string; + linkUrl?: string | null; + startsAt?: string | null; + endsAt?: string | null; + enabled?: boolean; + priority?: number; +}) { + return apiRequestData<{ item: PortalAnnouncement }>("/api/announcements", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function updateAnnouncement(id: string, payload: Partial<{ + title: string; + summary: string; + linkUrl: string | null; + startsAt: string | null; + endsAt: string | null; + enabled: boolean; + priority: number; +}>) { + return apiRequestData<{ item: PortalAnnouncement }>(`/api/announcements/${id}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export async function archiveAnnouncement(id: string) { + return apiRequestData<{ item: PortalAnnouncement }>(`/api/announcements/${id}/archive`, { + method: "POST", + body: JSON.stringify({}), + }); +} diff --git a/client/src/api/apOperations.ts b/client/src/api/apOperations.ts new file mode 100644 index 00000000..7e9b9e55 --- /dev/null +++ b/client/src/api/apOperations.ts @@ -0,0 +1,19 @@ +import { apiRequestData } from "./http"; +import type { RiskFlag } from "./feeControl"; + +export type ApOperationsSummary = { + summary: { + openPaymentBatchCount: number; + failedPaymentBatchItemCount: number; + openRiskFlagCount: number; + reconciliationAnomalyCount: number; + }; + openPaymentBatches: Array<{ id: string; batchNo: string; entityType: string; name: string; status: string; createdAt: string | null }>; + failedPaymentBatchItems: Array<{ id: string; paymentBatchId: string; entityId: string; entityCode: string; entityTitle: string; amount: string; currencyCode: string; failureCode: string | null; failureMessage: string | null; createdAt: string | null }>; + openRiskFlags: RiskFlag[]; + reconciliationAnomalies: Array<{ id: string; travelReconciliationBatchId: string; travelOrderId: string | null; travelRequestId: string | null; matchStatus: string; resolutionStatus: string; anomalyCode: string | null; orderAmount: string | null; transactionAmount: string | null; currency: string; createdAt: string | null }>; +}; + +export async function fetchApOperations() { + return apiRequestData("/api/ap-operations"); +} diff --git a/client/src/api/auth.ts b/client/src/api/auth.ts index 9291d227..45fe5e62 100644 --- a/client/src/api/auth.ts +++ b/client/src/api/auth.ts @@ -17,3 +17,9 @@ export async function changePassword(payload: { oldPassword: string; newPassword }); } + +export type SsoProviderInfo = { id: "oidc"; label: string; enabled: boolean; loginUrl: string }; + +export async function fetchSsoProviders() { + return apiRequestData("/api/auth/sso/providers", { auth: false }); +} diff --git a/client/src/api/cutover.ts b/client/src/api/cutover.ts new file mode 100644 index 00000000..295679c1 --- /dev/null +++ b/client/src/api/cutover.ts @@ -0,0 +1,72 @@ +import { apiRequestData } from "./http"; + +export type MigrationRun = { + id: string; + runType: "employees" | "departments" | "budgets" | "masterdata_validation"; + status: string; + sourceFilename: string; + summaryJson: Record | null; + errorJson: Record | null; + createdAt: string | null; + createdBy: string | null; +}; + +export type CutoverScope = { + id: string; + scopeType: "department" | "legal_entity" | "domain"; + scopeKey: string; + enabled: boolean; + startsAt: string | null; + notes: string | null; + createdAt: string | null; + updatedAt: string | null; + updatedBy: string | null; +}; + +export async function fetchMigrationRuns() { + return apiRequestData<{ items: MigrationRun[] }>("/api/migration-runs"); +} + +export async function fetchMigrationRun(id: string) { + return apiRequestData<{ item: MigrationRun }>(`/api/migration-runs/${id}`); +} + +export async function dryRunMigration(payload: { runType: MigrationRun["runType"]; file: File }) { + const formData = new FormData(); + formData.append("runType", payload.runType); + formData.append("file", payload.file); + return apiRequestData<{ item: MigrationRun }>("/api/migration-runs/dry-run", { + method: "POST", + body: formData, + }); +} + +export async function fetchCutoverScopes() { + return apiRequestData<{ items: CutoverScope[] }>("/api/cutover/scopes"); +} + +export async function createCutoverScope(payload: { + scopeType: CutoverScope["scopeType"]; + scopeKey: string; + enabled?: boolean; + startsAt?: string | null; + notes?: string | null; +}) { + return apiRequestData<{ item: CutoverScope }>("/api/cutover/scopes", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function updateCutoverScope(id: string, payload: Partial<{ + scopeType: CutoverScope["scopeType"]; + scopeKey: string; + enabled: boolean; + startsAt: string | null; + notes: string | null; +}>) { + return apiRequestData<{ item: CutoverScope }>(`/api/cutover/scopes/${id}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} diff --git a/client/src/api/featureFlags.ts b/client/src/api/featureFlags.ts index cb91f7ce..129d237c 100644 --- a/client/src/api/featureFlags.ts +++ b/client/src/api/featureFlags.ts @@ -1,6 +1,6 @@ import { apiRequestData } from "./http"; -export type FeatureFlagKey = "erp_sync" | "invoice_verification" | "leave_holiday_calendar"; +export type FeatureFlagKey = "erp_sync" | "invoice_verification" | "leave_holiday_calendar" | "budget_precheck_v1" | "payment_batch_v1" | "portal_home_v1" | "global_search_v1" | "announcements_v1" | "workflow_definition_admin_v1" | "cutover_console_v1"; export type FeatureFlagOverrideMode = "inherit" | "force_on" | "force_off"; export type FeatureFlagRolloutType = "global" | "department_allowlist"; diff --git a/client/src/api/feeControl.ts b/client/src/api/feeControl.ts new file mode 100644 index 00000000..4cc8379d --- /dev/null +++ b/client/src/api/feeControl.ts @@ -0,0 +1,34 @@ +export type RiskFlag = { + id: string; + entityType: string; + entityId: string; + riskCode: string; + level: "low" | "medium" | "high"; + title: string; + detail: string | null; + source: string; + status: "open" | "resolved" | "dismissed"; + payloadJson: Record | null; + createdAt: string | null; + createdBy: string | null; + resolvedAt: string | null; + resolvedBy: string | null; +}; + +export type PrecheckDecision = "pass" | "warn" | "manual_review" | "block"; + +export type PrecheckResponse = { + entityType: "reimbursement" | "purchase_request" | "travel_request"; + entityId: string; + decision: PrecheckDecision; + matchedBudgetControlRule: { + id: string; + name: string; + controlMode: "warn" | "block" | "manual_review"; + } | null; + budgetHint: Record | null; + warnings: Array<{ code: string; title: string; detail: string }>; + blockingReasons: Array<{ code: string; title: string; detail: string }>; + riskFlags: Array<{ code: string; level: "low" | "medium" | "high"; title: string; detail: string; willOpenOnSubmit: boolean }>; + nextActions: string[]; +}; diff --git a/client/src/api/paymentBatches.ts b/client/src/api/paymentBatches.ts new file mode 100644 index 00000000..d452f0e6 --- /dev/null +++ b/client/src/api/paymentBatches.ts @@ -0,0 +1,109 @@ +import { apiRequestData } from "./http"; +import type { RiskFlag } from "./feeControl"; + +export type PaymentBatchEntityType = "reimbursement" | "travel_order"; +export type PaymentBatchStatus = "draft" | "reviewed" | "executed" | "failed" | "cancelled"; +export type PaymentChannel = "bank" | "alipay" | "wechat" | "cash" | "other"; + +export type PaymentBatchItem = { + id: string; + entityId: string; + entityCode: string; + entityTitle: string; + amount: string; + currencyCode: string; + paymentVoucherNoOverride: string | null; + paymentDateOverride: string | null; + paymentChannelOverride: PaymentChannel | null; + status: string; + failureCode: string | null; + failureMessage: string | null; + snapshotJson: Record; + createdAt: string | null; + openRiskFlags: RiskFlag[]; +}; + +export type PaymentBatch = { + id: string; + batchNo: string; + entityType: PaymentBatchEntityType; + name: string; + status: PaymentBatchStatus; + defaultPaymentDate: string | null; + defaultPaymentChannel: PaymentChannel | null; + voucherPrefix: string | null; + reviewComment: string | null; + executionSummaryJson: Record | null; + createdAt: string | null; + createdBy: string | null; + reviewedAt: string | null; + reviewedBy: string | null; + executedAt: string | null; + executedBy: string | null; + cancelledAt: string | null; + cancelledBy: string | null; + items: PaymentBatchItem[]; +}; + +export type PaymentBatchListResponse = { items: PaymentBatch[] }; + +export async function fetchPaymentBatches(query: { entityType?: PaymentBatchEntityType; status?: PaymentBatchStatus } = {}) { + const params = new URLSearchParams(); + if (query.entityType) params.set("entityType", query.entityType); + if (query.status) params.set("status", query.status); + const suffix = params.toString() ? `?${params.toString()}` : ""; + return apiRequestData(`/api/payment-batches${suffix}`); +} + +export async function fetchPaymentBatchDetail(batchId: string) { + return apiRequestData<{ item: PaymentBatch }>(`/api/payment-batches/${batchId}`); +} + +export async function createPaymentBatch(payload: { + entityType: PaymentBatchEntityType; + name: string; + entityIds: string[]; + defaultPaymentDate?: string; + defaultPaymentChannel?: PaymentChannel; + voucherPrefix?: string; +}) { + return apiRequestData<{ item: PaymentBatch }>("/api/payment-batches", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function updatePaymentBatchItem(batchId: string, itemId: string, payload: { + paymentVoucherNoOverride?: string | null; + paymentDateOverride?: string | null; + paymentChannelOverride?: PaymentChannel | null; +}) { + return apiRequestData<{ item: Pick }>( + `/api/payment-batches/${batchId}/items/${itemId}`, + { + method: "PATCH", + body: JSON.stringify(payload), + }, + ); +} + +export async function reviewPaymentBatch(batchId: string, payload: { comment?: string; resolveRiskFlagIds?: string[] } = {}) { + return apiRequestData<{ item: PaymentBatch }>(`/api/payment-batches/${batchId}/review`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function executePaymentBatch(batchId: string) { + return apiRequestData<{ item: PaymentBatch }>(`/api/payment-batches/${batchId}/execute`, { + method: "POST", + body: JSON.stringify({}), + }); +} + +export async function cancelPaymentBatch(batchId: string) { + return apiRequestData<{ item: PaymentBatch }>(`/api/payment-batches/${batchId}/cancel`, { + method: "POST", + body: JSON.stringify({}), + }); +} diff --git a/client/src/api/portal.ts b/client/src/api/portal.ts new file mode 100644 index 00000000..c8b79422 --- /dev/null +++ b/client/src/api/portal.ts @@ -0,0 +1,15 @@ +import { apiRequestData } from "./http"; + +export type PortalHomePayload = { + greeting: { name: string }; + cards: Array<{ key: string; title: string; value: string | number; href: string; severity?: "normal" | "warning" | "high" }>; + pendingTasks: { count: number; href: string }; + myApplications: Array<{ domain: string; count: number; href: string }>; + riskSummary: { openCount: number; href: string } | null; + announcements: Array<{ id: string; title: string; summary: string; href: string | null }>; + quickLinks: Array<{ key: string; title: string; href: string }>; +}; + +export async function fetchPortalHome() { + return apiRequestData("/api/portal/home"); +} diff --git a/client/src/api/purchaseRequests.ts b/client/src/api/purchaseRequests.ts index 9620b201..6e62fea2 100644 --- a/client/src/api/purchaseRequests.ts +++ b/client/src/api/purchaseRequests.ts @@ -1,4 +1,5 @@ import { apiRequestData } from "./http"; +import type { PrecheckResponse, RiskFlag } from "./feeControl"; export type PurchaseRequestStatus = "draft" | "reviewing" | "approved" | "rejected" | "cancelled"; @@ -205,6 +206,7 @@ export type PurchaseRequestDetail = PurchaseRequestSummary & { canWithdraw: boolean; canArchiveOrder: boolean; }; + riskFlags: RiskFlag[]; }; export type PurchaseRequestListResponse = { @@ -283,6 +285,13 @@ export async function updatePurchaseRequest( }); } +export async function precheckPurchaseRequest(purchaseRequestId: string) { + return apiRequestData(`/api/purchase-requests/${purchaseRequestId}/precheck`, { + method: "POST", + body: JSON.stringify({}), + }); +} + export async function submitPurchaseRequest(purchaseRequestId: string) { return apiRequestData<{ purchaseRequestId: string; @@ -296,6 +305,8 @@ export async function submitPurchaseRequest(purchaseRequestId: string) { applied: boolean; reservedAmount: string; }; + precheckDecision: "pass" | "warn" | "manual_review"; + openedRiskFlagCount: number; }>(`/api/purchase-requests/${purchaseRequestId}/submit`, { method: "POST", body: JSON.stringify({}), diff --git a/client/src/api/reimbursements.ts b/client/src/api/reimbursements.ts index 211a937a..0c350962 100644 --- a/client/src/api/reimbursements.ts +++ b/client/src/api/reimbursements.ts @@ -1,4 +1,5 @@ import { apiRequestData } from "./http"; +import type { PrecheckResponse, RiskFlag } from "./feeControl"; export type ReimbursementStatus = | "draft" @@ -113,6 +114,7 @@ export type ReimbursementDetail = { assignee_role: string | null; } | null; } | null; + riskFlags: RiskFlag[]; items: ReimbursementItem[]; }; @@ -161,11 +163,13 @@ export type ReimbursementSubmitResponse = { budget: { budgetId: string; budgetCode: string; - controlMode: "warn" | "block"; + controlMode: "warn" | "block" | "manual_review"; usagePercent: number; exceededThreshold: boolean; exceededBudget: boolean; } | null; + precheckDecision: "pass" | "warn" | "manual_review"; + openedRiskFlagCount: number; }; function toQueryString(query: Record) { @@ -203,6 +207,13 @@ export async function createReimbursement(payload: { }); } +export async function precheckReimbursement(reimbursementId: string) { + return apiRequestData(`/api/reimbursements/${reimbursementId}/precheck`, { + method: "POST", + body: JSON.stringify({}), + }); +} + export async function submitReimbursement(reimbursementId: string) { return apiRequestData(`/api/reimbursements/${reimbursementId}/submit`, { method: "POST", diff --git a/client/src/api/search.ts b/client/src/api/search.ts new file mode 100644 index 00000000..b00db011 --- /dev/null +++ b/client/src/api/search.ts @@ -0,0 +1,17 @@ +import { apiRequestData } from "./http"; + +export type GlobalSearchHit = { + entityType: "reimbursement" | "leave" | "purchase_request" | "travel_request" | "asset"; + entityId: string; + title: string; + subtitle: string | null; + status: string | null; + href: string; + matchedBy: "exact" | "prefix" | "contains"; + updatedAt: string | null; +}; + +export async function searchGlobal(q: string, limit = 10) { + const params = new URLSearchParams({ q, limit: String(limit) }); + return apiRequestData<{ items: GlobalSearchHit[] }>(`/api/search?${params.toString()}`); +} diff --git a/client/src/api/travelRequests.ts b/client/src/api/travelRequests.ts index db4d895a..2a5765f4 100644 --- a/client/src/api/travelRequests.ts +++ b/client/src/api/travelRequests.ts @@ -1,4 +1,5 @@ import { apiRequestData } from "./http"; +import type { PrecheckResponse, RiskFlag } from "./feeControl"; export type TravelRequestStatus = "draft" | "reviewing" | "approved" | "rejected"; export type TravelTripType = "domestic" | "international"; @@ -108,6 +109,7 @@ export type TravelRequestDetail = TravelRequestSummary & { canWithdraw: boolean; canManage: boolean; }; + riskFlags: RiskFlag[]; }; export type TravelRequestListResponse = { @@ -160,6 +162,8 @@ export type TravelRequestSubmitResponse = { reservedAmount?: string; items?: Array<{ budgetPoolId: string; amount: string }>; }; + precheckDecision: "pass" | "warn" | "manual_review"; + openedRiskFlagCount: number; }; export type TravelRequestWithdrawResponse = { @@ -209,6 +213,13 @@ export async function updateTravelRequest(travelRequestId: string, payload: Part }); } +export async function precheckTravelRequest(travelRequestId: string) { + return apiRequestData(`/api/travel-requests/${travelRequestId}/precheck`, { + method: "POST", + body: JSON.stringify({}), + }); +} + export async function submitTravelRequest(travelRequestId: string) { return apiRequestData(`/api/travel-requests/${travelRequestId}/submit`, { method: "POST", diff --git a/client/src/api/workflowDefinitions.ts b/client/src/api/workflowDefinitions.ts new file mode 100644 index 00000000..e6823de6 --- /dev/null +++ b/client/src/api/workflowDefinitions.ts @@ -0,0 +1,45 @@ +import { apiRequestData } from "./http"; + +export type WorkflowDefinitionEditorModel = { + id: string; + entityType: "reimbursement" | "leave" | "purchase_request" | "travel_request"; + name: string; + version: number; + status: "draft" | "published" | "archived" | "derived_readonly"; + steps: Array<{ + id: string; + name: string; + assigneeRule: + | { type: "department_manager" } + | { type: "role"; value: string } + | { type: "specific_user"; value: string }; + }>; + notes?: string; + source: "manual" | "approval_rules_projection"; + createdAt: string | null; + updatedAt: string | null; + publishedAt: string | null; + archivedAt: string | null; +}; + +export async function fetchWorkflowDefinitions() { + return apiRequestData<{ items: WorkflowDefinitionEditorModel[] }>("/api/workflow-definitions"); +} +export async function fetchWorkflowDefinition(id: string) { + return apiRequestData<{ item: WorkflowDefinitionEditorModel }>(`/api/workflow-definitions/${id}`); +} +export async function fetchActiveWorkflowDefinition(entityType: WorkflowDefinitionEditorModel["entityType"]) { + return apiRequestData<{ item: WorkflowDefinitionEditorModel | null }>(`/api/workflow-definitions/entity/${entityType}/active`); +} +export async function createWorkflowDefinition(payload: { entityType: WorkflowDefinitionEditorModel["entityType"]; name: string; steps: WorkflowDefinitionEditorModel["steps"]; notes?: string }) { + return apiRequestData<{ item: WorkflowDefinitionEditorModel }>("/api/workflow-definitions", { method: "POST", body: JSON.stringify(payload) }); +} +export async function updateWorkflowDefinition(id: string, payload: Partial<{ name: string; steps: WorkflowDefinitionEditorModel["steps"]; notes?: string }>) { + return apiRequestData<{ item: WorkflowDefinitionEditorModel }>(`/api/workflow-definitions/${id}`, { method: "PATCH", body: JSON.stringify(payload) }); +} +export async function publishWorkflowDefinition(id: string) { + return apiRequestData<{ item: WorkflowDefinitionEditorModel }>(`/api/workflow-definitions/${id}/publish`, { method: "POST", body: JSON.stringify({}) }); +} +export async function archiveWorkflowDefinition(id: string) { + return apiRequestData<{ item: WorkflowDefinitionEditorModel }>(`/api/workflow-definitions/${id}/archive`, { method: "POST", body: JSON.stringify({}) }); +} diff --git a/client/src/auth/permissions.ts b/client/src/auth/permissions.ts index 7b1b5670..58fdb17d 100644 --- a/client/src/auth/permissions.ts +++ b/client/src/auth/permissions.ts @@ -87,6 +87,7 @@ const ROLE_PERMISSIONS: Record = { "budget.view", "expense.approve", "expense.finance.pay", + "payment.batch.manage", "expense.view_all", "procurement.view_all", "travel.view_all", @@ -106,6 +107,7 @@ const ROLE_PERMISSIONS: Record = { "ai.assist", "expense.approve", "expense.finance.pay", + "payment.batch.manage", "expense.view_all", "expense.finance.override_duplicate", "procurement.view_all", @@ -118,6 +120,8 @@ const ROLE_PERMISSIONS: Record = { "asset.inventory", "system.settings", "system.audit.view", + "payment.batch.manage", + "risk.review.manage", ], administration: [ "expense.proxy_submit", diff --git a/client/src/components/GlobalSearchDialog.tsx b/client/src/components/GlobalSearchDialog.tsx new file mode 100644 index 00000000..8d3e67f0 --- /dev/null +++ b/client/src/components/GlobalSearchDialog.tsx @@ -0,0 +1,78 @@ +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { searchGlobal } from "../api/search"; +import { isApiError } from "../api/types"; +import { mapApiErrorMessage } from "../api/errorMessages"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"; + +export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { + const navigate = useNavigate(); + const [keyword, setKeyword] = useState(""); + const trimmed = keyword.trim(); + const searchQuery = useQuery({ + enabled: open && trimmed.length >= 2, + queryKey: ["global-search", trimmed], + queryFn: () => searchGlobal(trimmed, 10), + }); + + const message = useMemo(() => { + if (!trimmed) return "输入编号、标题或关键字开始搜索。"; + if (trimmed.length < 2) return "至少输入 2 个字符。"; + if (searchQuery.isError) { + return isApiError(searchQuery.error) + ? `${searchQuery.error.code}: ${mapApiErrorMessage(searchQuery.error.code, searchQuery.error.details, searchQuery.error.message)}` + : "搜索失败,请稍后重试"; + } + return null; + }, [trimmed, searchQuery.error, searchQuery.isError]); + + return ( + + + + 全局搜索 + +
+ setKeyword(event.target.value)} + /> + + {message ?
{message}
: null} + +
+ {(searchQuery.data?.items ?? []).map((item) => ( + + ))} +
+ +
+ +
+
+
+
+ ); +} diff --git a/client/src/components/common/RiskFlagsCard.tsx b/client/src/components/common/RiskFlagsCard.tsx new file mode 100644 index 00000000..aae11602 --- /dev/null +++ b/client/src/components/common/RiskFlagsCard.tsx @@ -0,0 +1,34 @@ +import type { RiskFlag } from "../../api/feeControl"; + +export function RiskFlagsCard({ items, title = "风险项" }: { items: RiskFlag[]; title?: string }) { + if (!items.length) { + return null; + } + + return ( +
+

{title}

+
+ {items.map((item) => ( +
+
+
{item.title}
+ {item.level} +
+ {item.detail ?
{item.detail}
: null} +
来源:{item.source} · 状态:{item.status}
+
+ ))} +
+
+ ); +} diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index 25dc4796..847f34f9 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -11,6 +11,7 @@ export type NavItem = { }; export const NAV_ITEMS: NavItem[] = [ + { to: "/portal", label: "Portal", icon: "home_app_logo" }, { to: "/dashboard", label: "仪表盘", icon: "dashboard" }, { to: "/reimbursements", @@ -42,6 +43,18 @@ export const NAV_ITEMS: NavItem[] = [ icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"], }, + { + to: "/payment-batches", + label: "支付批次", + icon: "payments", + anyOf: ["payment.batch.manage", "risk.review.manage"], + }, + { + to: "/ap-operations", + label: "AP 运营", + icon: "account_balance_wallet", + anyOf: ["payment.batch.manage", "risk.review.manage"], + }, { to: "/assets", label: "资产台账", @@ -132,6 +145,24 @@ export const NAV_ITEMS: NavItem[] = [ icon: "rule", anyOf: ["system.settings"], }, + { + to: "/system/workflow-definitions", + label: "流程定义", + icon: "schema", + anyOf: ["system.settings"], + }, + { + to: "/system/announcements", + label: "公告管理", + icon: "campaign", + anyOf: ["system.settings"], + }, + { + to: "/system/cutover", + label: "切换控制台", + icon: "swap_calls", + anyOf: ["system.settings"], + }, { to: "/system/feature-flags", label: "功能开关", diff --git a/client/src/components/navigation/Topbar.tsx b/client/src/components/navigation/Topbar.tsx index 56ebc318..e252d4ed 100644 --- a/client/src/components/navigation/Topbar.tsx +++ b/client/src/components/navigation/Topbar.tsx @@ -3,13 +3,9 @@ import { Link } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { fetchNotifications } from "../../api/notifications"; import { useAuth } from "../../auth/AuthContext"; +import { GlobalSearchDialog } from "../GlobalSearchDialog"; import { Button } from "../ui/button"; -import { - Dialog, - DialogClose, - DialogContent, - DialogTrigger, -} from "../ui/dialog"; +import { Dialog, DialogClose, DialogContent, DialogTrigger } from "../ui/dialog"; import { DropdownMenu, DropdownMenuContent, @@ -29,12 +25,7 @@ function MenuIcon() { width="20" xmlns="http://www.w3.org/2000/svg" > - + ); } @@ -64,6 +55,7 @@ export function Topbar() { const { me, logout } = useAuth(); const roleCodes = me?.roleCodes ?? []; const [mobileNavOpen, setMobileNavOpen] = useState(false); + const [searchOpen, setSearchOpen] = useState(false); const unreadQuery = useQuery({ enabled: Boolean(me), @@ -76,100 +68,122 @@ export function Topbar() { const unreadBadge = unreadCount > 99 ? "99+" : String(unreadCount); return ( -
-
-
- - - - + <> +
+
+
+ + + + - -
-
-
-

- {me?.roleCodes?.[0] || 'User'} -

-

海音达工作台

+ +
+
+
+

+ {me?.roleCodes?.[0] || "User"} +

+

海音达工作台

+
+ + + +
+ +
+ setMobileNavOpen(false)} />
- - -
+
+
-
- setMobileNavOpen(false)} /> -
-
- -
- -
-

欢迎回来

-

{me?.name ?? "-"}

-
-
- -
- - -
-

角色

-

{roleCodes.join(" / ") || "-"}

+
+

欢迎回来

+

{me?.name ?? "-"}

+
- - - - +
+ - - - 个人设置 - - - { - event.preventDefault(); - void logout(); - }} - > - 退出登录 - - - + + + + +
+

角色

+

{roleCodes.join(" / ") || "-"}

+
+ + + + + + + + + 个人设置 + + + { + event.preventDefault(); + void logout(); + }} + > + 退出登录 + + + +
-
-
+ + + + ); } diff --git a/client/src/components/travel/TravelReconciliationTab.tsx b/client/src/components/travel/TravelReconciliationTab.tsx index 7cbd681c..4cbb45a6 100644 --- a/client/src/components/travel/TravelReconciliationTab.tsx +++ b/client/src/components/travel/TravelReconciliationTab.tsx @@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { mapApiErrorMessage } from '../../api/errorMessages'; +import { useAuth } from '../../auth/AuthContext'; +import { hasPermission } from '../../auth/permissions'; import { fetchCostCenters, fetchLegalEntities } from '../../api/masterData'; import { confirmTravelReconciliationItem, @@ -58,6 +60,9 @@ type TravelReconciliationTabProps = { export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: TravelReconciliationTabProps) { const queryClient = useQueryClient(); + const { permissions } = useAuth(); + const canCreateBatch = + hasPermission(permissions, 'travel.payment.manage') || hasPermission(permissions, 'integration.manage'); const [status, setStatus] = useState(''); const [legalEntityFilter, setLegalEntityFilter] = useState(''); const [costCenterFilter, setCostCenterFilter] = useState(''); @@ -257,7 +262,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav return (
- {detail?.permissions.canManagePayments ? ( + {canCreateBatch ? (

新建对账批次

diff --git a/client/src/layouts/AppShellLayout.tsx b/client/src/layouts/AppShellLayout.tsx index 9b7e0667..321a8a96 100644 --- a/client/src/layouts/AppShellLayout.tsx +++ b/client/src/layouts/AppShellLayout.tsx @@ -1,9 +1,42 @@ -import { Outlet } from "react-router-dom"; +import { useEffect } from "react"; +import { Outlet, useLocation } from "react-router-dom"; +import { NAV_ITEMS } from "../components/navigation/NavLinks"; import { ErrorBoundary } from "../components/common/ErrorBoundary"; import { Sidebar } from "../components/navigation/Sidebar"; import { Topbar } from "../components/navigation/Topbar"; +import { writeRecentVisit } from "../lib/recentVisits"; + +const EXTRA_TITLE_ENTRIES = [ + { path: "/notifications", title: "通知中心" }, + { path: "/reimbursements/", title: "报销详情" }, + { path: "/workflow/tasks/", title: "待办详情" }, +]; + +function resolveVisitTitle(pathname: string) { + const candidates = [ + ...NAV_ITEMS.map((item) => ({ path: item.to, title: item.label })), + ...EXTRA_TITLE_ENTRIES, + ].sort((left, right) => right.path.length - left.path.length); + + const matched = candidates.find((item) => pathname === item.path || pathname.startsWith(item.path)); + if (matched) { + return matched.title; + } + + return pathname === "/" ? "Portal" : pathname; +} export function AppShellLayout() { + const location = useLocation(); + + useEffect(() => { + const pathname = location.pathname; + if (!pathname || pathname === "/login" || pathname.startsWith("/mobile/")) { + return; + } + writeRecentVisit(pathname, resolveVisitTitle(pathname)); + }, [location.pathname]); + return (
diff --git a/client/src/lib/precheck.ts b/client/src/lib/precheck.ts new file mode 100644 index 00000000..12cd708f --- /dev/null +++ b/client/src/lib/precheck.ts @@ -0,0 +1,46 @@ +import type { PrecheckDecision, PrecheckResponse } from "../api/feeControl"; + +function decisionLabel(decision: PrecheckDecision) { + if (decision === "pass") return "通过"; + if (decision === "warn") return "预警"; + if (decision === "manual_review") return "需人工复核"; + return "阻断"; +} + +export function buildPrecheckDialogMessage(precheck: PrecheckResponse) { + const lines = [`提交前校验结果:${decisionLabel(precheck.decision)}`]; + + if (precheck.matchedBudgetControlRule) { + lines.push(`命中规则:${precheck.matchedBudgetControlRule.name}(${precheck.matchedBudgetControlRule.controlMode})`); + } + + if (precheck.blockingReasons.length) { + lines.push("", "阻断原因:"); + precheck.blockingReasons.forEach((item, index) => { + lines.push(`${index + 1}. ${item.title}${item.detail ? `:${item.detail}` : ""}`); + }); + } + + if (precheck.warnings.length) { + lines.push("", "预警信息:"); + precheck.warnings.forEach((item, index) => { + lines.push(`${index + 1}. ${item.title}${item.detail ? `:${item.detail}` : ""}`); + }); + } + + if (precheck.riskFlags.length) { + lines.push("", "风险项:"); + precheck.riskFlags.forEach((item, index) => { + lines.push(`${index + 1}. [${item.level}] ${item.title}${item.detail ? `:${item.detail}` : ""}`); + }); + } + + if (precheck.nextActions.length) { + lines.push("", "下一步建议:"); + precheck.nextActions.forEach((item, index) => { + lines.push(`${index + 1}. ${item}`); + }); + } + + return lines.join("\n"); +} diff --git a/client/src/lib/recentVisits.ts b/client/src/lib/recentVisits.ts new file mode 100644 index 00000000..30e8482d --- /dev/null +++ b/client/src/lib/recentVisits.ts @@ -0,0 +1,30 @@ +export const RECENT_VISITS_STORAGE_KEY = "hyb_oa_recent_visits"; + +export type RecentVisit = { + path: string; + title: string; + visitedAt: string; +}; + +function isWindowAvailable() { + return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; +} + +export function readRecentVisits(): RecentVisit[] { + if (!isWindowAvailable()) return []; + try { + const raw = window.localStorage.getItem(RECENT_VISITS_STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((item) => item && typeof item.path === "string") : []; + } catch { + return []; + } +} + +export function writeRecentVisit(path: string, title: string) { + if (!isWindowAvailable()) return; + const current = readRecentVisits().filter((item) => item.path !== path); + current.unshift({ path, title, visitedAt: new Date().toISOString() }); + window.localStorage.setItem(RECENT_VISITS_STORAGE_KEY, JSON.stringify(current.slice(0, 10))); +} diff --git a/client/src/mobile/indexedDbStore.ts b/client/src/mobile/indexedDbStore.ts index c70881d8..8804f170 100644 --- a/client/src/mobile/indexedDbStore.ts +++ b/client/src/mobile/indexedDbStore.ts @@ -42,13 +42,47 @@ export function createIndexedDbStore(options: IndexedDbStoreOptions) { const tx = db.transaction(storeName, mode); const store = tx.objectStore(storeName); + let executorSettled = false; + let transactionCompleted = false; + let settled = false; + let executorResult!: T; + + const resolveIfReady = () => { + if (!settled && executorSettled && transactionCompleted) { + settled = true; + resolve(executorResult); + } + }; + + const rejectOnce = (error: unknown) => { + if (settled) { + return; + } + settled = true; + reject(error instanceof Error ? error : new Error(String(error))); + }; + + tx.oncomplete = () => { + transactionCompleted = true; + resolveIfReady(); + }; + tx.onerror = () => rejectOnce(tx.error ?? new Error("indexeddb_tx_failed")); + tx.onabort = () => rejectOnce(tx.error ?? new Error("indexeddb_tx_aborted")); + Promise.resolve(executor(store)) .then((result) => { - tx.oncomplete = () => resolve(result); - tx.onerror = () => reject(tx.error ?? new Error("indexeddb_tx_failed")); - tx.onabort = () => reject(tx.error ?? new Error("indexeddb_tx_aborted")); + executorResult = result; + executorSettled = true; + resolveIfReady(); }) - .catch((error) => reject(error)); + .catch((error) => { + try { + tx.abort(); + } catch { + // noop + } + rejectOnce(error); + }); }); } diff --git a/client/src/pages/ApOperationsPage.tsx b/client/src/pages/ApOperationsPage.tsx new file mode 100644 index 00000000..2869932d --- /dev/null +++ b/client/src/pages/ApOperationsPage.tsx @@ -0,0 +1,74 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchApOperations } from "../api/apOperations"; +import { PageHeader } from "../components/common/PageHeader"; +import { RiskFlagsCard } from "../components/common/RiskFlagsCard"; + +export function ApOperationsPage() { + const summaryQuery = useQuery({ + queryKey: ["ap-operations"], + queryFn: () => fetchApOperations(), + }); + + if (summaryQuery.isLoading) { + return
加载 AP 运营视图...
; + } + + if (summaryQuery.isError || !summaryQuery.data) { + return
AP 运营视图加载失败。
; + } + + const data = summaryQuery.data; + return ( +
+ + +
+
开放批次
{data.summary.openPaymentBatchCount}
+
失败批次项
{data.summary.failedPaymentBatchItemCount}
+
开放风险
{data.summary.openRiskFlagCount}
+
对账异常
{data.summary.reconciliationAnomalyCount}
+
+ +
+
+

开放批次

+
+ {data.openPaymentBatches.length ? data.openPaymentBatches.map((item) => ( +
+
{item.batchNo}
+
{item.name}
+
{item.entityType} · {item.status}
+
+ )) :
暂无开放批次
} +
+
+ +
+

失败批次项

+
+ {data.failedPaymentBatchItems.length ? data.failedPaymentBatchItems.map((item) => ( +
+
{item.entityCode}
+
{item.failureMessage ?? item.failureCode ?? "执行失败"}
+
+ )) :
暂无失败项
} +
+
+
+ + + +
+

差旅对账异常

+
+ {data.reconciliationAnomalies.length ? data.reconciliationAnomalies.map((item) => ( +
+
{item.anomalyCode ?? item.matchStatus}
+
订单 {item.orderAmount ?? "-"} / 交易 {item.transactionAmount ?? "-"} {item.currency}
+
+ )) :
暂无异常
} +
+
+
+ ); +} diff --git a/client/src/pages/ApprovalRulesPage.tsx b/client/src/pages/ApprovalRulesPage.tsx index 538a7215..b7bcb402 100644 --- a/client/src/pages/ApprovalRulesPage.tsx +++ b/client/src/pages/ApprovalRulesPage.tsx @@ -1,4 +1,5 @@ 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"; @@ -147,6 +148,11 @@ export function ApprovalRulesPage() { + 查看流程定义 + + } /> {error ? ( diff --git a/client/src/pages/CutoverConsolePage.tsx b/client/src/pages/CutoverConsolePage.tsx new file mode 100644 index 00000000..04f6b90c --- /dev/null +++ b/client/src/pages/CutoverConsolePage.tsx @@ -0,0 +1,115 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createCutoverScope, dryRunMigration, fetchCutoverScopes, fetchMigrationRuns, updateCutoverScope } from "../api/cutover"; +import { PageHeader } from "../components/common/PageHeader"; +import { Button } from "../components/ui/button"; +import { Input } from "../components/ui/input"; +import { Label } from "../components/ui/label"; +import { toast } from "sonner"; + +export function CutoverConsolePage() { + const queryClient = useQueryClient(); + const [scopeType, setScopeType] = useState<"department" | "legal_entity" | "domain">("department"); + const [scopeKey, setScopeKey] = useState(""); + const [notes, setNotes] = useState(""); + const [dryRunType, setDryRunType] = useState<"employees" | "departments" | "budgets" | "masterdata_validation">("employees"); + const [dryRunFile, setDryRunFile] = useState(null); + + const scopesQuery = useQuery({ queryKey: ["cutover-scopes"], queryFn: () => fetchCutoverScopes() }); + const runsQuery = useQuery({ queryKey: ["migration-runs"], queryFn: () => fetchMigrationRuns() }); + const refresh = async () => { + await queryClient.invalidateQueries({ queryKey: ["cutover-scopes"] }); + await queryClient.invalidateQueries({ queryKey: ["migration-runs"] }); + }; + + const createScopeMutation = useMutation({ + mutationFn: () => createCutoverScope({ scopeType, scopeKey, notes, enabled: false }), + onSuccess: async () => { + setScopeKey(""); + setNotes(""); + await refresh(); + toast.success("切换范围已创建"); + }, + }); + + const toggleScopeMutation = useMutation({ + mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => updateCutoverScope(id, { enabled }), + onSuccess: async () => { + await refresh(); + toast.success("切换范围已更新"); + }, + }); + + const dryRunMutation = useMutation({ + mutationFn: async () => { + if (!dryRunFile) throw new Error("请先选择 CSV 文件"); + return dryRunMigration({ runType: dryRunType, file: dryRunFile }); + }, + onSuccess: async () => { + setDryRunFile(null); + await refresh(); + toast.success("dry-run 已完成"); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "dry-run 失败"); + }, + }); + + return ( +
+ +
+
+
+

新增切换范围

+
+
setScopeKey(event.target.value)} placeholder="部门 ID / 法体 ID / expense" />
+
setNotes(event.target.value)} />
+ +
+ +
+

上传 dry-run CSV

+
+ setDryRunFile(event.target.files?.[0] ?? null)} /> + +
+
+ +
+
+

切换范围

+
+ {(scopesQuery.data?.items ?? []).map((item) => ( +
+
+
+
{item.scopeType} · {item.scopeKey}
+
{item.notes ?? "-"}
+
{item.enabled ? "enabled" : "disabled"} · startsAt={item.startsAt ?? "-"}
+
+ +
+
+ ))} +
+
+ +
+

最近 dry-run

+
+ {(runsQuery.data?.items ?? []).map((item) => ( +
+
{item.runType} · {item.status}
+
{item.sourceFilename}
+
{item.createdAt ?? "-"}
+
+ ))} + {!runsQuery.data?.items?.length ?
暂无 dry-run 记录
: null} +
+
+
+
+
+ ); +} diff --git a/client/src/pages/FeatureFlagsPage.tsx b/client/src/pages/FeatureFlagsPage.tsx index 5f18eee9..50457aaa 100644 --- a/client/src/pages/FeatureFlagsPage.tsx +++ b/client/src/pages/FeatureFlagsPage.tsx @@ -5,6 +5,7 @@ import { fetchFeatureFlags, updateFeatureFlag, type FeatureFlagItem, + type FeatureFlagKey, type FeatureFlagOverrideMode, type FeatureFlagRolloutType, } from "../api/featureFlags"; @@ -32,6 +33,13 @@ function toDisplayName(flagKey: string) { if (flagKey === "erp_sync") return "ERP 同步"; if (flagKey === "invoice_verification") return "发票验真"; if (flagKey === "leave_holiday_calendar") return "请假日历增强"; + if (flagKey === "budget_precheck_v1") return "预算预校验"; + if (flagKey === "payment_batch_v1") return "支付批次"; + if (flagKey === "portal_home_v1") return "Portal 首页"; + if (flagKey === "global_search_v1") return "全局搜索"; + if (flagKey === "announcements_v1") return "公告中心"; + if (flagKey === "workflow_definition_admin_v1") return "流程定义治理"; + if (flagKey === "cutover_console_v1") return "切换控制台"; return flagKey; } @@ -79,7 +87,7 @@ export function FeatureFlagsPage() { throw new Error("未找到待提交草稿"); } - return updateFeatureFlag(flagKey as "erp_sync" | "invoice_verification" | "leave_holiday_calendar", { + return updateFeatureFlag(flagKey as FeatureFlagKey, { overrideMode: draft.overrideMode, rolloutType: draft.rolloutType, departmentIds: diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 49f611f8..205f359d 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -1,5 +1,7 @@ import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useNavigate, useSearchParams } from "react-router-dom"; +import { fetchSsoProviders } from "../api/auth"; import { mapApiErrorMessage } from "../api/errorMessages"; import { isApiError } from "../api/types"; import { useAuth } from "../auth/AuthContext"; @@ -10,11 +12,11 @@ import { Label } from "../components/ui/label"; function resolveRedirect(searchParams: URLSearchParams) { const redirect = searchParams.get("redirect"); if (!redirect) { - return "/dashboard"; + return "/portal"; } if (!redirect.startsWith("/")) { - return "/dashboard"; + return "/portal"; } return redirect; @@ -31,6 +33,12 @@ export function LoginPage() { const [error, setError] = useState(""); const redirectTo = resolveRedirect(searchParams); + const ssoProvidersQuery = useQuery({ + queryKey: ["auth", "sso-providers"], + queryFn: () => fetchSsoProviders(), + staleTime: 60_000, + }); + const oidcProvider = (ssoProvidersQuery.data ?? []).find((item) => item.id === "oidc" && item.enabled); async function handleSubmit(event: React.FormEvent) { event.preventDefault(); @@ -105,6 +113,22 @@ export function LoginPage() { {submitting ? "登录中..." : "登录"} + + {oidcProvider ? ( +
+

企业统一入口

+ +
+ ) : null}
); } diff --git a/client/src/pages/MobileApprovalsPage.tsx b/client/src/pages/MobileApprovalsPage.tsx index ea0350f7..5487f5c0 100644 --- a/client/src/pages/MobileApprovalsPage.tsx +++ b/client/src/pages/MobileApprovalsPage.tsx @@ -28,6 +28,7 @@ import { removeWorkflowActionDraft, resolveWorkflowTaskReferenceSnapshot, updateWorkflowActionDraftStatus, + type WorkflowDraftAction, type WorkflowDraftQueueItem, } from "../mobile/workflowActionQueue"; @@ -107,6 +108,7 @@ export function MobileApprovalsPage() { const [comment, setComment] = useState(""); const [transferToApproverId, setTransferToApproverId] = useState(""); const [transferReason, setTransferReason] = useState(""); + const [offlineDraftSubmitting, setOfflineDraftSubmitting] = useState(false); const [isOnline, setIsOnline] = useState(typeof navigator === "undefined" ? true : navigator.onLine); const autoSyncRequestedRef = useRef(false); @@ -192,14 +194,63 @@ export function MobileApprovalsPage() { queryFn: () => fetchWorkflowApprovers({ page: 1, pageSize: 100 }), }); + async function refreshDraftQueueCache() { + if (!ownerEmployeeId) { + return []; + } + const drafts = await listWorkflowActionDrafts(ownerEmployeeId); + queryClient.setQueryData(["mobile", "workflow", "draft-queue", ownerEmployeeId], drafts); + return drafts; + } + const currentQueueItems = queueQuery.data ?? []; const retryableQueueItems = useMemo( () => currentQueueItems.filter((item) => item.status !== "conflicted"), [currentQueueItems], ); + async function queueOfflineDraft(action: WorkflowDraftAction) { + try { + if (!selectedTaskId) { + throw new Error("请选择任务"); + } + if (!selectedTask) { + throw new Error("当前待办尚未缓存,请联网加载后再操作"); + } + if (!selectedTask.allowedActions.includes(action)) { + throw new Error("当前待办不允许该动作"); + } + if (action === "reject" && !comment.trim()) { + throw new Error("驳回必须填写意见"); + } + + setOfflineDraftSubmitting(true); + await enqueueWorkflowActionDraft({ + taskId: selectedTaskId, + action, + comment: action === "reject" ? comment.trim() : null, + ownerEmployeeId, + detail: selectedTask, + }); + await refreshDraftQueueCache(); + setComment(""); + setError(""); + setMessage(action === "approve" ? "当前离线,通过草稿已加入待同步队列。" : "当前离线,驳回草稿已加入待同步队列。"); + toast.success(action === "approve" ? "已加入通过草稿队列" : "已加入驳回草稿队列"); + } catch (actionError) { + const nextError = resolveErrorMessage(actionError); + setError(nextError); + setMessage(""); + toast.error(nextError); + } finally { + setOfflineDraftSubmitting(false); + } + } const actionMutation = useMutation({ mutationFn: async (action: MobileAction) => { + if (!canUseRemote) { + throw new Error("当前离线,请使用离线草稿动作"); + } if (!selectedTaskId) { throw new Error("请选择任务"); } @@ -210,28 +261,9 @@ export function MobileApprovalsPage() { throw new Error("当前待办不允许该动作"); } - if (!canUseRemote) { - if (action === "return" || action === "transfer") { - throw new Error("离线仅支持通过 / 驳回草稿,退回与转审需联网执行"); - } - if (action === "reject" && !comment.trim()) { - throw new Error("驳回必须填写意见"); - } - - await enqueueWorkflowActionDraft({ - taskId: selectedTaskId, - action, - comment: action === "reject" ? comment.trim() : null, - ownerEmployeeId, - detail: selectedTask, - }); - - return { mode: "queued" as const, action }; - } - if (action === "approve") { await approveWorkflowTask(selectedTaskId); - return { mode: "submitted" as const, action }; + return { action }; } if (action === "reject") { @@ -239,7 +271,7 @@ export function MobileApprovalsPage() { throw new Error("驳回必须填写意见"); } await rejectWorkflowTask(selectedTaskId, comment.trim()); - return { mode: "submitted" as const, action }; + return { action }; } if (action === "return") { @@ -247,7 +279,7 @@ export function MobileApprovalsPage() { throw new Error("退回必须填写意见"); } await returnWorkflowTask(selectedTaskId, comment.trim()); - return { mode: "submitted" as const, action }; + return { action }; } if (!transferToApproverId) { @@ -258,17 +290,9 @@ export function MobileApprovalsPage() { } await transferWorkflowTask(selectedTaskId, transferToApproverId, transferReason.trim()); - return { mode: "submitted" as const, action }; + return { action }; }, - onSuccess: async (result) => { - if (result.mode === "queued") { - await queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "draft-queue", ownerEmployeeId] }); - setError(""); - setMessage(result.action === "approve" ? "当前离线,通过草稿已加入待同步队列。" : "当前离线,驳回草稿已加入待同步队列。"); - toast.success(result.action === "approve" ? "已加入通过草稿队列" : "已加入驳回草稿队列"); - return; - } - + onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }), queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-detail", selectedTaskId] }), @@ -289,6 +313,8 @@ export function MobileApprovalsPage() { }, }); + const isActionSubmitting = actionMutation.isPending || offlineDraftSubmitting; + const syncMutation = useMutation({ mutationFn: async (items: WorkflowDraftQueueItem[]) => { let succeeded = 0; @@ -524,16 +550,28 @@ export function MobileApprovalsPage() {