diff options
Diffstat (limited to 'src/FundLab.Web/App.fs')
| -rw-r--r-- | src/FundLab.Web/App.fs | 268 |
1 files changed, 266 insertions, 2 deletions
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index 0ee4d56..43c438f 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -99,6 +99,34 @@ type RawNavResponse = observations: RawObservation array } +type CreateFundPayload = + { + idempotencyKey: string + name: string + initialCash: string + initialUnitNav: string + isSynthetic: bool + } + +type FundSummary = + { + id: string + name: string + currency: string + initialCash: string + initialUnitNav: string + isSynthetic: bool + availableCash: string + status: string + } + +type CreateAttempt = + { + idempotencyKey: string + name: string + cash: string + } + module Api = [<Import("searchInstruments", "./src/api.js")>] let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative @@ -109,6 +137,12 @@ module Api = [<Import("refreshNav", "./src/api.js")>] let refreshNav (token: string) (code: string) : JS.Promise<RawNavResponse> = jsNative + [<Import("createFund", "./src/api.js")>] + let createFund (token: string) (payload: CreateFundPayload) : JS.Promise<FundSummary> = jsNative + + [<Import("getFund", "./src/api.js")>] + let getFund (token: string) (fundId: string) : JS.Promise<FundSummary> = jsNative + let decodeOptionalText (raw: obj) : string option = if isNull raw then None @@ -151,6 +185,12 @@ type Model = observations: NavObservation list searchInFlight: bool navInFlight: bool + fundName: string + fundCash: string + fundSeq: int + fundInFlight: bool + createdFund: FundSummary option + lastCreateAttempt: CreateAttempt option error: string option } @@ -165,6 +205,26 @@ type Msg = | RefreshNavRequested | NavCompleted of requestId: int * observations: NavObservation list | NavFailed of requestId: int * message: string + | FundNameChanged of string + | FundCashChanged of string + | FundCreateRequested + | FundCreateCompleted of requestId: int * fund: FundSummary + | FundCreateFailed of requestId: int * message: string + | FundReadRequested + | FundReadCompleted of requestId: int * fund: FundSummary + | FundReadFailed of requestId: int * message: string + +let defaultInitialUnitNav = "1.00000000" + +let private cashPattern = + Text.RegularExpressions.Regex("^[0-9]{1,18}(\.[0-9]{1,2})?$", Text.RegularExpressions.RegexOptions.Compiled) + +let isValidCashText (text: string) = cashPattern.IsMatch text + +let resolveCreateKey (lastAttempt: CreateAttempt option) (name: string) (cash: string) = + match lastAttempt with + | Some attempt when attempt.name = name && attempt.cash = cash -> attempt.idempotencyKey + | _ -> Guid.NewGuid().ToString("N") let init () = { @@ -177,6 +237,12 @@ let init () = observations = [] searchInFlight = false navInFlight = false + fundName = "" + fundCash = "" + fundSeq = 0 + fundInFlight = false + createdFund = None + lastCreateAttempt = None error = None } @@ -207,6 +273,20 @@ let private refreshCommand token code requestId = (fun raw -> NavCompleted(requestId, Api.decodeNav raw)) (fun error -> NavFailed(requestId, errorText error)) +let private createFundCommand token payload requestId = + Cmd.OfPromise.either + (fun () -> Api.createFund token payload) + () + (fun fund -> FundCreateCompleted(requestId, fund)) + (fun error -> FundCreateFailed(requestId, errorText error)) + +let private readFundCommand token fundId requestId = + Cmd.OfPromise.either + (fun () -> Api.getFund token fundId) + () + (fun fund -> FundReadCompleted(requestId, fund)) + (fun error -> FundReadFailed(requestId, errorText error)) + let update message model = match message with | TokenChanged token -> @@ -220,6 +300,12 @@ let update message model = observations = [] searchInFlight = false navInFlight = false + fundName = "" + fundCash = "" + fundSeq = model.fundSeq + 1 + fundInFlight = false + createdFund = None + lastCreateAttempt = None error = None }, Cmd.none @@ -322,6 +408,86 @@ let update message model = { model with navInFlight = false; error = Some message }, Cmd.none else model, Cmd.none + | FundNameChanged value -> + { model with fundName = value; error = None; lastCreateAttempt = None }, Cmd.none + | FundCashChanged value -> + { model with fundCash = value; error = None; lastCreateAttempt = None }, Cmd.none + | FundCreateRequested -> + let name = model.fundName.Trim() + let cash = model.fundCash.Trim() + + if String.IsNullOrWhiteSpace model.token then + { model with error = Some "请输入 API token" }, Cmd.none + elif name = "" then + { model with error = Some "请输入基金名称" }, Cmd.none + elif not (isValidCashText cash) then + { model with error = Some "初始现金必须是合法金额,例如 10000.00" }, Cmd.none + elif model.fundInFlight then + model, Cmd.none + else + let requestId = model.fundSeq + 1 + let idempotencyKey = resolveCreateKey model.lastCreateAttempt name cash + + { + model with + fundName = name + fundCash = cash + fundSeq = requestId + fundInFlight = true + lastCreateAttempt = Some { idempotencyKey = idempotencyKey; name = name; cash = cash } + error = None + }, + createFundCommand + model.token + { + idempotencyKey = idempotencyKey + name = name + initialCash = cash + initialUnitNav = defaultInitialUnitNav + isSynthetic = false + } + requestId + | FundCreateCompleted (requestId, fund) -> + if requestId = model.fundSeq then + { + model with + createdFund = Some fund + fundInFlight = false + lastCreateAttempt = None + error = None + }, + Cmd.none + else + model, Cmd.none + | FundCreateFailed (requestId, message) -> + if requestId = model.fundSeq then + { model with fundInFlight = false; error = Some message }, Cmd.none + else + model, Cmd.none + | FundReadRequested -> + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) -> + if model.fundInFlight then + model, Cmd.none + else + let requestId = model.fundSeq + 1 + + { model with fundSeq = requestId; fundInFlight = true; error = None }, + readFundCommand model.token fund.id requestId + | Some _ -> + { model with error = Some "请输入 API token" }, Cmd.none + | None -> + { model with error = Some "请先创建一个基金" }, Cmd.none + | FundReadCompleted (requestId, fund) -> + if requestId = model.fundSeq then + { model with createdFund = Some fund; fundInFlight = false; error = None }, Cmd.none + else + model, Cmd.none + | FundReadFailed (requestId, message) -> + if requestId = model.fundSeq then + { model with fundInFlight = false; error = Some message }, Cmd.none + else + model, Cmd.none let private navText (text: string) = match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with @@ -556,6 +722,103 @@ let private navPanel model dispatch = ] ] +let private fundSummaryRow (label: string) (value: string) = + Html.div [ + prop.className "fund-detail-row" + prop.children [ + Html.span [ prop.className "fund-detail-label"; prop.text label ] + Html.span [ prop.className "fund-detail-value"; prop.text value ] + ] + ] + +let private fundPanel model dispatch = + Html.section [ + prop.className "panel fund-panel" + prop.children [ + Html.div [ + prop.className "section-heading" + prop.children [ + Html.div [ + Html.p [ prop.className "eyebrow"; prop.text "03 / CREATE" ] + Html.h2 "创建一笔真实基金" + ] + Html.span [ prop.className "section-note"; prop.text "PostgreSQL · idempotent" ] + ] + ] + 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 fund-name-input" + prop.placeholder "例如 稳健一号" + prop.value model.fundName + prop.onChange (fun value -> dispatch (FundNameChanged value)) + ] + ] + ] + Html.label [ + prop.className "field-label" + prop.children [ + Html.span "初始现金(元)" + Html.input [ + prop.className "text-input fund-cash-input" + prop.placeholder "例如 10000.00" + prop.value model.fundCash + prop.onChange (fun value -> dispatch (FundCashChanged value)) + ] + ] + ] + Html.button [ + prop.className "primary-action fund-create-action" + prop.disabled model.fundInFlight + prop.onClick (fun _ -> dispatch FundCreateRequested) + prop.text ((if model.fundInFlight then "创建中..." else "创建基金"): string) + ] + ] + ] + Html.p [ + prop.className "hint" + prop.text "初始单位净值固定为 1.00000000;金额以 decimal 字符串提交,不经过浮点运算。" + ] + match model.createdFund with + | Some fund -> + Html.div [ + prop.className "fund-summary" + prop.children [ + fundSummaryRow "基金ID" fund.id + fundSummaryRow "名称" fund.name + fundSummaryRow "币种" fund.currency + fundSummaryRow "初始现金" fund.initialCash + fundSummaryRow "可用现金" fund.availableCash + fundSummaryRow "初始单位净值" fund.initialUnitNav + fundSummaryRow "份额" "0(新基金尚未买入持仓)" + fundSummaryRow "状态" fund.status + fundSummaryRow "数据来源" (if fund.isSynthetic then "合成(测试标记)" else "真实建档") + Html.div [ + prop.className "panel-actions" + prop.children [ + Html.button [ + prop.className "secondary-action fund-read-action" + prop.disabled model.fundInFlight + prop.onClick (fun _ -> dispatch FundReadRequested) + prop.text ((if model.fundInFlight then "读取中..." else "重新读取"): string) + ] + ] + ] + ] + ] + | None -> + Html.p [ + prop.className "hint" + prop.text "创建成功后,这里会展示服务端返回的基金档案,可通过重新读取复核持久化结果。" + ] + ] + ] + let view model dispatch = Html.main [ prop.className "app-shell" @@ -570,7 +833,7 @@ let view model dispatch = Html.span "FUND LAB" ] ] - Html.span [ prop.className "topbar-status"; prop.text "MARKET DATA / 3B" ] + Html.span [ prop.className "topbar-status"; prop.text "LEDGER / 3C-1" ] ] ] Html.div [ @@ -610,7 +873,8 @@ let view model dispatch = | None -> Html.none searchPanel model dispatch navPanel model dispatch - Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / MODE · READ-ONLY" ] + fundPanel model dispatch + Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / LEDGER · CREATE & READ" ] ] ] |
