From 79600c15aef9bd411abe88dcf36286a102b5abea Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 05:56:14 +0800 Subject: Add daily market refresh endpoint, snapshot-first valuation and UI refresh (3d-25) --- src/FundLab.Web/App.fs | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) (limited to 'src/FundLab.Web/App.fs') diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index 7dea8bb..fe16411 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -505,6 +505,29 @@ type RawFundValuation = positions: RawValuationPosition array } +type RawMarketRefreshTarget = + { + instrumentCode: string + assetClass: string + snapshotDate: string + price: string + } + +type RawMarketRefreshFailure = + { + instrumentCode: string + assetClass: string + reason: string + } + +type RawMarketRefresh = + { + fundId: string + asOfDate: string + refreshed: RawMarketRefreshTarget array + failures: RawMarketRefreshFailure array + } + type CreateAttempt = { idempotencyKey: string @@ -996,6 +1019,9 @@ module Api = [] let getFundValuation (token: string) (fundId: string) : JS.Promise = jsNative + [] + let refreshFundMarketData (token: string) (fundId: string) : JS.Promise = jsNative + [] let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise = jsNative @@ -1492,6 +1518,9 @@ type Model = valuationInFlight: bool valuation: FundValuationView option valuationMessage: string option + marketRefreshSeq: int + marketRefreshInFlight: bool + marketRefreshMessage: string option returnsSelectedDate: string option planReadSeq: int planInFlight: bool @@ -1627,6 +1656,9 @@ type Msg = | ValuationReadRequested | ValuationReadCompleted of requestId: int * fundId: string * valuation: RawFundValuation | ValuationReadFailed of requestId: int * fundId: string * message: string + | MarketRefreshRequested + | MarketRefreshCompleted of requestId: int * fundId: string * response: RawMarketRefresh + | MarketRefreshFailed of requestId: int * fundId: string * message: string | ReturnsDateChanged of string | InvestmentPlansReadRequested | InvestmentPlansReadCompleted of requestId: int * plans: RawInvestmentPlan array @@ -1797,6 +1829,9 @@ let init () = valuationInFlight = false valuation = None valuationMessage = None + marketRefreshSeq = 0 + marketRefreshInFlight = false + marketRefreshMessage = None returnsSelectedDate = None planReadSeq = 0 planInFlight = false @@ -2058,6 +2093,13 @@ let private readValuationCommand token fundId requestId = (fun valuation -> ValuationReadCompleted(requestId, fundId, valuation)) (fun error -> ValuationReadFailed(requestId, fundId, errorText error)) +let private refreshMarketDataCommand token fundId requestId = + Cmd.OfPromise.either + (fun () -> Api.refreshFundMarketData token fundId) + () + (fun response -> MarketRefreshCompleted(requestId, fundId, response)) + (fun error -> MarketRefreshFailed(requestId, fundId, errorText error)) + let private readInvestmentPlansCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getInvestmentPlans token fundId) @@ -3710,6 +3752,42 @@ let update message model = Cmd.none else model, Cmd.none + | MarketRefreshRequested -> + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) -> + let requestId = model.marketRefreshSeq + 1 + + { + model with + marketRefreshSeq = requestId + marketRefreshInFlight = true + marketRefreshMessage = None + }, + refreshMarketDataCommand model.token fund.id requestId + | _ -> model, Cmd.none + | MarketRefreshCompleted (requestId, fundId, response) -> + if requestId = model.marketRefreshSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { + model with + marketRefreshInFlight = false + marketRefreshMessage = + Some(sprintf "行情刷新完成(%s):更新 %d 项,失败 %d 项" response.asOfDate response.refreshed.Length response.failures.Length) + }, + Cmd.ofMsg ValuationReadRequested + else + model, Cmd.none + | MarketRefreshFailed (requestId, fundId, message) -> + if requestId = model.marketRefreshSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { + model with + marketRefreshInFlight = false + marketRefreshMessage = Some(sprintf "行情刷新失败 — %s" message) + }, + Cmd.none + else + model, Cmd.none | ReturnsDateChanged value -> { model with returnsSelectedDate = (if String.IsNullOrWhiteSpace value then None else Some value) }, Cmd.none | InvestmentPlansReadRequested -> @@ -5743,8 +5821,19 @@ let private stocksPanel model dispatch = prop.onClick (fun _ -> dispatch ValuationReadRequested) prop.text ((if model.valuationInFlight then "估值中..." else "刷新估值"): string) ] + Html.button [ + prop.className "secondary-action market-refresh-action" + prop.disabled model.marketRefreshInFlight + prop.onClick (fun _ -> dispatch MarketRefreshRequested) + prop.text ((if model.marketRefreshInFlight then "刷新中..." else "刷新行情"): string) + ] ] ] + Html.p [ + prop.className "market-refresh-message" + prop.hidden (model.marketRefreshMessage.IsNone) + prop.text (model.marketRefreshMessage |> Option.defaultValue "") + ] match model.valuation with | Some valuation -> Html.div [ -- cgit v1.2.3