diff options
Diffstat (limited to 'src/FundLab.Web')
| -rw-r--r-- | src/FundLab.Web/App.fs | 358 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 12 | ||||
| -rw-r--r-- | src/FundLab.Web/src/styles.css | 6 |
3 files changed, 375 insertions, 1 deletions
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index f1e6df6..1386b01 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -235,6 +235,47 @@ type RawSipPlan = lastExecutionDate: obj } +type RawInvestmentPlan = + { + id: string + fundId: string + instrumentCode: string + amount: string + frequency: string + status: string + anchorDate: string + nextRunDate: string + lastRunStatus: obj + lastRunDate: obj + isSynthetic: bool + createdAt: string + } + +type RawInvestmentPlanRunOutcome = + { + runDate: string + status: string + orderId: obj + pendingReason: obj + } + +type RawInvestmentPlanRunPlan = + { + planId: string + instrumentCode: string + amount: string + frequency: string + runs: RawInvestmentPlanRunOutcome array + nextRunDate: string + } + +type RawInvestmentPlanRun = + { + fundId: string + processingDate: string + plans: RawInvestmentPlanRunPlan array + } + type RawRedemption = { id: string @@ -393,6 +434,44 @@ type SipPlan = lastExecutionDate: string option } +type InvestmentPlan = + { + id: string + instrumentCode: string + amount: string + frequency: string + status: string + anchorDate: string + nextRunDate: string + lastRunStatus: string option + lastRunDate: string option + } + +type InvestmentPlanRunOutcome = + { + runDate: string + status: string + orderId: string option + pendingReason: string option + } + +type InvestmentPlanRunPlan = + { + planId: string + instrumentCode: string + amount: string + frequency: string + runs: InvestmentPlanRunOutcome list + nextRunDate: string + } + +type InvestmentPlanRun = + { + fundId: string + processingDate: string + plans: InvestmentPlanRunPlan list + } + type RebalanceTarget = { instrumentCode: string @@ -576,6 +655,12 @@ module Api = [<Import("getSipPlans", "./src/api.js")>] let getSipPlans (token: string) (fundId: string) : JS.Promise<RawSipPlan array> = jsNative + [<Import("getInvestmentPlans", "./src/api.js")>] + let getInvestmentPlans (token: string) (fundId: string) : JS.Promise<RawInvestmentPlan array> = jsNative + + [<Import("runInvestmentPlans", "./src/api.js")>] + let runInvestmentPlans (token: string) (fundId: string) : JS.Promise<RawInvestmentPlanRun> = jsNative + let decodeOptionalText (raw: obj) : string option = if isNull raw then None @@ -685,6 +770,44 @@ module Api = lastExecutionDate = decodeOptionalText raw.lastExecutionDate } + let decodeInvestmentPlan (raw: RawInvestmentPlan) : InvestmentPlan = + { + id = raw.id + instrumentCode = raw.instrumentCode + amount = raw.amount + frequency = raw.frequency + status = raw.status + anchorDate = raw.anchorDate + nextRunDate = raw.nextRunDate + lastRunStatus = decodeOptionalText raw.lastRunStatus + lastRunDate = decodeOptionalText raw.lastRunDate + } + + let decodeInvestmentPlanRunOutcome (raw: RawInvestmentPlanRunOutcome) : InvestmentPlanRunOutcome = + { + runDate = raw.runDate + status = raw.status + orderId = decodeOptionalText raw.orderId + pendingReason = decodeOptionalText raw.pendingReason + } + + let decodeInvestmentPlanRunPlan (raw: RawInvestmentPlanRunPlan) : InvestmentPlanRunPlan = + { + planId = raw.planId + instrumentCode = raw.instrumentCode + amount = raw.amount + frequency = raw.frequency + runs = raw.runs |> Array.map decodeInvestmentPlanRunOutcome |> Array.toList + nextRunDate = raw.nextRunDate + } + + let decodeInvestmentPlanRun (raw: RawInvestmentPlanRun) : InvestmentPlanRun = + { + fundId = raw.fundId + processingDate = raw.processingDate + plans = raw.plans |> Array.map decodeInvestmentPlanRunPlan |> Array.toList + } + let decodeRedemption (raw: RawRedemption) : RedemptionDetail = { id = raw.id @@ -812,6 +935,12 @@ type Model = returnsReadSeq: int returnsInFlight: bool returns: FundReturns option + planReadSeq: int + planInFlight: bool + planRunSeq: int + planRunInFlight: bool + investmentPlans: InvestmentPlan list + lastPlanRun: InvestmentPlanRun option error: string option } @@ -893,6 +1022,12 @@ type Msg = | ReturnsReadRequested | ReturnsReadCompleted of requestId: int * returns: RawReturns | ReturnsReadFailed of requestId: int * message: string + | InvestmentPlansReadRequested + | InvestmentPlansReadCompleted of requestId: int * plans: RawInvestmentPlan array + | InvestmentPlansReadFailed of requestId: int * message: string + | InvestmentPlansRunRequested + | InvestmentPlansRunCompleted of requestId: int * fundId: string * run: RawInvestmentPlanRun + | InvestmentPlansRunFailed of requestId: int * fundId: string * message: string let defaultInitialUnitNav = "1.00000000" @@ -994,6 +1129,12 @@ let init () = returnsReadSeq = 0 returnsInFlight = false returns = None + planReadSeq = 0 + planInFlight = false + planRunSeq = 0 + planRunInFlight = false + investmentPlans = [] + lastPlanRun = None error = None } @@ -1143,6 +1284,20 @@ let private readReturnsCommand token fundId requestId = (fun returns -> ReturnsReadCompleted(requestId, returns)) (fun error -> ReturnsReadFailed(requestId, errorText error)) +let private readInvestmentPlansCommand token fundId requestId = + Cmd.OfPromise.either + (fun () -> Api.getInvestmentPlans token fundId) + () + (fun plans -> InvestmentPlansReadCompleted(requestId, plans)) + (fun error -> InvestmentPlansReadFailed(requestId, errorText error)) + +let private runInvestmentPlansCommand token fundId requestId = + Cmd.OfPromise.either + (fun () -> Api.runInvestmentPlans token fundId) + () + (fun run -> InvestmentPlansRunCompleted(requestId, fundId, run)) + (fun error -> InvestmentPlansRunFailed(requestId, fundId, errorText error)) + let update message model = match message with | TokenChanged token -> @@ -1217,6 +1372,12 @@ let update message model = returnsReadSeq = model.returnsReadSeq + 1 returnsInFlight = false returns = None + planReadSeq = model.planReadSeq + 1 + planInFlight = false + planRunSeq = model.planRunSeq + 1 + planRunInFlight = false + investmentPlans = [] + lastPlanRun = None error = None }, Cmd.none @@ -1417,9 +1578,15 @@ let update message model = returnsReadSeq = model.returnsReadSeq + 1 returnsInFlight = false returns = None + planReadSeq = model.planReadSeq + 1 + planInFlight = false + planRunSeq = model.planRunSeq + 1 + planRunInFlight = false + investmentPlans = [] + lastPlanRun = None error = None }, - Cmd.batch [ Cmd.ofMsg OrdersReadRequested; Cmd.ofMsg ReturnsReadRequested ] + Cmd.batch [ Cmd.ofMsg OrdersReadRequested; Cmd.ofMsg ReturnsReadRequested; Cmd.ofMsg InvestmentPlansReadRequested ] else model, Cmd.none | FundCreateFailed (requestId, message) -> @@ -2120,7 +2287,79 @@ let update message model = { model with returnsInFlight = false; error = Some message }, Cmd.none else model, Cmd.none + | InvestmentPlansReadRequested -> + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) -> + let requestId = model.planReadSeq + 1 + + { + model with + planReadSeq = requestId + planInFlight = true + error = None + }, + readInvestmentPlansCommand model.token fund.id requestId + | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none + | None -> model, Cmd.none + | InvestmentPlansReadCompleted (requestId, plans) -> + if requestId = model.planReadSeq then + { + model with + investmentPlans = plans |> Array.map Api.decodeInvestmentPlan |> Array.toList + planInFlight = false + error = None + }, + Cmd.none + else + model, Cmd.none + | InvestmentPlansReadFailed (requestId, message) -> + if requestId = model.planReadSeq then + { model with planInFlight = false; error = Some message }, Cmd.none + else + model, Cmd.none + | InvestmentPlansRunRequested -> + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) -> + if model.planRunInFlight then + model, Cmd.none + else + let requestId = model.planRunSeq + 1 + { + model with + planRunSeq = requestId + planRunInFlight = true + error = None + }, + runInvestmentPlansCommand model.token fund.id requestId + | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none + | None -> model, Cmd.none + | InvestmentPlansRunCompleted (requestId, fundId, run) -> + if requestId = model.planRunSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + let decoded = Api.decodeInvestmentPlanRun run + + { + model with + planRunInFlight = false + lastPlanRun = Some decoded + error = None + }, + Cmd.batch + [ + Cmd.ofMsg InvestmentPlansReadRequested + Cmd.ofMsg FundReadRequested + Cmd.ofMsg PositionsReadRequested + Cmd.ofMsg ReturnsReadRequested + ] + else + model, Cmd.none + | InvestmentPlansRunFailed (requestId, fundId, message) -> + if requestId = model.planRunSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { model with planRunInFlight = false; error = Some message }, Cmd.none + else + model, Cmd.none let private navText (text: string) = match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with @@ -3389,6 +3628,122 @@ let private returnsPanel model dispatch = ] ] +let private investmentPlanFrequencyText (frequency: string) = + if frequency = "daily" then "每日" + elif frequency = "weekly" then "每周" + elif frequency = "monthly" then "每月" + else frequency + +let private investmentPlanRunStatusText (status: string) = + if status = "succeeded" then "已执行" + elif status = "pending_nav" then "待净值" + elif status = "insufficient_cash" then "现金不足" + elif status = "failed" then "失败" + else status + +let private investmentPlanRow (plan: InvestmentPlan) = + Html.div [ + prop.className "order-row" + prop.children [ + Html.span [ prop.className "order-code"; prop.text plan.instrumentCode ] + Html.span [ prop.className "order-cell"; prop.text (sprintf "金额 %s" plan.amount) ] + Html.span [ prop.className "order-cell"; prop.text (sprintf "频率 %s" (investmentPlanFrequencyText plan.frequency)) ] + Html.span [ prop.className "order-cell"; prop.text (sprintf "起始日 %s" plan.anchorDate) ] + Html.span [ prop.className "order-cell"; prop.text (sprintf "下次执行 %s" plan.nextRunDate) ] + + let lastRun = + match plan.lastRunStatus with + | Some status -> + sprintf "最近执行 %s(%s)" (investmentPlanRunStatusText status) (plan.lastRunDate |> Option.defaultValue "—") + | None -> "最近执行 暂无" + + Html.span [ prop.className "order-cell"; prop.text lastRun ] + Html.span [ prop.className "order-status"; prop.text (if plan.status = "active" then "进行中" else plan.status) ] + ] + ] + +let private investmentPlanRunRow (plan: InvestmentPlanRunPlan) = + Html.div [ + prop.className "order-row" + prop.children [ + Html.span [ prop.className "order-code"; prop.text plan.instrumentCode ] + Html.span [ prop.className "order-cell"; prop.text (sprintf "金额 %s" plan.amount) ] + Html.span [ prop.className "order-cell"; prop.text (sprintf "频率 %s" (investmentPlanFrequencyText plan.frequency)) ] + + let runs = + if List.isEmpty plan.runs then + "本次无到期扣款" + else + plan.runs + |> List.map (fun run -> sprintf "%s %s" run.runDate (investmentPlanRunStatusText run.status)) + |> String.concat "、" + + Html.span [ prop.className "order-cell"; prop.text runs ] + Html.span [ prop.className "order-cell"; prop.text (sprintf "下次执行 %s" plan.nextRunDate) ] + ] + ] + +let private investmentPlansPanel model dispatch = + Html.section [ + prop.className "panel plans-panel" + prop.children [ + Html.div [ + prop.className "section-heading" + prop.children [ + Html.div [ + Html.p [ prop.className "eyebrow"; prop.text "11 / PLANS" ] + Html.h2 "定投计划执行" + ] + Html.span [ prop.className "section-note"; prop.text "scheduled contribution" ] + ] + ] + Html.p [ + prop.className "hint" + prop.text "执行到期计划:按当日净值扣款买入,重复执行不产生第二笔;存款与买入都不计入收益。" + ] + Html.div [ + prop.className "sip-plans plans-list" + prop.children [ + if List.isEmpty model.investmentPlans then + Html.p [ prop.className "hint"; prop.text "暂无定投计划" ] + else + yield! (model.investmentPlans |> List.map investmentPlanRow) + ] + ] + match model.lastPlanRun with + | Some run -> + Html.div [ + prop.className "plans-run-result" + prop.children [ + Html.p [ prop.className "hint"; prop.text (sprintf "最近执行处理日 %s" run.processingDate) ] + + if List.isEmpty run.plans then + Html.p [ prop.className "hint"; prop.text "本次没有可执行的计划" ] + else + yield! (run.plans |> List.map investmentPlanRunRow) + ] + ] + | None -> Html.none + Html.div [ + prop.className "panel-actions" + prop.children [ + Html.button [ + prop.className "primary-action plans-run-action" + prop.disabled model.planRunInFlight + prop.onClick (fun _ -> dispatch InvestmentPlansRunRequested) + prop.text ((if model.planRunInFlight then "执行中..." else "执行到期计划"): string) + ] + Html.button [ + prop.className "secondary-action plans-refresh-action" + prop.disabled model.planInFlight + prop.onClick (fun _ -> dispatch InvestmentPlansReadRequested) + prop.text ((if model.planInFlight then "读取中..." else "刷新计划"): string) + ] + ] + ] + ] + ] + let view model dispatch = Html.main [ prop.className "app-shell" @@ -3451,6 +3806,7 @@ let view model dispatch = rebalancePanel model dispatch dividendPanel model dispatch returnsPanel model dispatch + investmentPlansPanel model dispatch Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / LEDGER · CREATE & READ & SUBSCRIBE" ] ] ] diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js index 7606e02..4f3f47c 100644 --- a/src/FundLab.Web/src/api.js +++ b/src/FundLab.Web/src/api.js @@ -143,6 +143,18 @@ export function getSipPlans(token, fundId) { return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans`, token); } +export function getInvestmentPlans(token, fundId) { + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/investment-plans`, token); +} + +export function runInvestmentPlans(token, fundId) { + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/investment-plans/run`, token, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}" + }); +} + export function createRebalancePlan(token, fundId, payload) { const body = `{"targets":${JSON.stringify(payload.targets)}}`; return requestJson(`/api/funds/${encodeURIComponent(fundId)}/rebalance/plans`, token, { diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css index 016178d..de00388 100644 --- a/src/FundLab.Web/src/styles.css +++ b/src/FundLab.Web/src/styles.css @@ -554,6 +554,12 @@ h2 { height: 300px; } +.plans-panel .plans-run-result { + margin-top: 10px; + padding-top: 8px; + border-top: 1px dashed #e2e8f0; +} + .footer-note { padding: 16px 0 34px; font-family: "SFMono-Regular", Consolas, monospace; |
