summaryrefslogtreecommitdiff
path: root/src/FundLab.Web
diff options
context:
space:
mode:
Diffstat (limited to 'src/FundLab.Web')
-rw-r--r--src/FundLab.Web/App.fs268
-rw-r--r--src/FundLab.Web/src/api.js16
-rw-r--r--src/FundLab.Web/src/styles.css61
3 files changed, 342 insertions, 3 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" ]
]
]
diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js
index 7d12d4d..0d3b6fd 100644
--- a/src/FundLab.Web/src/api.js
+++ b/src/FundLab.Web/src/api.js
@@ -36,3 +36,19 @@ export function getNav(token, code) {
export function refreshNav(token, code) {
return requestJson(`/api/instruments/${encodeURIComponent(code)}/nav/refresh`, token, { method: "POST" });
}
+
+export function createFund(token, payload) {
+ const body = `{"name":${JSON.stringify(payload.name)},"initialCash":${JSON.stringify(payload.initialCash)},"initialUnitNav":${JSON.stringify(payload.initialUnitNav)},"isSynthetic":${payload.isSynthetic ? "true" : "false"}}`;
+ return requestJson("/api/funds", token, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Idempotency-Key": payload.idempotencyKey
+ },
+ body
+ });
+}
+
+export function getFund(token, fundId) {
+ return requestJson(`/api/funds/${encodeURIComponent(fundId)}`, token);
+}
diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css
index f895353..dcd8833 100644
--- a/src/FundLab.Web/src/styles.css
+++ b/src/FundLab.Web/src/styles.css
@@ -426,6 +426,59 @@ h2 {
margin-top: 24px;
}
+.fund-form-row {
+ display: flex;
+ gap: 12px;
+ align-items: flex-end;
+}
+
+.fund-form-row .field-label {
+ flex: 1;
+ min-width: 0;
+}
+
+.fund-form-row .primary-action {
+ flex: 0 0 auto;
+}
+
+.fund-summary {
+ display: grid;
+ margin-top: 20px;
+ padding: 8px 18px 18px;
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ background: #fff;
+}
+
+.fund-detail-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 11px 0;
+ border-bottom: 1px dashed #e2e8f0;
+}
+
+.fund-detail-label {
+ color: #667085;
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.fund-detail-value {
+ overflow: hidden;
+ color: #172033;
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 13px;
+ text-align: right;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.fund-summary .panel-actions {
+ margin-top: 10px;
+}
+
.footer-note {
padding: 16px 0 34px;
font-family: "SFMono-Regular", Consolas, monospace;
@@ -466,10 +519,16 @@ h2 {
flex-direction: column;
}
- .search-row .primary-action {
+ .search-row .primary-action,
+ .fund-form-row .primary-action {
width: 100%;
}
+ .fund-form-row {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
.result-row {
grid-template-columns: 82px minmax(0, 1fr);
}