Skip to content

Commit ba7af08

Browse files
v0.13.2: Web UI 修两处用户反馈的可用性问题
1. mermaid 折线图浏览器无法渲染: - markdown 库把 ```mermaid 块输出为 <pre><code class="language-mermaid"> 里面 " 还被转义成 &quot;,mermaid.js 既找不到 div.mermaid 也无法解析 - 修:_markdown_to_html 用正则把 <pre><code class="language-mermaid">...</code></pre> 转成 <div class="mermaid">...</div>,并用 html.unescape() 解码实体 - 验证:茅台报告页 1 个 div.mermaid 块,内容为原始 xychart-beta 无实体 2. 持仓股不能在 Web UI 增/删/改: - 添加 3 个 POST endpoints: POST /watch/add (code/market/price/shares) POST /watch/set/{code} (price/shares) POST /watch/remove/{code} - watchlist.html 顶部加添加表单(代码/市场/买入价/股数) - 每行加内联编辑(价格+股数输入框 + 保存按钮)+ 删除按钮(带确认) - 操作后用 303 重定向 + ?msg=/?err= 显示提示 - 验证:POST /watch/set/600276 (price=55.5, shares=400) → 303 watchlist.json 持久化生效 pyproject [web] 加 python-multipart>=0.0.6(FastAPI Form 依赖) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8298bd0 commit ba7af08

5 files changed

Lines changed: 163 additions & 34 deletions

File tree

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[project]
22
name = "stockwise"
3-
version = "0.13.1"
4-
description = "巴菲特/林奇范式 A 股 + 港股价值投资分析工具:Web UI + HKEx 链接 + 主营构成 + mkdocs 文档"
3+
version = "0.13.2"
4+
description = "巴菲特/林奇范式 A 股 + 港股价值投资分析工具:Web UI 增删改 + mermaid 浏览器渲染修复"
55
license = { text = "MIT" }
66
requires-python = ">=3.10"
77
dependencies = [
@@ -18,7 +18,7 @@ dependencies = [
1818

1919
[project.optional-dependencies]
2020
dev = ["pytest>=8.0"]
21-
web = ["fastapi>=0.110", "uvicorn>=0.27", "markdown>=3.5"]
21+
web = ["fastapi>=0.110", "uvicorn>=0.27", "markdown>=3.5", "python-multipart>=0.0.6"]
2222

2323
[project.scripts]
2424
stockwise = "stockwise.cli:main"

stockwise/web/__main__.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
"""启动 Web UI: python -m stockwise.web"""
1+
"""启动 Web UI: python -m stockwise.web [--port 8001] [--host 0.0.0.0]"""
22
from __future__ import annotations
33

4+
import argparse
45
import sys
56

67

@@ -10,8 +11,17 @@ def main():
1011
except ImportError:
1112
print("缺依赖:pip install fastapi uvicorn", file=sys.stderr)
1213
sys.exit(1)
14+
15+
parser = argparse.ArgumentParser(description="stockwise Web UI")
16+
parser.add_argument("--port", type=int, default=8001,
17+
help="端口(默认 8001 避开常见 8000 占用)")
18+
parser.add_argument("--host", default="127.0.0.1",
19+
help="监听地址(默认 127.0.0.1;用 0.0.0.0 暴露给局域网)")
20+
args = parser.parse_args()
21+
1322
from stockwise.web.app import app
14-
uvicorn.run(app, host="127.0.0.1", port=8000, log_level="info")
23+
print(f"\n ✨ stockwise Web UI 启动中 http://{args.host}:{args.port}/\n")
24+
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
1525

1626

1727
if __name__ == "__main__":

stockwise/web/app.py

Lines changed: 75 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
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 实体(&quot; → "),让浏览器端 mermaid.js 正常渲染 xychart
6+
- 加 watch CRUD:在 watchlist 页面表单 + POST endpoints
7+
"""
28
from __future__ import annotations
39

10+
import html as html_lib
11+
import re
412
from pathlib import Path
13+
from typing import Optional
514

615
try:
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
1018
except ImportError: # pragma: no cover
1119
FastAPI = None
1220

@@ -22,28 +30,80 @@
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 实体(&quot; → " 等)
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+
2556
def _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,

stockwise/web/templates/_base.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
code { background: #f3f4f6; padding: 2px 6px; border-radius: 3px; }
2222
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px;
2323
font-size: 0.85em; background: #e0e7ff; color: #4338ca; }
24+
button { font-family: inherit; }
2425
</style>
2526
</head>
2627
<body>
Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,53 @@
11
{% extends "_base.html" %}
22
{% block title %}watchlist · stockwise{% endblock %}
33
{% block content %}
4+
<style>
5+
.msg { padding: 10px 16px; border-radius: 6px; margin: 10px 0; }
6+
.msg.ok { background: #dcfce7; color: #166534; border: 1px solid #86efac; }
7+
.msg.err { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
8+
form.add-form { display: flex; gap: 8px; align-items: end; flex-wrap: wrap;
9+
margin: 16px 0; padding: 14px; background: #f9fafb;
10+
border-radius: 8px; border: 1px solid #e5e7eb; }
11+
form.add-form input { padding: 6px 10px; border: 1px solid #d1d5db;
12+
border-radius: 4px; font-size: 0.95em; }
13+
form.add-form label { display: flex; flex-direction: column; gap: 2px;
14+
font-size: 0.85em; color: #6b7280; }
15+
form.add-form button { padding: 7px 16px; background: #4f46e5; color: white;
16+
border: none; border-radius: 4px; cursor: pointer;
17+
font-size: 0.95em; }
18+
form.add-form button:hover { background: #4338ca; }
19+
.edit-row { display: flex; gap: 4px; align-items: center; }
20+
.edit-row input { width: 80px; padding: 3px 6px; font-size: 0.85em;
21+
border: 1px solid #d1d5db; border-radius: 3px; }
22+
.btn-small { padding: 3px 8px; font-size: 0.8em; border: 1px solid #d1d5db;
23+
background: white; border-radius: 3px; cursor: pointer; }
24+
.btn-danger { color: #dc2626; border-color: #fca5a5; }
25+
.btn-danger:hover { background: #fee2e2; }
26+
.btn-primary { color: #4f46e5; border-color: #a5b4fc; }
27+
.btn-primary:hover { background: #e0e7ff; }
28+
</style>
29+
430
<h1>📋 watchlist <span class="badge">{{ items | length }} 只</span></h1>
531

32+
{% if msg %}<div class="msg ok">✓ {{ msg }}</div>{% endif %}
33+
{% if err %}<div class="msg err">⚠ {{ err }}</div>{% endif %}
34+
35+
<!-- 添加新标的表单 -->
36+
<form class="add-form" method="post" action="/watch/add">
37+
<label>代码 <input name="code" placeholder="600519" required></label>
38+
<label>市场
39+
<select name="market" style="padding: 6px 10px;">
40+
<option value="A">A 股</option>
41+
<option value="HK">港股</option>
42+
</select>
43+
</label>
44+
<label>买入价 <input name="price" type="number" step="0.01" placeholder="可选"></label>
45+
<label>股数 <input name="shares" type="number" step="1" placeholder="可选"></label>
46+
<button type="submit">+ 添加</button>
47+
</form>
48+
649
{% if not items %}
7-
<p>watchlist 为空。命令行运行 <code>stockwise watch add CODE</code> 添加</p>
50+
<p>watchlist 为空。用上方表单添加,或命令行 <code>stockwise watch add CODE</code></p>
851
{% else %}
952
<table>
1053
<thead>
@@ -14,10 +57,10 @@ <h1>📋 watchlist <span class="badge">{{ items | length }} 只</span></h1>
1457
<th>名称</th>
1558
<th>评级</th>
1659
<th class="num">得分</th>
17-
<th>安全边际</th>
1860
<th>买入价</th>
1961
<th class="num">股数</th>
20-
<th>行动建议</th>
62+
<th>持仓 / 浮亏</th>
63+
<th>操作</th>
2164
</tr>
2265
</thead>
2366
<tbody>
@@ -28,18 +71,38 @@ <h1>📋 watchlist <span class="badge">{{ items | length }} 只</span></h1>
2871
<td>{{ i.name or '—' }}</td>
2972
<td>{{ i.last_rating or '—' }}</td>
3073
<td class="num">{{ i.last_score or '—' }}</td>
31-
<td>{{ i.last_margin or '—' }}</td>
32-
<td>{% if i.buy_price %}¥{{ "%.2f" | format(i.buy_price) }}{% else %}—{% endif %}</td>
33-
<td class="num">{{ i.shares or '—' }}</td>
34-
<td>{{ i.last_action or '—' }}</td>
74+
<!-- 内联编辑买入价 / 股数 -->
75+
<td colspan="2">
76+
<form class="edit-row" method="post" action="/watch/set/{{ i.code }}">
77+
<input name="price" type="number" step="0.01"
78+
value="{{ '%.2f'|format(i.buy_price) if i.buy_price else '' }}"
79+
placeholder="买入价">
80+
<input name="shares" type="number" step="1"
81+
value="{{ i.shares or '' }}" placeholder="股数">
82+
<button type="submit" class="btn-small btn-primary">保存</button>
83+
</form>
84+
</td>
85+
<td>
86+
{% if i.buy_price and i.shares %}
87+
¥{{ '{:,.0f}'.format(i.buy_price * i.shares) }}
88+
{% else %}—{% endif %}
89+
</td>
90+
<td>
91+
<form method="post" action="/watch/remove/{{ i.code }}"
92+
onsubmit="return confirm('确定删除 {{ i.code }} ({{ i.name or '' }})?')">
93+
<button type="submit" class="btn-small btn-danger">删除</button>
94+
</form>
95+
</td>
3596
</tr>
3697
{% endfor %}
3798
</tbody>
3899
</table>
39100

40101
{% if holdings %}
41-
<p><strong>{{ holdings | length }} 只持仓 · 总成本 ¥{{ "{:,.0f}".format(total_cost) }}</strong>
42-
<a href="/portfolio">详细组合视角 →</a></p>
102+
<p style="margin-top: 16px;">
103+
<strong>{{ holdings | length }} 只持仓 · 总成本 ¥{{ "{:,.0f}".format(total_cost) }}</strong>
104+
&nbsp;&nbsp;<a href="/portfolio">详细组合视角 →</a>
105+
</p>
43106
{% endif %}
44107
{% endif %}
45108
{% endblock %}

0 commit comments

Comments
 (0)