feat: bootstrap personal stock agent

This commit is contained in:
2026-05-11 21:10:55 +08:00
commit 437565c16c
21 changed files with 5014 additions and 0 deletions

250
src/client/main.tsx Normal file
View File

@@ -0,0 +1,250 @@
import React, { useEffect, useMemo, useState } from "react";
import { createRoot } from "react-dom/client";
import { Activity, Bell, CircleDollarSign, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
import type { DailyReport, WatchStock } from "../shared/types";
import "./styles.css";
interface HealthStatus {
providerConfigured: boolean;
baseUrl: string;
wireApi: string;
scheduler: string;
}
function App() {
const [watchlist, setWatchlist] = useState<WatchStock[]>([]);
const [report, setReport] = useState<DailyReport | undefined>();
const [health, setHealth] = useState<HealthStatus>({
providerConfigured: false,
baseUrl: "",
wireApi: "responses",
scheduler: "24h"
});
const [symbol, setSymbol] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
void loadAll();
}, []);
const marketCounts = useMemo(() => {
return {
CN: watchlist.filter((stock) => stock.market === "CN").length,
HK: watchlist.filter((stock) => stock.market === "HK").length,
US: watchlist.filter((stock) => stock.market === "US").length
};
}, [watchlist]);
async function loadAll() {
setLoading(true);
setError("");
try {
const [nextWatchlist, nextReport, nextHealth] = await Promise.all([
api<WatchStock[]>("/api/watchlist"),
api<DailyReport>("/api/report/daily"),
api<HealthStatus>("/api/health")
]);
setWatchlist(nextWatchlist);
setReport(nextReport);
setHealth(nextHealth);
} catch (nextError) {
setError(errorMessage(nextError));
} finally {
setLoading(false);
}
}
async function addStock(event: React.FormEvent) {
event.preventDefault();
setError("");
try {
await api<WatchStock>("/api/watchlist", {
method: "POST",
body: JSON.stringify({ symbol, name })
});
setSymbol("");
setName("");
await loadAll();
} catch (nextError) {
setError(errorMessage(nextError));
}
}
async function removeStock(display: string) {
await api(`/api/watchlist/${encodeURIComponent(display)}`, { method: "DELETE" });
await loadAll();
}
async function refreshReport() {
setLoading(true);
try {
setReport(await api<DailyReport>("/api/report/daily/refresh", { method: "POST" }));
} catch (nextError) {
setError(errorMessage(nextError));
} finally {
setLoading(false);
}
}
return (
<main className="shell">
<section className="topbar">
<div>
<p className="eyebrow">Personal Stock Agent</p>
<h1></h1>
</div>
<button className="iconText" onClick={refreshReport} disabled={loading} title="刷新日报">
<RefreshCw size={18} />
</button>
</section>
{error ? <div className="alert">{error}</div> : null}
<section className="metrics">
<Metric icon={<Activity size={20} />} label="自选股" value={watchlist.length} />
<Metric icon={<Bell size={20} />} label="A股 / 港股 / 美股" value={`${marketCounts.CN} / ${marketCounts.HK} / ${marketCounts.US}`} />
<Metric icon={<CircleDollarSign size={20} />} label="IPADS_API_KEY" value={health.providerConfigured ? "已配置" : "未配置"} />
<Metric icon={<Server size={20} />} label="wire API" value={health.wireApi} />
</section>
<section className="layout">
<div className="panel">
<div className="panelHeader">
<h2></h2>
</div>
<form className="stockForm" onSubmit={addStock}>
<input value={symbol} onChange={(event) => setSymbol(event.target.value)} placeholder="AAPL / 00700.HK / 600519" />
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="名称" />
<button className="iconButton" title="添加股票" aria-label="添加股票">
<Plus size={19} />
</button>
</form>
<div className="watchlist">
{watchlist.map((stock) => (
<div className="watchItem" key={stock.id}>
<div>
<strong>{stock.display}</strong>
<span>{stock.name || marketName(stock.market)}</span>
</div>
<button className="ghostIcon" onClick={() => removeStock(stock.display)} title="移除" aria-label={`移除 ${stock.display}`}>
<Trash2 size={17} />
</button>
</div>
))}
{watchlist.length === 0 ? <p className="empty"></p> : null}
</div>
</div>
<div className="panel">
<div className="panelHeader">
<h2>IPADS </h2>
</div>
<div className="configRows">
<span>base_url</span>
<strong>{health.baseUrl || "-"}</strong>
<span>env_key</span>
<strong>IPADS_API_KEY</strong>
<span></span>
<strong>{health.scheduler}</strong>
</div>
</div>
</section>
<section className="report">
<div className="panelHeader">
<div>
<h2></h2>
<p>{report?.summary ?? "暂无日报"}</p>
</div>
<span>{report ? new Date(report.generatedAt).toLocaleString() : ""}</span>
</div>
<div className="reportGrid">
{report?.items.map((item) => (
<article className="reportItem" key={item.stock.id}>
<div className="itemTitle">
<div>
<strong>{item.stock.display}</strong>
<span>{item.stock.name || marketName(item.stock.market)}</span>
</div>
<Badge tone={item.recommendation.action}>{actionLabel(item.recommendation.action)}</Badge>
</div>
<div className="quoteLine">
<span>{item.snapshot.price} {item.snapshot.currency}</span>
<span>PE {item.snapshot.pe}</span>
<span>{valuationLabel(item.valuation.label)}</span>
</div>
<p className="reason">{item.recommendation.reasons.join(" · ")}</p>
<div className="detailRows">
<span>: {item.earnings.nextDate ?? "未披露"}</span>
<span>: {item.valuation.peerAveragePe ?? "-"}</span>
<span>: {item.snapshot.peers.map((peer) => peer.display).join(", ") || "-"}</span>
</div>
{item.news[0] ? (
<a className="news" href={item.news[0].url} target="_blank" rel="noreferrer">
{item.news[0].title}
</a>
) : null}
</article>
))}
</div>
</section>
</main>
);
}
function Metric({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
return (
<div className="metric">
{icon}
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function Badge({ tone, children }: { tone: "buy_watch" | "hold" | "avoid"; children: React.ReactNode }) {
return <span className={`badge ${tone}`}>{children}</span>;
}
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {})
}
});
if (!response.ok) {
const body = (await response.json().catch(() => undefined)) as { error?: string } | undefined;
throw new Error(body?.error ?? `Request failed: ${response.status}`);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
function marketName(market: string) {
return market === "CN" ? "A股" : market === "HK" ? "港股" : "美股";
}
function actionLabel(action: string) {
return action === "buy_watch" ? "买入观察" : action === "avoid" ? "回避" : "持有";
}
function valuationLabel(label: string) {
return label === "discount" ? "估值折价" : label === "premium" ? "估值溢价" : label === "fair" ? "估值合理" : "估值未知";
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "Unknown error";
}
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

332
src/client/styles.css Normal file
View File

@@ -0,0 +1,332 @@
:root {
color: #18202c;
background: #eef1f4;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
.shell {
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 40px;
}
.topbar,
.layout,
.metrics,
.reportGrid,
.itemTitle,
.panelHeader,
.watchItem,
.quoteLine {
display: grid;
gap: 16px;
}
.topbar {
grid-template-columns: 1fr auto;
align-items: center;
margin-bottom: 22px;
}
.eyebrow {
margin: 0 0 4px;
color: #596579;
font-size: 13px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: clamp(30px, 4vw, 48px);
line-height: 1.05;
letter-spacing: 0;
}
h2 {
font-size: 18px;
letter-spacing: 0;
}
.iconText {
border: 0;
border-radius: 8px;
background: #184c8f;
color: #fff;
min-height: 42px;
padding: 0 16px;
font-weight: 700;
}
.iconText {
display: inline-flex;
align-items: center;
gap: 8px;
}
.iconButton,
.ghostIcon {
width: 42px;
height: 42px;
display: inline-grid;
place-items: center;
border-radius: 8px;
}
.iconButton {
border: 0;
background: #1f7a5c;
color: #fff;
}
.ghostIcon {
border: 1px solid #d6dce5;
background: #fff;
color: #596579;
}
.alert {
border-left: 4px solid #bd3b3b;
background: #fff;
color: #8f2020;
padding: 12px 14px;
margin-bottom: 18px;
border-radius: 8px;
}
.metrics {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 18px;
}
.metric,
.panel,
.report,
.reportItem {
background: #fff;
border: 1px solid #dfe4ea;
border-radius: 8px;
}
.metric {
min-height: 104px;
padding: 18px;
display: grid;
align-content: space-between;
}
.metric svg {
color: #b45f24;
}
.metric span {
color: #667085;
font-size: 13px;
}
.metric strong {
font-size: 24px;
}
.layout {
grid-template-columns: 1.15fr 0.85fr;
margin-bottom: 18px;
}
.panel,
.report {
padding: 18px;
}
.panelHeader {
grid-template-columns: 1fr auto;
align-items: start;
margin-bottom: 16px;
}
.panelHeader p,
.panelHeader span,
.reason,
.empty,
.detailRows,
.watchItem span {
color: #667085;
font-size: 13px;
}
.stockForm {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 10px;
margin-bottom: 14px;
}
.configRows {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 10px 12px;
align-items: baseline;
}
.configRows span {
color: #465366;
font-size: 13px;
}
.configRows strong {
min-width: 0;
overflow-wrap: anywhere;
font-size: 14px;
}
input {
width: 100%;
min-height: 42px;
border: 1px solid #cad2dd;
border-radius: 8px;
padding: 0 12px;
color: #18202c;
background: #f9fafb;
}
input:focus {
outline: 2px solid #8bb8e8;
border-color: #184c8f;
}
.watchlist {
display: grid;
gap: 10px;
}
.watchItem {
grid-template-columns: 1fr auto;
align-items: center;
padding: 10px 0;
border-top: 1px solid #edf0f3;
}
.watchItem div,
.itemTitle div {
display: grid;
gap: 3px;
}
.reportGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.reportItem {
padding: 16px;
}
.itemTitle {
grid-template-columns: 1fr auto;
align-items: center;
margin-bottom: 14px;
}
.badge {
border-radius: 999px;
padding: 5px 10px;
font-size: 12px;
font-weight: 800;
}
.buy_watch {
color: #0f5c43;
background: #dff4eb;
}
.hold {
color: #684513;
background: #f8ead2;
}
.avoid {
color: #8f2020;
background: #f7dddd;
}
.quoteLine {
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-bottom: 10px;
}
.quoteLine span {
border-radius: 8px;
background: #f2f5f8;
min-height: 34px;
display: grid;
place-items: center;
font-size: 13px;
font-weight: 700;
}
.reason {
min-height: 38px;
line-height: 1.45;
}
.detailRows {
display: grid;
gap: 6px;
margin: 12px 0;
}
.news {
display: block;
color: #184c8f;
font-size: 13px;
font-weight: 700;
text-decoration: none;
overflow-wrap: anywhere;
}
@media (max-width: 860px) {
.shell {
width: min(100% - 20px, 720px);
padding-top: 18px;
}
.topbar,
.layout,
.reportGrid,
.metrics {
grid-template-columns: 1fr;
}
.stockForm {
grid-template-columns: 1fr auto;
}
.stockForm input:first-child {
grid-column: 1 / -1;
}
}