diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 01:40:10 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 01:40:10 +0800 |
| commit | be1afd248e4cc9b03a1e21090602a945af6988b2 (patch) | |
| tree | d31bd6e6c1736ab4ad784b52ebc5010ade074439 /src/FundLab.Web | |
| parent | 2ada70d6467aec11b45328112d598454ca50f2d6 (diff) | |
| download | fund-lab-be1afd248e4cc9b03a1e21090602a945af6988b2.tar.gz | |
feat(app): 接入 AKShare 行情采集、净值查询与前端净值曲线
[变更性质]
- 本提交完成 3b 行情闭环:真实 AKShare 搜索与净值采集、PostgreSQL 持久化、鉴权 API 与 Fable 前端净值曲线展示。
[新增功能]
- API 新增 /api/instruments/search、/api/instruments/{code}/nav 与 nav/refresh 接口,经 Bearer 鉴权调用 AKShare Python 采集器并幂等落库。
- 前端提供基金搜索、精确代码选择、历史净值刷新/重读与 SVG 折线图,token 变更即清空私有结果并失效在途请求。
[实现方案]
- MarketData 以 requiredProperty 校验采集器 payload,净值观测按 (code, nav_date) 幂等 upsert 并保留来源与哈希。
- 前端以 requestId 序列守卫 SearchCompleted/SearchFailed/NavCompleted/NavFailed,TokenChanged 同时递增 searchSeq/navSeq 拒绝过期响应;边界解码兼容 F# option 的 {"case":"Some"} 线格式。
[影响范围]
- 新增 FundLab.Web.Tests(边界解码、序列失效、图表纯函数共 8 项),扩展 API 测试至 20 项;vite dev 代理 /api 至本地 API。
- 真实 AKShare 端到端依赖本机 akshare 环境,由负责人另行验收;本提交不包含 docs/overnight-progress.md 的现有修改。
Diffstat (limited to 'src/FundLab.Web')
| -rw-r--r-- | src/FundLab.Web/App.fs | 607 | ||||
| -rw-r--r-- | src/FundLab.Web/index.html | 2 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 38 | ||||
| -rw-r--r-- | src/FundLab.Web/src/main.js | 1 | ||||
| -rw-r--r-- | src/FundLab.Web/src/styles.css | 501 | ||||
| -rw-r--r-- | src/FundLab.Web/vite.config.js | 8 |
6 files changed, 1142 insertions, 15 deletions
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index 630a8e9..0ee4d56 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -1,41 +1,620 @@ module FundLab.Web +open System +open System.Globalization open Elmish open Elmish.React open Feliz +open Fable.Core + +type Instrument = + { + code: string + name: string + fundType: string option + } + +type NavObservation = + { + navDate: string + nav: string + dailyReturn: string option + } + +type ChartPoint = + { + x: float + y: float + navDate: string + nav: string + } + +module Chart = + let private invariant = CultureInfo.InvariantCulture + + let private navValue (observation: NavObservation) = + Decimal.Parse(observation.nav, NumberStyles.Float, invariant) + + let chartPoints (observations: NavObservation list) = + let ordered: NavObservation list = observations |> List.sortBy (fun observation -> observation.navDate) + let values = ordered |> List.map navValue + + match values with + | [] -> [] + | _ -> + let minimum = List.min values + let maximum = List.max values + let range = maximum - minimum + let count = List.length ordered + + ordered + |> List.mapi (fun index observation -> + let value = navValue observation + let x = + if count = 1 then + 0.5 + else + float index / float (count - 1) + + let y = + if range = 0M then + 0.5 + else + float ((value - minimum) / range) + + { + x = x + y = y + navDate = observation.navDate + nav = observation.nav + }) + +type RawOptionalText = + { + case: string + fields: string array + } + +type RawInstrument = + { + code: string + name: string + fundType: obj + } + +type RawSearchResponse = + { + instruments: RawInstrument array + } + +type RawObservation = + { + navDate: string + nav: string + dailyReturn: obj + } + +type RawNavResponse = + { + observations: RawObservation array + } + +module Api = + [<Import("searchInstruments", "./src/api.js")>] + let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative + + [<Import("getNav", "./src/api.js")>] + let getNav (token: string) (code: string) : JS.Promise<RawNavResponse> = jsNative + + [<Import("refreshNav", "./src/api.js")>] + let refreshNav (token: string) (code: string) : JS.Promise<RawNavResponse> = jsNative + + let decodeOptionalText (raw: obj) : string option = + if isNull raw then + None + else + let envelope = unbox<RawOptionalText> raw + + if isNull (box envelope.fields) || envelope.fields.Length = 0 then + None + else + Some envelope.fields[0] + + let decodeInstrument (raw: RawInstrument) : Instrument = + { + code = raw.code + name = raw.name + fundType = decodeOptionalText raw.fundType + } + + let decodeSearch (raw: RawSearchResponse) : Instrument list = + raw.instruments |> Array.map decodeInstrument |> List.ofArray + + let decodeObservation (raw: RawObservation) : NavObservation = + { + navDate = raw.navDate + nav = raw.nav + dailyReturn = decodeOptionalText raw.dailyReturn + } + + let decodeNav (raw: RawNavResponse) : NavObservation list = + raw.observations |> Array.map decodeObservation |> List.ofArray type Model = { - hasFund: bool + token: string + query: string + searchSeq: int + navSeq: int + searchResults: Instrument list + selected: Instrument option + observations: NavObservation list + searchInFlight: bool + navInFlight: bool + error: string option } type Msg = - | CreateFundRequested + | TokenChanged of string + | QueryChanged of string + | SearchRequested + | SearchCompleted of requestId: int * instruments: Instrument list + | SearchFailed of requestId: int * message: string + | InstrumentSelected of Instrument + | LoadNavRequested + | RefreshNavRequested + | NavCompleted of requestId: int * observations: NavObservation list + | NavFailed of requestId: int * message: string let init () = - { hasFund = false } + { + token = "" + query = "" + searchSeq = 0 + navSeq = 0 + searchResults = [] + selected = None + observations = [] + searchInFlight = false + navInFlight = false + error = None + } + +let private errorText (error: exn) = + if String.IsNullOrWhiteSpace error.Message then + "请求失败" + else + error.Message + +let private searchCommand token query requestId = + Cmd.OfPromise.either + (fun () -> Api.searchInstruments token query) + () + (fun raw -> SearchCompleted(requestId, Api.decodeSearch raw)) + (fun error -> SearchFailed(requestId, errorText error)) + +let private navCommand token code requestId = + Cmd.OfPromise.either + (fun () -> Api.getNav token code) + () + (fun raw -> NavCompleted(requestId, Api.decodeNav raw)) + (fun error -> NavFailed(requestId, errorText error)) + +let private refreshCommand token code requestId = + Cmd.OfPromise.either + (fun () -> Api.refreshNav token code) + () + (fun raw -> NavCompleted(requestId, Api.decodeNav raw)) + (fun error -> NavFailed(requestId, errorText error)) let update message model = match message with - | CreateFundRequested -> model + | TokenChanged token -> + { + model with + token = token + searchSeq = model.searchSeq + 1 + navSeq = model.navSeq + 1 + searchResults = [] + selected = None + observations = [] + searchInFlight = false + navInFlight = false + error = None + }, + Cmd.none + | QueryChanged query -> + { model with query = query; error = None }, Cmd.none + | SearchRequested -> + let query = model.query.Trim() + + if String.IsNullOrWhiteSpace model.token then + { model with error = Some "请输入 API token" }, Cmd.none + elif String.IsNullOrWhiteSpace query then + { model with error = Some "请输入基金名称或六位代码" }, Cmd.none + else + let requestId = model.searchSeq + 1 + + { + model with + query = query + searchSeq = requestId + searchInFlight = true + error = None + }, + searchCommand model.token query requestId + | SearchCompleted (requestId, instruments) -> + if requestId = model.searchSeq then + { + model with + searchResults = instruments + searchInFlight = false + error = None + }, + Cmd.none + else + model, Cmd.none + | SearchFailed (requestId, message) -> + if requestId = model.searchSeq then + { model with searchInFlight = false; error = Some message }, Cmd.none + else + model, Cmd.none + | InstrumentSelected instrument -> + if String.IsNullOrWhiteSpace model.token then + { model with error = Some "请输入 API token" }, Cmd.none + else + let requestId = model.navSeq + 1 + + { + model with + selected = Some instrument + observations = [] + navSeq = requestId + navInFlight = true + error = None + }, + navCommand model.token instrument.code requestId + | LoadNavRequested -> + match model.selected with + | Some instrument when not (String.IsNullOrWhiteSpace model.token) -> + let requestId = model.navSeq + 1 + + { + model with + navSeq = requestId + navInFlight = true + error = None + }, + navCommand model.token instrument.code requestId + | Some _ -> + { model with error = Some "请输入 API token" }, Cmd.none + | None -> + { model with error = Some "请先选择一个基金" }, Cmd.none + | RefreshNavRequested -> + match model.selected with + | Some instrument when not (String.IsNullOrWhiteSpace model.token) -> + let requestId = model.navSeq + 1 + + { + model with + navSeq = requestId + navInFlight = true + error = None + }, + refreshCommand model.token instrument.code requestId + | Some _ -> + { model with error = Some "请输入 API token" }, Cmd.none + | None -> + { model with error = Some "请先选择一个基金" }, Cmd.none + | NavCompleted (requestId, observations) -> + if requestId = model.navSeq then + { + model with + observations = observations |> List.sortBy (fun observation -> observation.navDate) + navInFlight = false + error = None + }, + Cmd.none + else + model, Cmd.none + | NavFailed (requestId, message) -> + if requestId = model.navSeq then + { model with navInFlight = false; error = Some message }, Cmd.none + else + model, Cmd.none + +let private navText (text: string) = + match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with + | true, value -> value.ToString("0.00000000", CultureInfo.InvariantCulture) + | false, _ -> text + +let private latestStats (observations: NavObservation list) = + let ordered: NavObservation list = observations |> List.sortBy (fun observation -> observation.navDate) + + match ordered with + | [] -> None + | ordered -> + let first = List.head ordered + let latest = List.last ordered + + Some( + navText latest.nav, + latest.dailyReturn |> Option.map (fun value -> value + "%"), + first.navDate, + latest.navDate + ) + +let private chartView (observations: NavObservation list) = + let points = Chart.chartPoints observations + + if List.isEmpty points then + Html.div [ prop.className "chart-empty"; prop.text "选择基金后加载历史净值" ] + else + let width = 960.0 + let height = 320.0 + let padding = 28.0 + let chartWidth = width - padding * 2.0 + let chartHeight = height - padding * 2.0 + + let coordinates = + points + |> List.map (fun point -> + padding + point.x * chartWidth, + padding + (1.0 - point.y) * chartHeight) + + let gridLines = + [ 0 .. 4 ] + |> List.map (fun index -> + let y = padding + float index / 4.0 * chartHeight + + Svg.line [ + svg.x1 padding + svg.y1 y + svg.x2 (width - padding) + svg.y2 y + svg.stroke "#e2e8f0" + svg.strokeWidth 1 + ]) + + let markers = + coordinates + |> List.mapi (fun index (x, y) -> + Svg.circle [ + svg.cx x + svg.cy y + svg.r 3.5 + svg.fill "#f59e0b" + ]) + + Html.div [ + prop.className "chart-wrap" + prop.children [ + Svg.svg [ + svg.className "nav-chart" + svg.viewBox (0, 0, 960, 320) + svg.children [ + yield! gridLines + Svg.polyline [ + svg.points coordinates + svg.fill "none" + svg.stroke "#1e40af" + svg.strokeWidth 3 + ] + yield! markers + ] + ] + Html.div [ + prop.className "chart-axis" + prop.children [ + Html.span [ prop.text (points |> List.head |> fun point -> point.navDate) ] + Html.span [ prop.text (points |> List.last |> fun point -> point.navDate) ] + ] + ] + ] + ] + +let private resultRow instrument dispatch = + Html.button [ + prop.className "result-row" + prop.onClick (fun _ -> dispatch (InstrumentSelected instrument)) + prop.children [ + Html.span [ prop.className "result-code"; prop.text instrument.code ] + Html.span [ prop.className "result-name"; prop.text instrument.name ] + Html.span [ prop.className "result-type"; prop.text (instrument.fundType |> Option.defaultValue "基金") ] + ] + ] + +let private searchPanel model dispatch = + Html.section [ + prop.className "panel search-panel" + prop.children [ + Html.div [ + prop.className "section-heading" + prop.children [ + Html.div [ + Html.p [ prop.className "eyebrow"; prop.text "01 / FIND" ] + Html.h2 "先锁定真实代码" + ] + Html.span [ prop.className "section-note"; prop.text "AKShare · exact match" ] + ] + ] + Html.div [ + prop.className "search-row" + prop.children [ + Html.input [ + prop.className "text-input search-input" + prop.placeholder "输入基金名称或六位代码" + prop.value model.query + prop.onChange (fun value -> dispatch (QueryChanged value)) + ] + Html.button [ + prop.className "primary-action" + prop.disabled model.searchInFlight + prop.onClick (fun _ -> dispatch SearchRequested) + prop.text (if model.searchInFlight then "查询中..." else "搜索") + ] + ] + ] + if List.isEmpty model.searchResults then + Html.p [ prop.className "hint"; prop.text "搜索结果会保留来源与代码,避免名称歧义。" ] + else + Html.div [ + prop.className "result-list" + prop.children (model.searchResults |> List.map (fun instrument -> resultRow instrument dispatch)) + ] + ] + ] + +let private navPanel model dispatch = + let selectedName = + model.selected + |> Option.map (fun instrument -> instrument.name) + |> Option.defaultValue "尚未选择基金" + + Html.section [ + prop.className "panel nav-panel" + prop.children [ + Html.div [ + prop.className "section-heading" + prop.children [ + Html.div [ + Html.p [ prop.className "eyebrow"; prop.text "02 / TRACE" ] + Html.h2 selectedName + ] + match model.selected with + | Some instrument -> + Html.div [ + prop.className "instrument-meta" + prop.children [ + Html.span [ prop.className "code-chip"; prop.text instrument.code ] + Html.span [ prop.className "section-note"; prop.text (instrument.fundType |> Option.defaultValue "基金") ] + ] + ] + | None -> Html.none + ] + ] + match latestStats model.observations with + | None -> + Html.div [ + prop.className "chart-placeholder" + prop.children [ + Html.div [ prop.className "placeholder-line" ] + Html.p "历史净值会在这里形成一条可追溯的曲线" + Html.span "数据来自已持久化的日频观察值" + ] + ] + | Some(latestNav, dailyReturn, fromDate, toDate) -> + Html.div [ + prop.className "nav-content" + prop.children [ + Html.div [ + prop.className "metric-strip" + prop.children [ + Html.div [ + prop.className "metric" + prop.children [ + Html.span "最新单位净值" + Html.strong latestNav + ] + ] + Html.div [ + prop.className "metric" + prop.children [ + Html.span "最新日收益" + Html.strong (dailyReturn |> Option.defaultValue "暂无") + ] + ] + Html.div [ + prop.className "metric" + prop.children [ + Html.span "观察区间" + Html.strong (sprintf "%s — %s" fromDate toDate) + ] + ] + ] + ] + chartView model.observations + ] + ] + Html.div [ + prop.className "panel-actions" + prop.children [ + Html.button [ + prop.className "secondary-action" + prop.disabled (model.navInFlight || model.selected.IsNone) + prop.onClick (fun _ -> dispatch LoadNavRequested) + prop.text "重新读取" + ] + Html.button [ + prop.className "primary-action" + prop.disabled (model.navInFlight || model.selected.IsNone) + prop.onClick (fun _ -> dispatch RefreshNavRequested) + prop.text (if model.navInFlight then "同步中..." else "刷新 AKShare") + ] + ] + ] + ] + ] let view model dispatch = Html.main [ - prop.className "empty-state" + prop.className "app-shell" prop.children [ - Html.p [ prop.className "eyebrow"; prop.text "FUND LAB" ] - Html.h1 "尚未创建基金" - Html.p "先创建一支人民币模拟基金,再选择基金代码和持仓金额。" - Html.p (if model.hasFund then "已创建基金" else "尚未选择投资") - Html.button [ - prop.className "primary-action" - prop.text "创建基金" - prop.onClick (fun _ -> dispatch CreateFundRequested) + Html.header [ + prop.className "topbar" + prop.children [ + Html.div [ + prop.className "brand" + prop.children [ + Html.span [ prop.className "brand-mark"; prop.text "F" ] + Html.span "FUND LAB" + ] + ] + Html.span [ prop.className "topbar-status"; prop.text "MARKET DATA / 3B" ] + ] + ] + Html.div [ + prop.className "hero" + prop.children [ + Html.div [ + prop.className "hero-copy" + prop.children [ + Html.p [ prop.className "eyebrow"; prop.text "人民币模拟基金 · 观察台" ] + Html.h1 "把净值曲线,先看清楚。" + Html.p "从真实基金代码开始,读取、校验并追踪每日单位净值。这里不下单,只保留可复核的数据。" + ] + ] + Html.div [ + prop.className "token-card" + prop.children [ + Html.label [ + prop.className "field-label" + prop.children [ + Html.span "API TOKEN" + Html.input [ + prop.className "text-input token-input" + prop.type' "password" + prop.placeholder "仅保存在当前页面内存" + prop.value model.token + prop.onChange (fun value -> dispatch (TokenChanged value)) + ] + ] + ] + Html.p [ prop.className "token-note"; prop.text "请求通过 Bearer header 发送,不写入构建产物。" ] + ] + ] + ] ] + match model.error with + | Some message -> Html.div [ prop.className "error-banner"; prop.text message ] + | None -> Html.none + searchPanel model dispatch + navPanel model dispatch + Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / MODE · READ-ONLY" ] ] ] let start () = - Program.mkSimple init update view + Program.mkProgram (fun () -> init (), Cmd.none) update view |> Program.withReactSynchronous "fund-lab-root" |> Program.run diff --git a/src/FundLab.Web/index.html b/src/FundLab.Web/index.html index 7b27019..d87ee2b 100644 --- a/src/FundLab.Web/index.html +++ b/src/FundLab.Web/index.html @@ -3,7 +3,7 @@ <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> - <title>Fund Lab</title> + <title>Fund Lab / Market Data</title> </head> <body> <div id="fund-lab-root"></div> diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js new file mode 100644 index 0000000..7d12d4d --- /dev/null +++ b/src/FundLab.Web/src/api.js @@ -0,0 +1,38 @@ +async function requestJson(path, token, options = {}) { + const response = await fetch(path, { + ...options, + headers: { + ...options.headers, + Authorization: `Bearer ${token}` + } + }); + + const body = await response.text(); + let payload = {}; + + if (body) { + try { + payload = JSON.parse(body); + } catch { + payload = {}; + } + } + + if (!response.ok) { + throw new Error(payload.message || `API request failed (${response.status})`); + } + + return payload; +} + +export function searchInstruments(token, query) { + return requestJson(`/api/instruments/search?q=${encodeURIComponent(query)}`, token); +} + +export function getNav(token, code) { + return requestJson(`/api/instruments/${encodeURIComponent(code)}/nav`, token); +} + +export function refreshNav(token, code) { + return requestJson(`/api/instruments/${encodeURIComponent(code)}/nav/refresh`, token, { method: "POST" }); +} diff --git a/src/FundLab.Web/src/main.js b/src/FundLab.Web/src/main.js index 57c83db..0048fef 100644 --- a/src/FundLab.Web/src/main.js +++ b/src/FundLab.Web/src/main.js @@ -1,3 +1,4 @@ import { start } from "../dist/App.js"; +import "./styles.css"; start(); diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css new file mode 100644 index 0000000..f895353 --- /dev/null +++ b/src/FundLab.Web/src/styles.css @@ -0,0 +1,501 @@ +:root { + color: #172033; + background: #f8fafc; + font-family: Inter, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + background: #f8fafc; +} + +button, +input { + font: inherit; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.app-shell { + min-height: 100vh; + background: + radial-gradient(circle at 82% 0%, rgba(59, 130, 246, 0.09), transparent 24rem), + #f8fafc; +} + +.topbar, +.hero, +.panel, +.footer-note, +.error-banner { + width: min(1120px, calc(100% - 48px)); + margin-inline: auto; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 28px 0 18px; + border-bottom: 1px solid #dbe4ef; +} + +.brand, +.instrument-meta, +.topbar-status, +.section-heading, +.search-row, +.panel-actions, +.metric-strip, +.chart-axis { + display: flex; + align-items: center; +} + +.brand { + gap: 10px; + color: #102a68; + font-size: 13px; + font-weight: 800; + letter-spacing: 0.15em; +} + +.brand-mark { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border-radius: 8px; + color: white; + background: #1e40af; + box-shadow: 0 6px 16px rgba(30, 64, 175, 0.24); +} + +.topbar-status, +.section-note, +.footer-note, +.token-note, +.hint, +.field-label, +.metric span, +.result-type, +.chart-axis { + color: #667085; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr); + gap: 56px; + align-items: end; + padding: 84px 0 58px; +} + +.hero-copy { + max-width: 680px; +} + +.eyebrow { + margin: 0 0 16px; + color: #1e40af; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +h1, +h2, +p { + margin-top: 0; +} + +h1 { + max-width: 650px; + margin-bottom: 20px; + color: #101828; + font-size: clamp(42px, 6vw, 74px); + font-weight: 750; + letter-spacing: -0.065em; + line-height: 0.98; +} + +.hero-copy > p:last-child { + max-width: 570px; + margin-bottom: 0; + color: #526074; + font-size: 16px; + line-height: 1.8; +} + +.token-card, +.panel { + border: 1px solid #dbe4ef; + background: rgba(255, 255, 255, 0.82); + box-shadow: 0 20px 60px rgba(30, 64, 175, 0.06); +} + +.token-card { + padding: 22px; + border-radius: 16px; +} + +.field-label { + display: grid; + gap: 10px; + font-weight: 800; +} + +.text-input { + width: 100%; + border: 1px solid #cbd5e1; + border-radius: 9px; + outline: none; + color: #172033; + background: #fff; + transition: border-color 140ms ease, box-shadow 140ms ease; +} + +.text-input:focus { + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); +} + +.token-input, +.search-input { + padding: 13px 14px; +} + +.token-note { + margin: 12px 0 0; + font-size: 10px; + line-height: 1.6; + text-transform: none; + letter-spacing: 0; +} + +.error-banner { + margin-bottom: 20px; + padding: 14px 16px; + border: 1px solid #f1b6b6; + border-radius: 10px; + color: #9b1c1c; + background: #fff4f4; + font-size: 13px; +} + +.panel { + margin-bottom: 20px; + padding: 30px; + border-radius: 18px; +} + +.section-heading { + justify-content: space-between; + gap: 20px; + margin-bottom: 25px; +} + +.section-heading .eyebrow { + margin-bottom: 8px; +} + +h2 { + margin-bottom: 0; + color: #172033; + font-size: clamp(22px, 3vw, 32px); + letter-spacing: -0.04em; +} + +.search-row { + gap: 12px; +} + +.search-input { + flex: 1; + min-width: 0; +} + +.primary-action, +.secondary-action { + min-height: 46px; + padding: 0 20px; + border-radius: 9px; + font-size: 13px; + font-weight: 750; + transition: transform 140ms ease, box-shadow 140ms ease, background 140ms ease; +} + +.primary-action { + border: 1px solid #1e40af; + color: white; + background: #1e40af; + box-shadow: 0 8px 18px rgba(30, 64, 175, 0.2); +} + +.primary-action:hover:not(:disabled) { + background: #18358f; + box-shadow: 0 12px 24px rgba(30, 64, 175, 0.25); + transform: translateY(-1px); +} + +.secondary-action { + border: 1px solid #cbd5e1; + color: #334155; + background: white; +} + +.secondary-action:hover:not(:disabled) { + border-color: #94a3b8; + background: #f8fafc; +} + +.hint { + margin: 16px 0 0; + font-size: 11px; + text-transform: none; + letter-spacing: 0; +} + +.result-list { + display: grid; + gap: 8px; + margin-top: 20px; +} + +.result-row { + display: grid; + grid-template-columns: 100px minmax(0, 1fr) 80px; + gap: 14px; + align-items: center; + width: 100%; + padding: 14px 16px; + border: 1px solid #e2e8f0; + border-radius: 10px; + text-align: left; + background: #fff; + transition: border-color 140ms ease, transform 140ms ease; +} + +.result-row:hover { + border-color: #3b82f6; + transform: translateX(2px); +} + +.result-code, +.code-chip { + color: #1e40af; + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.04em; +} + +.result-name { + overflow: hidden; + color: #1f2937; + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.result-type { + overflow: hidden; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; + text-transform: none; + letter-spacing: 0; +} + +.instrument-meta { + gap: 14px; +} + +.code-chip { + padding: 7px 9px; + border-radius: 6px; + background: #eff6ff; +} + +.chart-placeholder { + min-height: 270px; + display: grid; + align-content: center; + justify-items: center; + border: 1px dashed #cbd5e1; + border-radius: 12px; + color: #667085; + text-align: center; +} + +.placeholder-line { + width: min(420px, 80%); + height: 70px; + margin-bottom: 20px; + border-top: 2px solid #bfdbfe; + border-radius: 50%; + transform: rotate(-7deg); +} + +.chart-placeholder p { + margin-bottom: 8px; + color: #334155; + font-size: 15px; +} + +.chart-placeholder span { + font-size: 12px; +} + +.nav-content { + display: grid; + gap: 24px; +} + +.metric-strip { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1px; + overflow: hidden; + border: 1px solid #e2e8f0; + border-radius: 11px; + background: #e2e8f0; +} + +.metric { + display: grid; + gap: 8px; + min-width: 0; + padding: 16px; + background: #fff; +} + +.metric strong { + overflow: hidden; + color: #172033; + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chart-wrap { + overflow: hidden; + padding: 18px 14px 0; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: linear-gradient(180deg, #fff, #f8fbff); +} + +.nav-chart { + display: block; + width: 100%; + height: 320px; +} + +.chart-axis { + justify-content: space-between; + padding: 10px 6px 13px; + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 10px; + letter-spacing: 0; + text-transform: none; +} + +.panel-actions { + justify-content: flex-end; + gap: 10px; + margin-top: 24px; +} + +.footer-note { + padding: 16px 0 34px; + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 9px; + text-align: right; +} + +@media (max-width: 760px) { + .topbar, + .hero, + .panel, + .footer-note, + .error-banner { + width: min(100% - 28px, 1120px); + } + + .hero { + grid-template-columns: 1fr; + gap: 32px; + padding: 52px 0 34px; + } + + h1 { + font-size: clamp(42px, 14vw, 62px); + } + + .panel { + padding: 20px; + } + + .section-heading { + align-items: flex-start; + flex-direction: column; + } + + .search-row { + align-items: stretch; + flex-direction: column; + } + + .search-row .primary-action { + width: 100%; + } + + .result-row { + grid-template-columns: 82px minmax(0, 1fr); + } + + .result-type { + display: none; + } + + .instrument-meta { + align-self: stretch; + } + + .metric-strip { + grid-template-columns: 1fr; + } + + .panel-actions { + align-items: stretch; + flex-direction: column-reverse; + } + + .panel-actions button { + width: 100%; + } + + .footer-note { + text-align: left; + } +} diff --git a/src/FundLab.Web/vite.config.js b/src/FundLab.Web/vite.config.js index 5e01572..21680e0 100644 --- a/src/FundLab.Web/vite.config.js +++ b/src/FundLab.Web/vite.config.js @@ -4,5 +4,13 @@ export default defineConfig({ build: { outDir: "dist", emptyOutDir: true + }, + server: { + proxy: { + "/api": { + target: process.env.FUND_LAB_API_TARGET || "http://127.0.0.1:5099", + changeOrigin: true + } + } } }); |
