1- """FastAPI 应用主体(v0.13 #57 轻量 Web UI)。"""
1+ """FastAPI 应用主体(v0.13 #57 轻量 Web UI)。
2+
3+ v0.13.2:
4+ - 修 mermaid:把 <pre><code class="language-mermaid"> 转成 <div class="mermaid"> +
5+ 解码 HTML 实体(" → "),让浏览器端 mermaid.js 正常渲染 xychart
6+ - 加 watch CRUD:在 watchlist 页面表单 + POST endpoints
7+ """
28from __future__ import annotations
39
10+ import html as html_lib
11+ import re
412from pathlib import Path
13+ from typing import Optional
514
615try :
7- from fastapi import FastAPI , Request , HTTPException
16+ from fastapi import FastAPI , Form , HTTPException
817 from fastapi .responses import HTMLResponse , RedirectResponse
9- from fastapi .staticfiles import StaticFiles
1018except ImportError : # pragma: no cover
1119 FastAPI = None
1220
2230)
2331
2432
33+ def _markdown_to_html (md : str ) -> str :
34+ """Markdown → HTML,并把 mermaid 代码块改为浏览器可渲染的 div.mermaid。"""
35+ try :
36+ import markdown
37+ except ImportError :
38+ return f"<pre>{ html_lib .escape (md )} </pre>"
39+ html = markdown .markdown (md , extensions = ["tables" , "fenced_code" ])
40+
41+ # mermaid 代码块:markdown 库会输出 <pre><code class="language-mermaid">...</code></pre>
42+ # 但 mermaid.js 需要 <div class="mermaid">原始代码</div>,且不能 HTML 实体编码
43+ def _fix_mermaid (m ):
44+ inner = m .group (1 )
45+ # 解码 HTML 实体(" → " 等)
46+ inner = html_lib .unescape (inner )
47+ return f'<div class="mermaid">{ inner } </div>'
48+
49+ html = re .sub (
50+ r'<pre><code class="language-mermaid">(.*?)</code></pre>' ,
51+ _fix_mermaid , html , flags = re .DOTALL ,
52+ )
53+ return html
54+
55+
2556def _create_app ():
2657 if FastAPI is None :
2758 raise RuntimeError ("fastapi 未安装:pip install fastapi uvicorn" )
28- app = FastAPI (title = "stockwise Web UI" , version = "0.13.0 " )
59+ app = FastAPI (title = "stockwise Web UI" , version = "0.13.2 " )
2960
3061 @app .get ("/" , response_class = HTMLResponse )
31- async def watchlist_page ():
62+ async def watchlist_page (msg : Optional [ str ] = None , err : Optional [ str ] = None ):
3263 wl = Watchlist .load ()
33- # 计算简单统计
3464 holdings = [i for i in wl .items if i .buy_price and i .shares ]
3565 total_cost = sum (i .buy_price * i .shares for i in holdings )
3666 tpl = _env .get_template ("watchlist.html" )
37- return tpl .render (items = wl .items , holdings = holdings , total_cost = total_cost )
67+ return tpl .render (items = wl .items , holdings = holdings ,
68+ total_cost = total_cost , msg = msg , err = err )
69+
70+ @app .post ("/watch/add" )
71+ async def watch_add (code : str = Form (...), market : str = Form ("A" ),
72+ price : Optional [float ] = Form (None ),
73+ shares : Optional [int ] = Form (None )):
74+ code = code .strip ()
75+ market = market .upper ()
76+ if not code :
77+ return RedirectResponse ("/?err=代码不能为空" , status_code = 303 )
78+ wl = Watchlist .load ()
79+ if wl .add (code , market , buy_price = price , shares = shares ):
80+ wl .save ()
81+ return RedirectResponse (f"/?msg=已添加 { code } ({ market } )" , status_code = 303 )
82+ return RedirectResponse (f"/?err={ code } 已存在 (用编辑而非添加)" , status_code = 303 )
83+
84+ @app .post ("/watch/set/{code}" )
85+ async def watch_set (code : str ,
86+ price : Optional [float ] = Form (None ),
87+ shares : Optional [int ] = Form (None )):
88+ wl = Watchlist .load ()
89+ if wl .update_holding (code , buy_price = price , shares = shares ):
90+ wl .save ()
91+ return RedirectResponse (f"/?msg=已更新 { code } " , status_code = 303 )
92+ return RedirectResponse (f"/?err=未找到 { code } " , status_code = 303 )
93+
94+ @app .post ("/watch/remove/{code}" )
95+ async def watch_remove (code : str ):
96+ wl = Watchlist .load ()
97+ if wl .remove (code ):
98+ wl .save ()
99+ return RedirectResponse (f"/?msg=已删除 { code } " , status_code = 303 )
100+ return RedirectResponse (f"/?err=未找到 { code } " , status_code = 303 )
38101
39102 @app .get ("/stock/{code}" , response_class = HTMLResponse )
40103 async def stock_page (code : str ):
41- # 读取最新报告(按文件命名约定 reports/CODE_*_YYYY-MM-DD.md)
42- from datetime import date
43104 reports_dir = Path ("reports" )
44105 if not reports_dir .exists ():
45106 raise HTTPException (404 , "无报告目录" )
46- # 找匹配的最近一个 md
47107 candidates = sorted (reports_dir .glob (f"{ code } _*.md" ), reverse = True )
48108 if not candidates :
49109 return HTMLResponse (
@@ -52,28 +112,23 @@ async def stock_page(code: str):
52112 status_code = 404 ,
53113 )
54114 md = candidates [0 ].read_text (encoding = "utf-8" )
55- # 简单 Markdown → HTML(用 mistune 或 markdown)
56- try :
57- import markdown
58- html = markdown .markdown (md , extensions = ["tables" , "fenced_code" ])
59- except ImportError :
60- html = f"<pre>{ md } </pre>"
115+ html_content = _markdown_to_html (md )
61116 tpl = _env .get_template ("stock.html" )
62- return tpl .render (code = code , html_content = html , file_name = candidates [0 ].name )
117+ return tpl .render (code = code , html_content = html_content ,
118+ file_name = candidates [0 ].name )
63119
64120 @app .get ("/portfolio" , response_class = HTMLResponse )
65121 async def portfolio_page ():
66122 wl = Watchlist .load ()
67123 holdings = [i for i in wl .items if i .buy_price and i .shares ]
68124 if not holdings :
69125 return HTMLResponse (
70- "<h1>无持仓</h1><p>用 <code>stockwise watch add CODE --price X --shares Y</code> 添加 。</p>"
126+ "<h1>无持仓</h1><p>回 <a href='/'>watchlist</a> 添加买入价 / 股数 。</p>"
71127 )
72128 total_cost = sum (i .buy_price * i .shares for i in holdings )
73- # 行业分布(简化:从 last 状态推断)
74129 industry_count = {}
75130 for h in holdings :
76- ind = h .last_rating or "未知" # 简化版只看评级
131+ ind = h .last_rating or "未知"
77132 industry_count [ind ] = industry_count .get (ind , 0 ) + 1
78133 tpl = _env .get_template ("portfolio.html" )
79134 return tpl .render (holdings = holdings , total_cost = total_cost ,
0 commit comments