diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 05:23:52 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 05:23:52 +0800 |
| commit | 6abd4c206fbfe8369b3fde4c905619d08f13c5d1 (patch) | |
| tree | f53ce1746591b640f78cd11d63c8411160059d98 /src/FundLab.Web/App.fs | |
| parent | 545f2a507fd0138f7fd50ed7fb2631d0273db625 (diff) | |
| download | fund-lab-6abd4c206fbfe8369b3fde4c905619d08f13c5d1.tar.gz | |
Add SIP plan pause/resume and rebalance plan archive lifecycle (3d-24)
Diffstat (limited to 'src/FundLab.Web/App.fs')
| -rw-r--r-- | src/FundLab.Web/App.fs | 146 |
1 files changed, 143 insertions, 3 deletions
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index 62bb20e..7dea8bb 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -1005,6 +1005,9 @@ module Api = [<Import("getRebalancePlans", "./src/api.js")>] let getRebalancePlans (token: string) (fundId: string) : JS.Promise<RawRebalancePlan array> = jsNative + [<Import("archiveRebalancePlan", "./src/api.js")>] + let archiveRebalancePlan (token: string) (fundId: string) (planId: string) : JS.Promise<RawRebalancePlan> = jsNative + [<Import("executeRebalancePlan", "./src/api.js")>] let executeRebalancePlan (token: string) (fundId: string) (planId: string) : JS.Promise<RawRebalanceExecution> = jsNative @@ -1020,6 +1023,12 @@ module Api = [<Import("getSipPlans", "./src/api.js")>] let getSipPlans (token: string) (fundId: string) : JS.Promise<RawSipPlan array> = jsNative + [<Import("pauseSipPlan", "./src/api.js")>] + let pauseSipPlan (token: string) (fundId: string) (planId: string) : JS.Promise<RawSipPlan> = jsNative + + [<Import("resumeSipPlan", "./src/api.js")>] + let resumeSipPlan (token: string) (fundId: string) (planId: string) : JS.Promise<RawSipPlan> = jsNative + [<Import("getInvestmentPlans", "./src/api.js")>] let getInvestmentPlans (token: string) (fundId: string) : JS.Promise<RawInvestmentPlan array> = jsNative @@ -1411,11 +1420,15 @@ type Model = sipCreateSeq: int sipReadSeq: int sipInFlight: bool + sipStatusSeq: int + sipStatusInFlight: bool lastSipAttempt: SipAttempt option sipPlans: SipPlan list rebalTargets: RebalanceTargetDraft list rebalCreateSeq: int rebalReadSeq: int + rebalArchiveSeq: int + rebalArchiveInFlight: bool rebalInFlight: bool rebalExecuteSeq: int rebalExecuteInFlight: bool @@ -1548,6 +1561,10 @@ type Msg = | SipPlansReadRequested | SipPlansReadCompleted of requestId: int * plans: RawSipPlan array | SipPlansReadFailed of requestId: int * message: string + | SipPlanPauseRequested of planId: string + | SipPlanResumeRequested of planId: string + | SipPlanStatusChanged of requestId: int * plan: RawSipPlan + | SipPlanStatusChangeFailed of requestId: int * message: string | RebalTargetCodeChanged of index: int * value: string | RebalTargetPercentChanged of index: int * value: string | RebalTargetAdded @@ -1558,6 +1575,9 @@ type Msg = | RebalancePlansReadRequested | RebalancePlansReadCompleted of requestId: int * plans: RawRebalancePlan array | RebalancePlansReadFailed of requestId: int * message: string + | RebalancePlanArchiveRequested of planId: string + | RebalancePlanArchived of requestId: int * plan: RawRebalancePlan + | RebalancePlanArchiveFailed of requestId: int * message: string | RebalancePreviewRequested of planId: string | RebalancePreviewCompleted of requestId: int * preview: RawRebalancePreview | RebalancePreviewFailed of requestId: int * message: string @@ -1705,11 +1725,15 @@ let init () = sipCreateSeq = 0 sipReadSeq = 0 sipInFlight = false + sipStatusSeq = 0 + sipStatusInFlight = false lastSipAttempt = None sipPlans = [] rebalTargets = [ { code = ""; percent = "" }; { code = ""; percent = "" } ] rebalCreateSeq = 0 rebalReadSeq = 0 + rebalArchiveSeq = 0 + rebalArchiveInFlight = false rebalInFlight = false rebalExecuteSeq = 0 rebalExecuteInFlight = false @@ -1894,6 +1918,20 @@ let private readSipPlansCommand token fundId requestId = (fun plans -> SipPlansReadCompleted(requestId, plans)) (fun error -> SipPlansReadFailed(requestId, errorText error)) +let private pauseSipPlanCommand token fundId planId requestId = + Cmd.OfPromise.either + (fun () -> Api.pauseSipPlan token fundId planId) + () + (fun plan -> SipPlanStatusChanged(requestId, plan)) + (fun error -> SipPlanStatusChangeFailed(requestId, errorText error)) + +let private resumeSipPlanCommand token fundId planId requestId = + Cmd.OfPromise.either + (fun () -> Api.resumeSipPlan token fundId planId) + () + (fun plan -> SipPlanStatusChanged(requestId, plan)) + (fun error -> SipPlanStatusChangeFailed(requestId, errorText error)) + let private createRebalancePlanCommand token fundId payload requestId = Cmd.OfPromise.either (fun () -> Api.createRebalancePlan token fundId payload) @@ -1908,6 +1946,13 @@ let private readRebalancePlansCommand token fundId requestId = (fun plans -> RebalancePlansReadCompleted(requestId, plans)) (fun error -> RebalancePlansReadFailed(requestId, errorText error)) +let private archiveRebalancePlanCommand token fundId planId requestId = + Cmd.OfPromise.either + (fun () -> Api.archiveRebalancePlan token fundId planId) + () + (fun plan -> RebalancePlanArchived(requestId, plan)) + (fun error -> RebalancePlanArchiveFailed(requestId, errorText error)) + let private executeRebalanceCommand token fundId planId requestId = Cmd.OfPromise.either (fun () -> Api.executeRebalancePlan token fundId planId) @@ -2027,6 +2072,18 @@ let private runInvestmentPlansCommand token fundId requestId = (fun run -> InvestmentPlansRunCompleted(requestId, fundId, run)) (fun error -> InvestmentPlansRunFailed(requestId, fundId, errorText error)) +let private dispatchSipStatusChange model planId pause = + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.sipStatusInFlight -> + let requestId = model.sipStatusSeq + 1 + + { model with sipStatusSeq = requestId; sipStatusInFlight = true; error = None }, + (if pause then + pauseSipPlanCommand model.token fund.id planId requestId + else + resumeSipPlanCommand model.token fund.id planId requestId) + | _ -> model, Cmd.none + let update message model = match message with | TokenChanged token -> @@ -2834,6 +2891,28 @@ let update message model = { model with error = Some message }, Cmd.none else model, Cmd.none + | SipPlanPauseRequested planId -> dispatchSipStatusChange model planId true + | SipPlanResumeRequested planId -> dispatchSipStatusChange model planId false + | SipPlanStatusChanged (requestId, plan) -> + if requestId = model.sipStatusSeq then + let decoded = Api.decodeSipPlan plan + + { + model with + sipStatusInFlight = false + sipPlans = + model.sipPlans + |> List.map (fun existing -> if existing.id = decoded.id then decoded else existing) + error = None + }, + Cmd.none + else + model, Cmd.none + | SipPlanStatusChangeFailed (requestId, message) -> + if requestId = model.sipStatusSeq then + { model with sipStatusInFlight = false; error = Some message }, Cmd.none + else + model, Cmd.none | RebalTargetCodeChanged (index, value) -> { model with @@ -2958,6 +3037,33 @@ let update message model = { model with error = Some message }, Cmd.none else model, Cmd.none + | RebalancePlanArchiveRequested planId -> + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.rebalArchiveInFlight -> + let requestId = model.rebalArchiveSeq + 1 + { model with rebalArchiveSeq = requestId; rebalArchiveInFlight = true; error = None }, + archiveRebalancePlanCommand model.token fund.id planId requestId + | _ -> model, Cmd.none + | RebalancePlanArchived (requestId, plan) -> + if requestId = model.rebalArchiveSeq then + let decoded = Api.decodeRebalancePlan plan + + { + model with + rebalArchiveInFlight = false + rebalPlans = + model.rebalPlans + |> List.map (fun existing -> if existing.id = decoded.id then decoded else existing) + error = None + }, + Cmd.none + else + model, Cmd.none + | RebalancePlanArchiveFailed (requestId, message) -> + if requestId = model.rebalArchiveSeq then + { model with rebalArchiveInFlight = false; error = Some message }, Cmd.none + else + model, Cmd.none | RebalancePreviewRequested planId -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.rebalPreviewInFlight -> @@ -4394,7 +4500,7 @@ let private sipFrequencyText (frequency: string) = elif frequency = "monthly" then "每月" else frequency -let private sipPlanRow (plan: SipPlan) = +let private sipPlanRow dispatch (plan: SipPlan) = Html.div [ prop.className "order-row" prop.children [ @@ -4418,7 +4524,31 @@ let private sipPlanRow (plan: SipPlan) = | None -> "最近执行 暂无" Html.span [ prop.className "order-cell"; prop.text lastExecution ] - Html.span [ prop.className "order-status"; prop.text (if plan.status = "active" then "进行中" else plan.status) ] + Html.span [ + prop.className "order-status" + prop.text ( + if plan.status = "active" then "进行中" + elif plan.status = "paused" then "已暂停" + else plan.status + ) + ] + Html.span [ + prop.className "order-cell sip-plan-actions" + prop.children [ + if plan.status = "paused" then + Html.button [ + prop.className "secondary-action sip-resume-action" + prop.onClick (fun _ -> dispatch (SipPlanResumeRequested plan.id)) + prop.text "恢复" + ] + else + Html.button [ + prop.className "secondary-action sip-pause-action" + prop.onClick (fun _ -> dispatch (SipPlanPauseRequested plan.id)) + prop.text "暂停" + ] + ] + ] ] ] @@ -4497,7 +4627,7 @@ let private sipPanel model dispatch = if List.isEmpty model.sipPlans then Html.p [ prop.className "hint"; prop.text "暂无定投计划" ] else - yield! (model.sipPlans |> List.map sipPlanRow) + yield! (model.sipPlans |> List.map (sipPlanRow dispatch)) ] ] Html.div [ @@ -4691,6 +4821,10 @@ let private rebalancePanel model dispatch = |> List.map (fun target -> sprintf "%s %s%%" target.instrumentCode target.targetPercent) |> String.concat " / ") ] + Html.span [ + prop.className "order-status" + prop.text (if plan.status = "archived" then "已归档" else "进行中") + ] (if plan.status = "active" then Html.span [ prop.className "order-actions" @@ -4707,6 +4841,12 @@ let private rebalancePanel model dispatch = prop.onClick (fun _ -> dispatch (RebalanceExecuteRequested plan.id)) prop.text ((if model.rebalExecuteInFlight then "执行中..." else "执行再平衡"): string) ] + Html.button [ + prop.className "secondary-action rebalance-archive-action" + prop.disabled model.rebalArchiveInFlight + prop.onClick (fun _ -> dispatch (RebalancePlanArchiveRequested plan.id)) + prop.text "归档" + ] ] ] else |
