diff options
Diffstat (limited to 'src/FundLab.Web')
| -rw-r--r-- | src/FundLab.Web/App.fs | 126 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 12 | ||||
| -rw-r--r-- | src/FundLab.Web/src/styles.css | 11 |
3 files changed, 148 insertions, 1 deletions
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index fe16411..c150596 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -453,6 +453,20 @@ type RawStockPositions = positions: RawStockPosition array } +type RawStockSell = + { + id: string + fundId: string + instrumentCode: string + stockName: obj + quantity: string + price: string + feeAmount: string + proceeds: string + executedAt: string + isSynthetic: bool + } + type RawBondTrade = { id: string @@ -616,6 +630,20 @@ type CreateStockTradePayload = quantity: string } +type CreateStockSellPayload = + { + idempotencyKey: string + instrumentCode: string + quantity: string + } + +type StockSellAttempt = + { + idempotencyKey: string + instrumentCode: string + quantity: string + } + type CreateBondTradePayload = { idempotencyKey: string @@ -1010,6 +1038,9 @@ module Api = [<Import("getStockPositions", "./src/api.js")>] let getStockPositions (token: string) (fundId: string) : JS.Promise<RawStockPositions> = jsNative + [<Import("createStockSell", "./src/api.js")>] + let createStockSell (token: string) (fundId: string) (payload: CreateStockSellPayload) : JS.Promise<RawStockSell> = jsNative + [<Import("createBondTrade", "./src/api.js")>] let createBondTrade (token: string) (fundId: string) (payload: CreateBondTradePayload) : JS.Promise<RawBondTrade> = jsNative @@ -1504,6 +1535,11 @@ type Model = stockPositionsInFlight: bool stockPositions: StockPositionView list stockPositionsMessage: string option + stockSellQuantities: Map<string, string> + stockSellAttempts: Map<string, StockSellAttempt> + stockSellSeq: int + stockSellInFlight: bool + stockSellMessage: string option bondTradeQuantity: string bondTradeSeq: int bondTradeInFlight: bool @@ -1646,6 +1682,10 @@ type Msg = | StockPositionsReadRequested | StockPositionsReadCompleted of requestId: int * fundId: string * positions: RawStockPositions | StockPositionsReadFailed of requestId: int * fundId: string * message: string + | StockSellQuantityChanged of code: string * value: string + | StockSellRequested of code: string + | StockSellCompleted of requestId: int * fundId: string * code: string * sell: RawStockSell + | StockSellFailed of requestId: int * fundId: string * code: string * message: string | BondTradeQuantityChanged of string | BondTradeRequested | BondTradeCompleted of requestId: int * fundId: string * trade: RawBondTrade @@ -1815,6 +1855,11 @@ let init () = stockPositionsInFlight = false stockPositions = [] stockPositionsMessage = None + stockSellQuantities = Map.empty + stockSellAttempts = Map.empty + stockSellSeq = 0 + stockSellInFlight = false + stockSellMessage = None bondTradeQuantity = "" bondTradeSeq = 0 bondTradeInFlight = false @@ -2072,6 +2117,13 @@ let private readStockPositionsCommand token fundId requestId = (fun positions -> StockPositionsReadCompleted(requestId, fundId, positions)) (fun error -> StockPositionsReadFailed(requestId, fundId, errorText error)) +let private createStockSellCommand token fundId payload requestId = + Cmd.OfPromise.either + (fun () -> Api.createStockSell token fundId payload) + () + (fun sell -> StockSellCompleted(requestId, fundId, payload.instrumentCode, sell)) + (fun error -> StockSellFailed(requestId, fundId, payload.instrumentCode, errorText error)) + let private createBondTradeCommand token fundId payload requestId = Cmd.OfPromise.either (fun () -> Api.createBondTrade token fundId payload) @@ -3616,6 +3668,61 @@ let update message model = Cmd.none else model, Cmd.none + | StockSellQuantityChanged (code, value) -> + { model with stockSellQuantities = Map.add code value model.stockSellQuantities }, Cmd.none + | StockSellRequested code -> + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) -> + let quantity = (model.stockSellQuantities |> Map.tryFind code |> Option.defaultValue "").Trim() + + if not (isValidQuantityText quantity) then + { model with stockSellMessage = Some "卖出数量必须是大于零的整数股数,例如 100" }, Cmd.none + else + let requestId = model.stockSellSeq + 1 + + let idempotencyKey = + match model.stockSellAttempts |> Map.tryFind code with + | Some attempt when attempt.quantity = quantity -> attempt.idempotencyKey + | _ -> Guid.NewGuid().ToString("N") + + let payload : CreateStockSellPayload = + { idempotencyKey = idempotencyKey; instrumentCode = code; quantity = quantity } + + let attempt : StockSellAttempt = + { idempotencyKey = idempotencyKey; instrumentCode = code; quantity = quantity } + + { + model with + stockSellSeq = requestId + stockSellInFlight = true + stockSellMessage = None + stockSellAttempts = Map.add code attempt model.stockSellAttempts + }, + createStockSellCommand model.token fund.id payload requestId + | _ when String.IsNullOrWhiteSpace model.token -> + { model with stockSellMessage = Some "请输入 API token" }, Cmd.none + | _ -> + { model with stockSellMessage = Some "请先创建一个基金" }, Cmd.none + | StockSellCompleted (requestId, fundId, code, sell) -> + if requestId = model.stockSellSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { + model with + stockSellInFlight = false + stockSellMessage = + Some(sprintf "已卖出 %s %s 股:成交价 %s,回收现金 %s" code sell.quantity sell.price sell.proceeds) + stockSellQuantities = Map.remove code model.stockSellQuantities + stockSellAttempts = Map.remove code model.stockSellAttempts + }, + Cmd.batch [ Cmd.ofMsg StockPositionsReadRequested; Cmd.ofMsg ValuationReadRequested ] + else + model, Cmd.none + | StockSellFailed (requestId, fundId, code, message) -> + if requestId = model.stockSellSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { model with stockSellInFlight = false; stockSellMessage = Some(sprintf "卖出失败 — %s" message) }, Cmd.none + else + model, Cmd.none | BondTradeQuantityChanged value -> { model with bondTradeQuantity = value }, Cmd.none | BondTradeRequested -> let code = model.bondCode.Trim() @@ -5788,6 +5895,8 @@ let private stocksPanel model dispatch = Html.span [ prop.className "fund-detail-label"; prop.text "名称" ] Html.span [ prop.className "fund-detail-label"; prop.text "数量" ] Html.span [ prop.className "fund-detail-label"; prop.text "成本" ] + Html.span [ prop.className "fund-detail-label"; prop.text "卖出数量" ] + Html.span [ prop.className "fund-detail-label"; prop.text "操作" ] ] ] :: (model.stockPositions @@ -5799,6 +5908,18 @@ let private stocksPanel model dispatch = Html.span [ prop.className "fund-detail-value"; prop.text (position.stockName |> Option.defaultValue "—") ] Html.span [ prop.className "fund-detail-value"; prop.text position.quantity ] Html.span [ prop.className "fund-detail-value"; prop.text position.costCash ] + Html.input [ + prop.className "text-input stock-sell-quantity-input" + prop.placeholder "卖出数量" + prop.value (model.stockSellQuantities |> Map.tryFind position.instrumentCode |> Option.defaultValue "") + prop.onChange (fun value -> dispatch (StockSellQuantityChanged(position.instrumentCode, value))) + ] + Html.button [ + prop.className "secondary-action stock-sell-action" + prop.disabled model.stockSellInFlight + prop.onClick (fun _ -> dispatch (StockSellRequested position.instrumentCode)) + prop.text ((if model.stockSellInFlight then "卖出中..." else "卖出"): string) + ] ] ])) ) @@ -5808,6 +5929,11 @@ let private stocksPanel model dispatch = prop.hidden (model.stockPositionsMessage.IsNone) prop.text (model.stockPositionsMessage |> Option.defaultValue "") ] + Html.p [ + prop.className "stock-sell-message" + prop.hidden (model.stockSellMessage.IsNone) + prop.text (model.stockSellMessage |> Option.defaultValue "") + ] Html.div [ prop.className "fund-valuation" prop.children [ diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js index eaa8978..3947688 100644 --- a/src/FundLab.Web/src/api.js +++ b/src/FundLab.Web/src/api.js @@ -159,6 +159,18 @@ export function createStockTrade(token, fundId, payload) { }); } +export function createStockSell(token, fundId, payload) { + const body = `{"instrumentCode":${JSON.stringify(payload.instrumentCode)},"quantity":${JSON.stringify(payload.quantity)}}`; + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/stock-sells`, token, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": payload.idempotencyKey + }, + body + }); +} + export function getStockPositions(token, fundId) { return requestJson(`/api/funds/${encodeURIComponent(fundId)}/stock-positions`, token); } diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css index dbd53ed..8aee2d2 100644 --- a/src/FundLab.Web/src/styles.css +++ b/src/FundLab.Web/src/styles.css @@ -648,13 +648,22 @@ h2 { .stock-positions-row { display: grid; - grid-template-columns: 1fr 1.4fr 1fr 1fr; + grid-template-columns: 1fr 1.4fr 1fr 1fr 1.1fr 0.9fr; gap: 8px; align-items: center; padding: 4px 0; border-bottom: 1px solid rgba(148, 163, 184, 0.2); } +.stock-sell-quantity-input { + min-width: 0; +} + +.stock-sell-message { + color: #0f766e; + font-size: 12px; +} + .returns-pending { color: #b45309; font-size: 12px; |
