feat: bootstrap personal stock agent
This commit is contained in:
250
src/client/main.tsx
Normal file
250
src/client/main.tsx
Normal 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
332
src/client/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user