summaryrefslogtreecommitdiff
path: root/src/FundLab.Web
diff options
context:
space:
mode:
Diffstat (limited to 'src/FundLab.Web')
-rw-r--r--src/FundLab.Web/App.fs324
-rw-r--r--src/FundLab.Web/src/api.js16
2 files changed, 340 insertions, 0 deletions
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs
index dc558c9..6fe1189 100644
--- a/src/FundLab.Web/App.fs
+++ b/src/FundLab.Web/App.fs
@@ -160,6 +160,17 @@ type RawPosition =
valuationCollectedAt: obj
}
+type RawSipPlan =
+ {
+ id: string
+ instrumentCode: string
+ amount: string
+ frequency: string
+ status: string
+ anchorDate: string
+ nextTradeDate: string
+ }
+
type RawRedemption =
{
id: string
@@ -240,6 +251,33 @@ type CreateCapitalPayload =
note: string
}
+type SipAttempt =
+ {
+ idempotencyKey: string
+ instrumentCode: string
+ amount: string
+ frequency: string
+ }
+
+type CreateSipPayload =
+ {
+ idempotencyKey: string
+ instrumentCode: string
+ amount: string
+ frequency: string
+ }
+
+type SipPlan =
+ {
+ id: string
+ instrumentCode: string
+ amount: string
+ frequency: string
+ status: string
+ anchorDate: string
+ nextTradeDate: string
+ }
+
type OrderDetail =
{
id: string
@@ -334,6 +372,12 @@ module Api =
[<Import("createCapitalDeposit", "./src/api.js")>]
let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise<obj> = jsNative
+ [<Import("createSipPlan", "./src/api.js")>]
+ let createSipPlan (token: string) (fundId: string) (payload: CreateSipPayload) : JS.Promise<RawSipPlan> = jsNative
+
+ [<Import("getSipPlans", "./src/api.js")>]
+ let getSipPlans (token: string) (fundId: string) : JS.Promise<RawSipPlan array> = jsNative
+
let decodeOptionalText (raw: obj) : string option =
if isNull raw then
None
@@ -405,6 +449,17 @@ module Api =
positions = raw.positions |> Array.map decodePosition |> List.ofArray
}
+ let decodeSipPlan (raw: RawSipPlan) : SipPlan =
+ {
+ id = raw.id
+ instrumentCode = raw.instrumentCode
+ amount = raw.amount
+ frequency = raw.frequency
+ status = raw.status
+ anchorDate = raw.anchorDate
+ nextTradeDate = raw.nextTradeDate
+ }
+
let decodeRedemption (raw: RawRedemption) : RedemptionDetail =
{
id = raw.id
@@ -469,6 +524,14 @@ type Model =
capitalInFlight: bool
lastCapitalAttempt: CapitalAttempt option
capitalNote: string option
+ sipCode: string
+ sipAmount: string
+ sipFrequency: string
+ sipCreateSeq: int
+ sipReadSeq: int
+ sipInFlight: bool
+ lastSipAttempt: SipAttempt option
+ sipPlans: SipPlan list
error: string option
}
@@ -522,6 +585,15 @@ type Msg =
| CapitalDepositRequested
| CapitalDepositCompleted of requestId: int * amount: string
| CapitalDepositFailed of requestId: int * message: string
+ | SipCodeChanged of string
+ | SipAmountChanged of string
+ | SipFrequencyChanged of string
+ | SipPlanCreateRequested
+ | SipPlanCreateCompleted of requestId: int * fundId: string * plan: RawSipPlan
+ | SipPlanCreateFailed of requestId: int * fundId: string * message: string
+ | SipPlansReadRequested
+ | SipPlansReadCompleted of requestId: int * plans: RawSipPlan array
+ | SipPlansReadFailed of requestId: int * message: string
let defaultInitialUnitNav = "1.00000000"
@@ -598,6 +670,14 @@ let init () =
capitalInFlight = false
lastCapitalAttempt = None
capitalNote = None
+ sipCode = ""
+ sipAmount = ""
+ sipFrequency = "weekly"
+ sipCreateSeq = 0
+ sipReadSeq = 0
+ sipInFlight = false
+ lastSipAttempt = None
+ sipPlans = []
error = None
}
@@ -698,6 +778,20 @@ let private createCapitalDepositCommand token fundId payload requestId =
(fun _ -> CapitalDepositCompleted(requestId, payload.amount))
(fun error -> CapitalDepositFailed(requestId, errorText error))
+let private createSipPlanCommand token fundId payload requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.createSipPlan token fundId payload)
+ ()
+ (fun plan -> SipPlanCreateCompleted(requestId, fundId, plan))
+ (fun error -> SipPlanCreateFailed(requestId, fundId, errorText error))
+
+let private readSipPlansCommand token fundId requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.getSipPlans token fundId)
+ ()
+ (fun plans -> SipPlansReadCompleted(requestId, plans))
+ (fun error -> SipPlansReadFailed(requestId, errorText error))
+
let update message model =
match message with
| TokenChanged token ->
@@ -747,6 +841,14 @@ let update message model =
capitalInFlight = false
lastCapitalAttempt = None
capitalNote = None
+ sipCode = ""
+ sipAmount = ""
+ sipFrequency = "weekly"
+ sipCreateSeq = model.sipCreateSeq + 1
+ sipReadSeq = model.sipReadSeq + 1
+ sipInFlight = false
+ lastSipAttempt = None
+ sipPlans = []
error = None
},
Cmd.none
@@ -922,6 +1024,14 @@ let update message model =
capitalInFlight = false
lastCapitalAttempt = None
capitalNote = None
+ sipCode = ""
+ sipAmount = ""
+ sipFrequency = "weekly"
+ sipCreateSeq = model.sipCreateSeq + 1
+ sipReadSeq = model.sipReadSeq + 1
+ sipInFlight = false
+ lastSipAttempt = None
+ sipPlans = []
error = None
},
Cmd.ofMsg OrdersReadRequested
@@ -1316,6 +1426,109 @@ let update message model =
{ model with capitalInFlight = false; error = Some message }, Cmd.none
else
model, Cmd.none
+ | SipCodeChanged value ->
+ { model with sipCode = value; error = None; lastSipAttempt = None }, Cmd.none
+ | SipAmountChanged value ->
+ { model with sipAmount = value; error = None; lastSipAttempt = None }, Cmd.none
+ | SipFrequencyChanged value ->
+ { model with sipFrequency = value; error = None; lastSipAttempt = None }, Cmd.none
+ | SipPlanCreateRequested ->
+ let code = model.sipCode.Trim()
+ let amount = model.sipAmount.Trim()
+
+ if String.IsNullOrWhiteSpace model.token then
+ { model with error = Some "请输入 API token" }, Cmd.none
+ elif model.createdFund.IsNone then
+ { model with error = Some "请先创建一个基金" }, Cmd.none
+ elif code = "" then
+ { model with error = Some "请输入基金代码" }, Cmd.none
+ elif not (isValidCashText amount) || not (isPositiveCash amount) then
+ { model with error = Some "定投金额必须是大于零的两位小数金额,例如 200.00" }, Cmd.none
+ elif model.sipInFlight then
+ model, Cmd.none
+ else
+ let requestId = model.sipCreateSeq + 1
+
+ let idempotencyKey =
+ match model.lastSipAttempt with
+ | Some attempt when
+ attempt.instrumentCode = code
+ && attempt.amount = amount
+ && attempt.frequency = model.sipFrequency
+ ->
+ attempt.idempotencyKey
+ | _ -> Guid.NewGuid().ToString("N")
+
+ {
+ model with
+ sipCode = code
+ sipAmount = amount
+ sipCreateSeq = requestId
+ sipInFlight = true
+ lastSipAttempt =
+ Some
+ {
+ idempotencyKey = idempotencyKey
+ instrumentCode = code
+ amount = amount
+ frequency = model.sipFrequency
+ }
+ error = None
+ },
+ createSipPlanCommand
+ model.token
+ model.createdFund.Value.id
+ {
+ idempotencyKey = idempotencyKey
+ instrumentCode = code
+ amount = amount
+ frequency = model.sipFrequency
+ }
+ requestId
+ | SipPlanCreateCompleted (requestId, fundId, plan) ->
+ if requestId = model.sipCreateSeq
+ && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then
+ {
+ model with
+ sipInFlight = false
+ lastSipAttempt = None
+ error = None
+ },
+ Cmd.ofMsg SipPlansReadRequested
+ else
+ model, Cmd.none
+ | SipPlanCreateFailed (requestId, fundId, message) ->
+ if requestId = model.sipCreateSeq
+ && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then
+ { model with sipInFlight = false; error = Some message }, Cmd.none
+ else
+ model, Cmd.none
+ | SipPlansReadRequested ->
+ match model.createdFund with
+ | Some fund when not (String.IsNullOrWhiteSpace model.token) ->
+ let requestId = model.sipReadSeq + 1
+
+ { model with sipReadSeq = requestId; error = None },
+ readSipPlansCommand model.token fund.id requestId
+ | Some _ ->
+ { model with error = Some "请输入 API token" }, Cmd.none
+ | None ->
+ model, Cmd.none
+ | SipPlansReadCompleted (requestId, plans) ->
+ if requestId = model.sipReadSeq then
+ {
+ model with
+ sipPlans = plans |> Array.map Api.decodeSipPlan |> Array.toList
+ error = None
+ },
+ Cmd.none
+ else
+ model, Cmd.none
+ | SipPlansReadFailed (requestId, message) ->
+ if requestId = model.sipReadSeq then
+ { model with error = Some message }, Cmd.none
+ else
+ model, Cmd.none
let private navText (text: string) =
match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with
@@ -2025,6 +2238,116 @@ let private redeemPanel model dispatch =
]
]
+let private sipFrequencyText (frequency: string) =
+ if frequency = "weekly" then "每周"
+ elif frequency = "biweekly" then "每两周"
+ elif frequency = "monthly" then "每月"
+ else frequency
+
+let private sipPlanRow (plan: SipPlan) =
+ 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" (sipFrequencyText 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.nextTradeDate) ]
+ Html.span [ prop.className "order-status"; prop.text (if plan.status = "active" then "进行中" else plan.status) ]
+ ]
+ ]
+
+let private sipPanel model dispatch =
+ Html.section [
+ prop.className "panel sip-panel"
+ prop.children [
+ Html.div [
+ prop.className "section-heading"
+ prop.children [
+ Html.div [
+ Html.p [ prop.className "eyebrow"; prop.text "07 / SIP" ]
+ Html.h2 "定期定额投资计划"
+ ]
+ Html.span [ prop.className "section-note"; prop.text "SIP · schedule only" ]
+ ]
+ ]
+ Html.div [
+ prop.className "fund-form-row"
+ prop.children [
+ Html.label [
+ prop.className "field-label"
+ prop.children [
+ Html.span "基金代码"
+ Html.input [
+ prop.className "text-input sip-code-input"
+ prop.placeholder "六位基金代码"
+ prop.value model.sipCode
+ prop.onChange (fun value -> dispatch (SipCodeChanged value))
+ ]
+ ]
+ ]
+ Html.label [
+ prop.className "field-label"
+ prop.children [
+ Html.span "每期金额(元)"
+ Html.input [
+ prop.className "text-input sip-amount-input"
+ prop.placeholder "例如 200.00"
+ prop.value model.sipAmount
+ prop.onChange (fun value -> dispatch (SipAmountChanged value))
+ ]
+ ]
+ ]
+ Html.label [
+ prop.className "field-label"
+ prop.children [
+ Html.span "频率"
+ Html.select [
+ prop.className "text-input sip-frequency-input"
+ prop.value model.sipFrequency
+ prop.onChange (fun value -> dispatch (SipFrequencyChanged value))
+ prop.children [
+ Html.option [ prop.value "weekly"; prop.text "每周" ]
+ Html.option [ prop.value "biweekly"; prop.text "每两周" ]
+ Html.option [ prop.value "monthly"; prop.text "每月" ]
+ ]
+ ]
+ ]
+ ]
+ Html.button [
+ prop.className "primary-action sip-submit-action"
+ prop.disabled model.sipInFlight
+ prop.onClick (fun _ -> dispatch SipPlanCreateRequested)
+ prop.text ((if model.sipInFlight then "创建中..." else "创建定投计划"): string)
+ ]
+ ]
+ ]
+ Html.p [
+ prop.className "hint"
+ prop.text "计划只登记扣款节奏与金额;到期执行将在后续接入,当前不扣款、不动份额与净值。"
+ ]
+ Html.div [
+ prop.className "sip-plans"
+ prop.children [
+ if List.isEmpty model.sipPlans then
+ Html.p [ prop.className "hint"; prop.text "暂无定投计划" ]
+ else
+ yield! (model.sipPlans |> List.map sipPlanRow)
+ ]
+ ]
+ Html.div [
+ prop.className "panel-actions"
+ prop.children [
+ Html.button [
+ prop.className "secondary-action sip-plans-refresh-action"
+ prop.onClick (fun _ -> dispatch SipPlansReadRequested)
+ prop.text "刷新计划"
+ ]
+ ]
+ ]
+ ]
+ ]
+
let view model dispatch =
Html.main [
prop.className "app-shell"
@@ -2083,6 +2406,7 @@ let view model dispatch =
subscribePanel model dispatch
positionsPanel model dispatch
redeemPanel model dispatch
+ sipPanel 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 ab3235a..c5d4c2c 100644
--- a/src/FundLab.Web/src/api.js
+++ b/src/FundLab.Web/src/api.js
@@ -119,6 +119,22 @@ export function confirmRedemption(token, fundId, orderId, idempotencyKey) {
);
}
+export function createSipPlan(token, fundId, payload) {
+ const body = `{"instrumentCode":${JSON.stringify(payload.instrumentCode)},"amount":${JSON.stringify(payload.amount)},"frequency":${JSON.stringify(payload.frequency)}}`;
+ return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans`, token, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Idempotency-Key": payload.idempotencyKey
+ },
+ body
+ });
+}
+
+export function getSipPlans(token, fundId) {
+ return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans`, token);
+}
+
export function createCapitalDeposit(token, fundId, payload) {
const body = `{"amount":${JSON.stringify(payload.amount)},"note":${JSON.stringify(payload.note || null)}}`;
return requestJson(`/api/funds/${encodeURIComponent(fundId)}/capital/deposit`, token, {