summaryrefslogtreecommitdiff
path: root/src/FundLab.Web/App.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 05:23:52 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 05:23:52 +0800
commit6abd4c206fbfe8369b3fde4c905619d08f13c5d1 (patch)
treef53ce1746591b640f78cd11d63c8411160059d98 /src/FundLab.Web/App.fs
parent545f2a507fd0138f7fd50ed7fb2631d0273db625 (diff)
downloadfund-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.fs146
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