feat: 自动候选筛选与股票研究工作台

This commit is contained in:
2026-10-09 20:39:23 +08:00
parent 92329ddce2
commit 57882ff568
32 changed files with 1343 additions and 87 deletions
+17 -14
View File
@@ -1,13 +1,14 @@
import { useEffect, useRef, useState } from "react"
import { Activity, ArrowUpRight, BarChart3, Bell, BookOpen, ChevronRight, CircleDot, Database, FlaskConical, LayoutDashboard, LockKeyhole, RefreshCw, Server, Settings2, ShieldCheck, Sparkles, Users } from "lucide-react"
import { Fragment, useEffect, useRef, useState } from "react"
import { Activity, Bell, BookOpen, ChevronRight, CircleDot, Database, FlaskConical, LayoutDashboard, LockKeyhole, RefreshCw, Server, Settings2, ShieldCheck, Sparkles, Users, CandlestickChart } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card"
import { getHealth, type Health } from "@/lib/api"
import {type User} from "@/lib/auth"
import {ProvidersPanel} from "@/components/ProvidersPanel"
import {NotificationTasksPanel} from "@/components/NotificationTasksPanel"
import {SimulationQuotesPanel} from "@/components/SimulationQuotesPanel"
import {TradingOverview} from "@/components/TradingOverview"
import {MarketPanel} from "@/components/MarketPanel"
import {BacktestsPanel} from "@/components/BacktestsPanel"
import {StrategiesPanel} from "@/components/StrategiesPanel"
@@ -16,7 +17,7 @@ import {AuditPanel} from "@/components/AuditPanel"
import {UsersPanel} from "@/components/UsersPanel"
const workspaces = [
{name:"总览", icon:LayoutDashboard}, {name:"候选审核",icon:BookOpen}, {name:"策略中心",icon:Settings2},
{name:"总览", icon:LayoutDashboard}, {name:"股票行情",icon:CandlestickChart}, {name:"候选审核",icon:BookOpen}, {name:"策略中心",icon:Settings2},
{name:"回测研究",icon:FlaskConical}, {name:"数据健康",icon:Database}, {name:"模型配置",icon:Sparkles},
{name:"通知任务",icon:Bell}, {name:"用户管理",icon:Users}, {name:"审计日志",icon:ShieldCheck}, {name:"系统设置",icon:LockKeyhole},
]
@@ -24,6 +25,9 @@ const label = {ready:"连接正常",unavailable:"无法连接",not_configured:"
export default function App({user,onLogout,onSessionExpired}:{user:User;onLogout:()=>Promise<void>;onSessionExpired:()=>void}) {
const [page,setPage] = useState("总览")
const [marketSymbol,setMarketSymbol]=useState<string|undefined>()
const [candidateID,setCandidateID]=useState<string|undefined>()
function navigate(next:string,symbol?:string,candidate?:string){setMarketSymbol(symbol);setCandidateID(candidate);setPage(next)}
const [health,setHealth] = useState<Health | null>(null)
const [error,setError] = useState("")
const [loading,setLoading] = useState(false)
@@ -41,19 +45,19 @@ export default function App({user,onLogout,onSessionExpired}:{user:User;onLogout
finally {if (!next.signal.aborted) setLoading(false)}
}
useEffect(()=>{void refresh();return ()=>controller.current?.abort()},[])
const overview = page === "总览" || page === "数据健康"
const overview = page === "数据健康"
return <div className="min-h-screen lg:grid lg:grid-cols-[238px_1fr]">
<aside className="flex flex-col border-r bg-card px-5 py-7 lg:min-h-screen">
<a href="#" onClick={()=>setPage("总览")} className="flex items-center gap-3 px-2 text-foreground no-underline"><span className="flex size-10 items-center justify-center rounded-xl bg-foreground font-serif text-xl text-card">V</span><div><strong className="text-base">见势 · Veyra</strong><p className="mt-1 text-[10px] tracking-[.2em] text-muted-foreground">OPERATIONS CONSOLE</p></div></a>
<p className="mb-3 mt-10 px-3 text-[10px] tracking-[.18em] text-muted-foreground">工作空间</p>
<nav aria-label="管理端工作区" className="grid grid-cols-2 gap-1 sm:grid-cols-5 lg:grid-cols-1">{workspaces.map(({name,icon:Icon})=><button key={name} onClick={()=>setPage(name)} aria-current={page===name ? "page" : undefined} className={`flex items-center gap-3 rounded-lg px-3 py-3 text-left text-sm transition-colors ${page===name ? "bg-accent font-medium text-accent-foreground" : "text-muted-foreground hover:bg-muted"}`}><Icon size={17}/>{name}{page===name && <ChevronRight size={14} className="ml-auto"/>}</button>)}</nav>
<div className="mt-auto hidden pt-12 lg:block"><div className="rounded-lg border p-3 text-xs text-muted-foreground"><div className="mb-2 flex items-center gap-2 text-foreground"><CircleDot size={14}/>开发环境</div>模拟业务流程已接通</div></div>
<a href="#" onClick={()=>navigate("总览")} className="flex items-center gap-3 px-2 text-foreground no-underline"><span className="flex size-10 items-center justify-center rounded-xl bg-foreground font-serif text-xl text-card">V</span><div><strong className="text-base">见势 · Veyra</strong><p className="mt-1 text-[10px] tracking-[.2em] text-muted-foreground">A-SHARE RESEARCH</p></div></a>
<p className="mb-3 mt-10 px-3 text-[10px] tracking-[.18em] text-muted-foreground">股票研究与审核</p>
<nav aria-label="管理端工作区" className="grid grid-cols-2 gap-1 sm:grid-cols-5 lg:grid-cols-1">{workspaces.map(({name,icon:Icon})=><Fragment key={name}>{name==="数据健康"&&<p className="col-span-full mb-2 mt-6 px-3 text-[10px] tracking-[.18em] text-muted-foreground">运行与配置</p>}<button onClick={()=>navigate(name)} aria-current={page===name ? "page" : undefined} className={`flex items-center gap-3 rounded-lg px-3 py-3 text-left text-sm transition-colors ${page===name ? "bg-accent font-medium text-accent-foreground" : "text-muted-foreground hover:bg-muted"}`}><Icon size={17}/>{name}{page===name && <ChevronRight size={14} className="ml-auto"/>}</button></Fragment>)}</nav>
<div className="mt-auto hidden pt-12 lg:block"><div className="rounded-lg border p-3 text-xs text-muted-foreground"><div className="mb-2 flex items-center gap-2 text-foreground"><CircleDot size={14}/>开发环境</div>公开行情 · 模拟业务<br/>自动筛选与人工审核</div></div>
</aside>
<div className="min-w-0">
<header className="flex h-20 items-center justify-between border-b px-6 lg:px-10"><div className="flex items-center gap-2 text-sm text-muted-foreground">工作空间 <ChevronRight size={13}/><span className="text-foreground">{page}</span></div><div className="flex items-center gap-3"><span className="hidden text-xs text-muted-foreground sm:block">{user.display_name}</span><Badge variant="outline">LOCAL · 开发环境</Badge><Button variant="ghost" size="sm" onClick={()=>void signOut()} disabled={signingOut}>{signingOut ? "正在退出" : "退出"}</Button></div></header>
<main className="mx-auto max-w-7xl p-6 lg:p-10">
<main className="mx-auto max-w-7xl p-6 lg:p-8">
{logoutError && <p role="alert" className="mb-6 text-sm text-destructive">{logoutError}</p>}
<div className="mb-8 flex flex-wrap items-end justify-between gap-4"><div><p className="mb-3 text-[10px] tracking-[.2em] text-primary">VEYRA / {overview ? "SYSTEM OVERVIEW" : "WORKSPACE"}</p><h1 className="text-3xl font-semibold tracking-tight">{page === "总览" ? "把每一步,建立在依据之上。" : page}</h1><p className="mt-3 text-sm text-muted-foreground">{overview ? "查看服务连接与建设进度,从可靠的基础开始。" : page === "模型配置" || page === "系统设置" ? "配置供应商连接信息,按需启用。" : page === "审计日志" ? "查看后台配置的操作记录。" : page === "通知任务" ? "查看持久化事件与站内通知处理状态。" : page === "候选审核" ? "录入证据、审核修订并发布模拟候选。" : page === "策略中心" ? "管理研究参数与版本历史。" : page === "回测研究" ? "提交历史数据,查看可追溯的 Python 回测结果。" : "该工作区的业务功能正在建设中。"}</p></div>{overview && <Button variant="outline" onClick={()=>void refresh()} disabled={loading}><RefreshCw className={loading ? "animate-spin" : ""}/>{loading ? "检查中" : "刷新状态"}</Button>}</div>
<div className="mb-6 flex flex-wrap items-end justify-between gap-4"><div><p className="mb-3 text-[10px] tracking-[.2em] text-primary">VEYRA / {overview ? "SYSTEM OVERVIEW" : "WORKSPACE"}</p><h1 className="text-2xl font-semibold tracking-tight">{page === "总览" ? "A 股研究总览" : page}</h1><p className="mt-3 text-sm text-muted-foreground">{page === "总览" ? "从真实股票行情,到策略信号与候选审核。" : page === "股票行情" ? "搜索沪深股票,查看报价、数据时间与真实日线走势。" : overview ? "检查行情链路、存储与任务依赖。" : page === "模型配置" || page === "系统设置" ? "配置供应商连接信息,按需启用。" : page === "审计日志" ? "查看后台配置的操作记录。" : page === "通知任务" ? "查看持久化事件与站内通知处理状态。" : page === "候选审核" ? "审核自动生成的股票信号、证据与参考价格,发布到 App。" : page === "策略中心" ? "管理研究参数与版本历史。" : page === "回测研究" ? "提交历史数据,查看可追溯的 Python 回测结果。" : "该工作区的业务功能正在建设中。"}</p></div>{overview && <Button variant="outline" onClick={()=>void refresh()} disabled={loading}><RefreshCw className={loading ? "animate-spin" : ""}/>{loading ? "检查中" : "刷新状态"}</Button>}</div>
{overview ? <>
{error && <div role="alert" className="mb-6 rounded-lg border border-destructive/25 bg-destructive/5 p-4 text-sm text-destructive">无法读取当前服务状态。请检查 API 是否启动。{error}</div>}
<div className="mb-7 grid gap-4 md:grid-cols-3">{[
@@ -61,9 +65,8 @@ export default function App({user,onLogout,onSessionExpired}:{user:User;onLogout
{title:"PostgreSQL",subtitle:"业务事实与版本化迁移",icon:Database,value:loading ? "检查中" : health ? label[health.components.postgresql.status] : "未知"},
{title:"Redis",subtitle:"缓存与订阅索引",icon:Activity,value:loading ? "检查中" : health ? label[health.components.redis.status] : "未知"},
].map(({title,subtitle,icon:Icon,value})=><Card key={title}><CardHeader><div className="flex items-center justify-between"><CardDescription>{title}</CardDescription><Icon size={18} className="text-muted-foreground"/></div></CardHeader><CardContent><p className="mb-2 text-2xl font-semibold">{value}</p><p className="text-xs text-muted-foreground">{subtitle}</p></CardContent></Card>)}</div>
<div className="grid gap-6 xl:grid-cols-[1.5fr_1fr]"><Card><CardHeader><div className="flex items-center justify-between"><CardTitle>第一条产品闭环</CardTitle><Badge>模拟流程已接通</Badge></div><CardDescription className="mt-2">先验证完整流程,再扩大策略与数据范围。</CardDescription></CardHeader><CardContent><ol className="space-y-5">{["管理端审核并发布观察候选","用户查看证据,确认买入计划","条件满足后发送提醒","记录实际成交,建立持仓监控","记录退出成交,生成成本后复盘"].map((v,i)=><li key={v} className="flex items-center gap-4"><span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-xs text-muted-foreground">0{i+1}</span><span className="text-sm">{v}</span><span className="ml-auto text-xs text-muted-foreground">已接通模拟流程</span></li>)}</ol></CardContent></Card><Card><CardHeader><BarChart3 size={24} className="mb-4 text-primary"/><CardTitle>数据接入后,开始观察。</CardTitle><CardDescription className="mt-2 leading-7">候选数量、策略表现与账户指标将在业务功能与数据接入完成后展示。</CardDescription></CardHeader><CardContent><div className="rounded-lg bg-muted p-4 text-xs leading-6 text-muted-foreground">当前状态来源:Go API 实时探测<br/>最近检查:{health ? new Date(health.checked_at).toLocaleString("zh-CN") : "尚未取得状态"}</div><Button variant="ghost" className="mt-4 w-full justify-between" onClick={()=>setPage("数据健康")}>查看数据健康<ArrowUpRight/></Button></CardContent></Card></div>
{page==="数据健康"&&<><MarketPanel onSessionExpired={onSessionExpired}/><SimulationQuotesPanel onSessionExpired={onSessionExpired}/></>}</> : page==="候选审核" ? <CandidatesPanel onSessionExpired={onSessionExpired}/> : page==="回测研究" ? <BacktestsPanel onSessionExpired={onSessionExpired}/> : page==="策略中心" ? <StrategiesPanel onSessionExpired={onSessionExpired}/> : page==="通知任务" ? <NotificationTasksPanel onSessionExpired={onSessionExpired}/> : page==="用户管理" ? <UsersPanel onSessionExpired={onSessionExpired}/> : page==="模型配置" ? <ProvidersPanel kind="model" onSessionExpired={onSessionExpired}/> : page==="系统设置" ? <ProvidersPanel kind="market" onSessionExpired={onSessionExpired}/> : page==="审计日志" ? <AuditPanel onSessionExpired={onSessionExpired}/> : <Card><CardContent className="flex min-h-80 flex-col items-center justify-center p-10 text-center"><div className="mb-5 rounded-2xl bg-muted p-5"><LockKeyhole size={28} className="text-muted-foreground"/></div><h2 className="text-lg font-semibold">{page}尚未开放</h2><p className="mt-3 max-w-md text-sm leading-7 text-muted-foreground">该模块完成业务接口、权限与验证后开放。</p><Button variant="outline" className="mt-6" onClick={()=>setPage("总览")}>返回总览</Button></CardContent></Card>}
<footer className="mt-10 flex items-center justify-between border-t pt-5 text-[11px] text-muted-foreground"><span>见势 · Veyra / 可追溯的投资决策</span><span>模拟业务闭环 v0.8</span></footer>
<details className="rounded-xl border bg-card p-5"><summary className="cursor-pointer text-sm font-medium">开发验证:模拟价格回放</summary><SimulationQuotesPanel onSessionExpired={onSessionExpired}/></details></> : page==="总览" ? <TradingOverview onSessionExpired={onSessionExpired} onNavigate={navigate}/> : page==="股票行情" ? <MarketPanel initialSymbol={marketSymbol} onSessionExpired={onSessionExpired}/> : page==="候选审核" ? <CandidatesPanel initialCandidateID={candidateID} onSessionExpired={onSessionExpired}/> : page==="回测研究" ? <BacktestsPanel onSessionExpired={onSessionExpired}/> : page==="策略中心" ? <StrategiesPanel onSessionExpired={onSessionExpired}/> : page==="通知任务" ? <NotificationTasksPanel onSessionExpired={onSessionExpired}/> : page==="用户管理" ? <UsersPanel onSessionExpired={onSessionExpired}/> : page==="模型配置" ? <ProvidersPanel kind="model" onSessionExpired={onSessionExpired}/> : page==="系统设置" ? <ProvidersPanel kind="market" onSessionExpired={onSessionExpired}/> : page==="审计日志" ? <AuditPanel onSessionExpired={onSessionExpired}/> : <Card><CardContent className="flex min-h-80 flex-col items-center justify-center p-10 text-center"><div className="mb-5 rounded-2xl bg-muted p-5"><LockKeyhole size={28} className="text-muted-foreground"/></div><h2 className="text-lg font-semibold">{page}尚未开放</h2><p className="mt-3 max-w-md text-sm leading-7 text-muted-foreground">该模块完成业务接口、权限与验证后开放。</p><Button variant="outline" className="mt-6" onClick={()=>navigate("总览")}>返回总览</Button></CardContent></Card>}
<footer className="mt-10 flex items-center justify-between border-t pt-5 text-[11px] text-muted-foreground"><span>见势 · Veyra / 可追溯的投资决策</span><span>A 股研究工作台 · 开发版</span></footer>
</main>
</div>
</div>
+9 -5
View File
@@ -1,4 +1,6 @@
import {useEffect,useRef,useState,type FormEvent} from "react"
import {ScreeningRunControl} from "./ScreeningRunControl"
import {StockHistoryChart} from "./StockHistoryChart"
import {Plus,RefreshCw} from "lucide-react"
import {Button} from "./ui/button"
import {Input} from "./ui/input"
@@ -10,7 +12,7 @@ const control="w-full rounded-md border bg-background px-3 py-2 text-sm focus:ou
const actions:Record<string,string>={submit:"提交审核",publish:"审核发布",return:"退回修订",withdraw:"撤回发布"}
const localTime=(date:Date)=>new Date(date.getTime()-date.getTimezoneOffset()*60000).toISOString().slice(0,16)
function blank():CandidateContent{return {symbol:"",exchange:"SSE",name:"",strategy_code:"trend_following",title:"",thesis:"",evidence:[{source_url:"",locator:"",summary:""}],counter_evidence:"",invalidation:"",reference_plan:{entry_low:"",entry_high:"",stop_loss:"",target_price:""}}}
export function CandidatesPanel({onSessionExpired}:{onSessionExpired:()=>void}){
export function CandidatesPanel({onSessionExpired,initialCandidateID}:{onSessionExpired:()=>void;initialCandidateID?:string}){
const [list,setList]=useState<Candidate[]>([]),[selected,setSelected]=useState<Candidate|null>(null),[status,setStatus]=useState("")
const [error,setError]=useState(""),[busy,setBusy]=useState(false),[loading,setLoading]=useState(false),[editing,setEditing]=useState(false),[source,setSource]=useState<Candidate|undefined>()
const [content,setContent]=useState<CandidateContent>(blank),[observed,setObserved]=useState(()=>localTime(new Date())),[until,setUntil]=useState(()=>localTime(new Date(Date.now()+86400000)))
@@ -19,20 +21,22 @@ export function CandidatesPanel({onSessionExpired}:{onSessionExpired:()=>void}){
function failed(e:unknown){if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof Error?e.message:"操作失败,请重试。")}
async function refresh(){controller.current?.abort();const next=new AbortController();controller.current=next;setLoading(true);try{const result=await getCandidates(next.signal,status);if(!next.signal.aborted){setList(result);setError("")}}catch(e){if(!next.signal.aborted)failed(e)}finally{if(!next.signal.aborted)setLoading(false)}}
useEffect(()=>{void refresh();return()=>{controller.current?.abort();detailController.current?.abort()}},[status])
useEffect(()=>{if(!initialCandidateID)return;const c=new AbortController();getCandidate(initialCandidateID,c.signal).then(result=>{if(!c.signal.aborted)setSelected(result)}).catch(e=>{if(!c.signal.aborted)failed(e)});return()=>c.abort()},[initialCandidateID])
async function select(c:Candidate){detailController.current?.abort();const next=new AbortController();detailController.current=next;setSelected(null);setAction("");setEditing(false);try{const detail=await getCandidate(c.id,next.signal);if(!next.signal.aborted)setSelected(detail)}catch(e){if(!next.signal.aborted)failed(e)}}
function edit(c?:Candidate){setSource(c);setContent(c?structuredClone(c.content):blank());setObserved(localTime(new Date()));setUntil(localTime(new Date(Date.now()+86400000)));setEditing(true);setAction("");setError("")}
async function save(e:FormEvent){e.preventDefault();setBusy(true);setError("");try{const result=await saveDraft({content,observed_at:new Date(observed).toISOString(),valid_until:new Date(until).toISOString()},source);setEditing(false);await refresh();await select(result)}catch(e){failed(e)}finally{setBusy(false)}}
async function confirm(e:FormEvent){e.preventDefault();if(!selected)return;setBusy(true);setError("");try{const result=await actOnCandidate(selected,action,reason);setAction("");setReason("");await refresh();await select(result)}catch(e){failed(e)}finally{setBusy(false)}}
const set=(key:keyof CandidateContent,value:string)=>setContent(c=>({...c,[key]:value}))
return <div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3"><div className="flex items-center gap-3"><Badge variant="outline">模拟候选</Badge><p className="text-sm text-muted-foreground">手工录入来源,保存修订后再提交审核。</p></div><div className="flex gap-2"><Button variant="outline" disabled={busy||loading} onClick={()=>void refresh()}><RefreshCw/>刷新</Button><Button disabled={busy} onClick={()=>edit()}><Plus/>录入候选</Button></div></div>
<ScreeningRunControl onSessionExpired={onSessionExpired} onCompleted={()=>void refresh()}/>
<div className="flex flex-wrap items-center justify-between gap-3"><div className="flex items-center gap-3"><Badge variant="outline">模拟候选</Badge><p className="text-sm text-muted-foreground">自动筛选形成队列,审核股票依据后发布。</p></div><div className="flex gap-2"><Button variant="outline" disabled={busy||loading} onClick={()=>void refresh()}><RefreshCw/>刷新</Button><Button variant="ghost" disabled={busy} onClick={()=>edit()}><Plus/>手工补录</Button></div></div>
{error&&<p role="alert" className="rounded-lg border border-destructive/30 p-4 text-sm text-destructive">{error}</p>}
{editing&&<Card><CardHeader><CardTitle>{source?`创建第 ${source.revision+1} 次修订` : "录入观察候选"}</CardTitle><CardDescription>原修订保留。证据、反证与参考价格需完整填写;当前不会自动获取或核验行情。</CardDescription></CardHeader><CardContent><form onSubmit={e=>void save(e)} className="space-y-5"><div className="grid gap-4 sm:grid-cols-3">{([["证券代码","symbol"],["证券名称","name"],["候选标题","title"]] as const).map(([label,key])=><label key={key} className="space-y-2 text-sm"><span>{label}</span><Input required value={content[key]} onChange={e=>set(key,e.target.value)} maxLength={key==="symbol"?6:120}/></label>)}<label className="space-y-2 text-sm">交易所<select className={control} value={content.exchange} onChange={e=>set("exchange",e.target.value)}><option value="SSE">上交所</option><option value="SZSE">深交所</option></select></label><label className="space-y-2 text-sm">研究方法<select className={control} value={content.strategy_code} onChange={e=>set("strategy_code",e.target.value)}><option value="trend_following">趋势观察</option><option value="pullback">回调观察</option><option value="event_driven">事件观察</option></select></label><label className="space-y-2 text-sm">观察时间<Input type="datetime-local" required value={observed} onChange={e=>setObserved(e.target.value)}/></label><label className="space-y-2 text-sm">有效截止<Input type="datetime-local" required value={until} onChange={e=>setUntil(e.target.value)}/></label></div>
{editing&&<Card><CardHeader><CardTitle>{source?`创建第 ${source.revision+1} 次修订` : "录入观察候选"}</CardTitle><CardDescription>原修订保留。证据、反证与参考价格需完整填写;此表单为手工录入;自动筛选结果从策略中心进入待审核。</CardDescription></CardHeader><CardContent><form onSubmit={e=>void save(e)} className="space-y-5"><div className="grid gap-4 sm:grid-cols-3">{([["证券代码","symbol"],["证券名称","name"],["候选标题","title"]] as const).map(([label,key])=><label key={key} className="space-y-2 text-sm"><span>{label}</span><Input required value={content[key]} onChange={e=>set(key,e.target.value)} maxLength={key==="symbol"?6:120}/></label>)}<label className="space-y-2 text-sm">交易所<select className={control} value={content.exchange} onChange={e=>set("exchange",e.target.value)}><option value="SSE">上交所</option><option value="SZSE">深交所</option></select></label><label className="space-y-2 text-sm">研究方法<select className={control} value={content.strategy_code} onChange={e=>set("strategy_code",e.target.value)}><option value="trend_following">趋势观察</option><option value="pullback">回调观察</option><option value="event_driven">事件观察</option></select></label><label className="space-y-2 text-sm">观察时间<Input type="datetime-local" required value={observed} onChange={e=>setObserved(e.target.value)}/></label><label className="space-y-2 text-sm">有效截止<Input type="datetime-local" required value={until} onChange={e=>setUntil(e.target.value)}/></label></div>
{([["观察逻辑","thesis"],["反证与风险","counter_evidence"],["失效条件","invalidation"]] as const).map(([label,key])=><label key={key} className="block space-y-2 text-sm"><span>{label}</span><textarea className={control} rows={3} required maxLength={key==="thesis"?2000:1000} value={content[key]} onChange={e=>set(key,e.target.value)}/></label>)}
<div className="space-y-3"><h3 className="text-sm font-medium">证据来源</h3>{content.evidence.map((evidence,i)=><div key={i} className="grid gap-3 rounded-lg border p-4 sm:grid-cols-2">{([["来源 HTTPS 链接","source_url"],["页码 / 段落 / 标题","locator"],["证据摘要","summary"]] as const).map(([label,key])=><label className="space-y-2 text-sm" key={key}><span>{label}</span><Input required type={key==="source_url"?"url":"text"} value={evidence[key]} onChange={e=>setContent(c=>({...c,evidence:c.evidence.map((item,index)=>index===i?{...item,[key]:e.target.value}:item)}))}/></label>)}{content.evidence.length>1&&<Button type="button" variant="ghost" onClick={()=>setContent(c=>({...c,evidence:c.evidence.filter((_,index)=>index!==i)}))}>移除此证据</Button>}</div>)}<Button type="button" variant="outline" disabled={content.evidence.length>=8} onClick={()=>setContent(c=>({...c,evidence:[...c.evidence,{source_url:"",locator:"",summary:""}]}))}>添加证据</Button></div>
<div className="grid gap-4 sm:grid-cols-4">{([["参考区间下限","entry_low"],["参考区间上限","entry_high"],["风险价格","stop_loss"],["目标观察价格","target_price"]] as const).map(([label,key])=><label className="space-y-2 text-sm" key={key}><span>{label}</span><Input required inputMode="decimal" value={content.reference_plan[key]} onChange={e=>setContent(c=>({...c,reference_plan:{...c.reference_plan,[key]:e.target.value}}))}/></label>)}</div><p className="text-xs text-muted-foreground">金额最多两位小数;风险价格 &lt; 下限 ≤ 上限 &lt; 目标价格。观察时间限最近 24 小时,有效期最长 7 天。</p><div className="flex gap-3"><Button disabled={busy} type="submit">{busy?"保存中":"保存新草稿"}</Button><Button type="button" variant="outline" disabled={busy} onClick={()=>setEditing(false)}>取消</Button></div></form></CardContent></Card>}
<div className="grid items-start gap-6 xl:grid-cols-[.9fr_1.3fr]"><Card><CardHeader><div className="flex items-center justify-between"><CardTitle>候选队列</CardTitle><select aria-label="候选状态" className={`${control} max-w-32`} value={status} onChange={e=>setStatus(e.target.value)}><option value="">全部状态</option>{Object.entries(states).map(([key,label])=><option key={key} value={key}>{label}</option>)}</select></div><CardDescription>最近 100 条最新修订及当前发布版本。</CardDescription></CardHeader><CardContent className="space-y-2">{loading&&<p className="text-sm text-muted-foreground">正在读取…</p>}{!loading&&list.length===0&&<p className="py-8 text-center text-sm text-muted-foreground">没有符合此状态的候选。可从录入候选开始。</p>}{list.map(c=><button disabled={busy} key={c.id} onClick={()=>void select(c)} className={`w-full rounded-lg border p-4 text-left transition hover:bg-muted ${selected?.id===c.id?"border-primary bg-accent/40":""}`}><div className="flex items-center justify-between gap-2"><strong className="text-sm">{c.content.name} · {c.content.symbol}</strong><Badge variant="outline">{states[c.status]}</Badge></div><p className="mt-3 text-sm">{c.content.title}</p><p className="mt-2 text-xs text-muted-foreground">修订 {c.revision} · 版本 {c.version} · {c.is_active_publication?"当前有效发布":c.status==="PUBLISHED"?"已过期或被替代":"模拟研究"}</p></button>)}</CardContent></Card>
<Card><CardHeader><CardTitle>{selected?selected.content.title:"修订详情与审核"}</CardTitle><CardDescription>{selected?`${selected.content.name} ${selected.content.exchange}:${selected.content.symbol} · 修订 ${selected.revision}`:"选择候选查看依据与审核记录。"}</CardDescription></CardHeader><CardContent className="space-y-5">{selected&&<><div className="flex flex-wrap gap-2"><Badge>{states[selected.status]}</Badge><Badge variant="outline">模拟候选</Badge><Badge variant="outline">版本 {selected.version}</Badge></div><p className="text-xs text-muted-foreground">观察 {new Date(selected.observed_at).toLocaleString("zh-CN")} · 有效截止 {new Date(selected.valid_until).toLocaleString("zh-CN")}</p>{([["观察逻辑",selected.content.thesis],["反证与风险",selected.content.counter_evidence],["失效条件",selected.content.invalidation]]).map(([label,value])=><section key={label}><h3 className="mb-2 text-sm font-semibold">{label}</h3><p className="whitespace-pre-wrap text-sm leading-7 text-muted-foreground">{value}</p></section>)}<section><h3 className="mb-3 text-sm font-semibold">证据</h3>{selected.content.evidence.map((e,i)=><div key={i} className="mb-3 rounded-lg bg-muted p-4 text-sm"><a href={e.source_url} target="_blank" rel="noopener noreferrer" className="break-all text-primary underline">{e.locator}</a><p className="mt-2 whitespace-pre-wrap leading-6">{e.summary}</p></div>)}</section><div className="rounded-lg border p-4 text-sm leading-7">参考区间 ¥{selected.content.reference_plan.entry_low}–{selected.content.reference_plan.entry_high}<br/>风险价格 ¥{selected.content.reference_plan.stop_loss} · 目标观察 ¥{selected.content.reference_plan.target_price}</div>
<div className="grid items-start gap-6 xl:grid-cols-[.9fr_1.3fr]"><Card><CardHeader><div className="flex items-center justify-between"><CardTitle>候选队列</CardTitle><select aria-label="候选状态" className={`${control} max-w-32`} value={status} onChange={e=>setStatus(e.target.value)}><option value="">全部状态</option>{Object.entries(states).map(([key,label])=><option key={key} value={key}>{label}</option>)}</select></div><CardDescription>最近 100 条最新修订及当前发布版本。</CardDescription></CardHeader><CardContent className="space-y-2">{loading&&<p className="text-sm text-muted-foreground">正在读取…</p>}{!loading&&list.length===0&&<p className="py-8 text-center text-sm text-muted-foreground">尚无符合此状态的股票候选。点击上方自动筛选,命中结果会进入待审核;无需手工录入。</p>}{list.map(c=><button disabled={busy} key={c.id} onClick={()=>void select(c)} className={`w-full rounded-lg border p-4 text-left transition hover:bg-muted ${selected?.id===c.id?"border-primary bg-accent/40":""}`}><div className="flex items-center justify-between gap-2"><strong className="text-lg">{c.content.name}</strong><Badge variant="outline">{states[c.status]}</Badge></div><p className="mt-1 font-mono text-xs text-muted-foreground">{c.content.exchange} · {c.content.symbol}</p><div className="my-3 flex items-end gap-2"><span className="text-xs text-muted-foreground">参考区间</span><strong className="font-mono text-xl">¥{c.content.reference_plan.entry_low}–{c.content.reference_plan.entry_high}</strong></div><p className="text-xs text-muted-foreground">风险 ¥{c.content.reference_plan.stop_loss} · 目标观察 ¥{c.content.reference_plan.target_price}</p><p className="mt-3 text-sm">{c.content.title}</p><p className="mt-2 text-xs text-muted-foreground">修订 {c.revision} · 版本 {c.version} · {c.is_active_publication?"当前有效发布":c.status==="PUBLISHED"?"已过期或被替代":"模拟研究"}</p></button>)}</CardContent></Card>
<Card><CardHeader><CardTitle>{selected?`${selected.content.name} · ${selected.content.symbol}`:"股票依据与审核"}</CardTitle><CardDescription>{selected?`${selected.content.name} ${selected.content.exchange}:${selected.content.symbol} · 修订 ${selected.revision}`:"选择候选查看依据与审核记录。"}</CardDescription></CardHeader><CardContent className="space-y-5">{selected&&<><StockHistoryChart symbol={selected.content.symbol} exchange={selected.content.exchange} onSessionExpired={onSessionExpired}/><div className="flex flex-wrap gap-2"><Badge>{states[selected.status]}</Badge><Badge variant="outline">模拟候选</Badge><Badge variant="outline">版本 {selected.version}</Badge></div>{selected.screening&&<div className="rounded-lg border p-4 text-sm leading-7"><strong>自动日线筛选</strong><br/>{selected.screening.strategy_name} · 修订 {selected.screening.strategy_revision}<br/>日线 {selected.screening.bar_date} · {selected.screening.source_adapter} 配置版本 {selected.screening.source_version}<p className="break-all text-xs text-muted-foreground">输入摘要 {selected.screening.input_hash}</p></div>}<p className="text-xs text-muted-foreground">观察 {new Date(selected.observed_at).toLocaleString("zh-CN")} · 有效截止 {new Date(selected.valid_until).toLocaleString("zh-CN")}</p>{([["观察逻辑",selected.content.thesis],["反证与风险",selected.content.counter_evidence],["失效条件",selected.content.invalidation]]).map(([label,value])=><section key={label}><h3 className="mb-2 text-sm font-semibold">{label}</h3><p className="whitespace-pre-wrap text-sm leading-7 text-muted-foreground">{value}</p></section>)}<section><h3 className="mb-3 text-sm font-semibold">证据</h3>{selected.content.evidence.map((e,i)=><div key={i} className="mb-3 rounded-lg bg-muted p-4 text-sm"><a href={e.source_url} target="_blank" rel="noopener noreferrer" className="break-all text-primary underline">{e.locator}</a><p className="mt-2 whitespace-pre-wrap leading-6">{e.summary}</p></div>)}</section><div className="rounded-lg border p-4 text-sm leading-7">参考区间 ¥{selected.content.reference_plan.entry_low}–{selected.content.reference_plan.entry_high}<br/>风险价格 ¥{selected.content.reference_plan.stop_loss} · 目标观察 ¥{selected.content.reference_plan.target_price}</div>
<div className="flex flex-wrap gap-2">{selected.is_latest&&selected.status!=="PENDING_REVIEW"&&<Button variant="outline" disabled={busy} onClick={()=>edit(selected)}>创建新修订</Button>}{(selected.is_latest&&selected.status==="DRAFT"?["submit"]:selected.is_latest&&selected.status==="PENDING_REVIEW"?["publish","return"]:selected.is_active_publication?["withdraw"]:[]).map(a=><Button key={a} disabled={busy} variant={a==="publish"?"default":"outline"} onClick={()=>{setAction(a);setReason("")}}>{actions[a]}</Button>)}</div>
{action&&<form onSubmit={e=>void confirm(e)} className="space-y-3 rounded-lg border border-primary/30 p-4"><p className="text-sm font-semibold">确认{actions[action]}:{selected.content.name},修订 {selected.revision},版本 {selected.version}</p><p className="text-xs text-muted-foreground">{action==="publish"?"发布后 App 可读取此模拟候选;旧发布版本将被替代。":action==="withdraw"?"撤回后此版本从有效候选列表移除,历史链接仍显示失效状态。":"此操作会写入不可覆盖的审核记录。"}</p><textarea required maxLength={1000} aria-label="审核意见" placeholder="填写具体理由或审核意见" rows={3} className={control} value={reason} onChange={e=>setReason(e.target.value)}/><div className="flex gap-2"><Button disabled={busy||!reason.trim()} type="submit">{busy?"提交中":"确认"}</Button><Button disabled={busy} type="button" variant="ghost" onClick={()=>setAction("")}>取消</Button></div></form>}
<section><h3 className="mb-3 text-sm font-semibold">审核记录</h3>{selected.reviews?.length?selected.reviews.map(r=><div key={r.id} className="border-t py-3 text-sm"><strong>{actions[r.decision.toLowerCase()]??r.decision}</strong><span className="ml-3 text-xs text-muted-foreground">{new Date(r.created_at).toLocaleString("zh-CN")} · 输入版本 {r.input_version}</span><p className="mt-2 whitespace-pre-wrap text-muted-foreground">{r.reason}</p></div>):<p className="text-sm text-muted-foreground">尚无审核记录。</p>}</section></>}</CardContent></Card></div>
+5 -3
View File
@@ -1,13 +1,15 @@
import {StockHistoryChart} from "./StockHistoryChart"
import {useEffect,useState} from "react"
import {ApiError,request} from "@/lib/auth"
import {Button} from "@/components/ui/button"
import {Card,CardContent,CardHeader,CardTitle} from "@/components/ui/card"
type Instrument={id:string;symbol:string;exchange:string;name:string;price:string;observed_at:string;received_at:string}
type Sync={id:string;status:string;instrument_count:number;message:string;finished_at:string}
export function MarketPanel({onSessionExpired}:{onSessionExpired:()=>void}){
const [list,setList]=useState<Instrument[]>([]),[tasks,setTasks]=useState<Sync[]>([]),[query,setQuery]=useState(""),[error,setError]=useState(""),[busy,setBusy]=useState(false)
export function MarketPanel({onSessionExpired,initialSymbol}:{onSessionExpired:()=>void;initialSymbol?:string}){
const [selected,setSelected]=useState<Instrument|null>(null)
const [list,setList]=useState<Instrument[]>([]),[tasks,setTasks]=useState<Sync[]>([]),[query,setQuery]=useState(initialSymbol??""),[error,setError]=useState(""),[busy,setBusy]=useState(false)
function fail(e:unknown){if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof Error?e.message:"行情读取失败")}
async function load(){setBusy(true);setError("");try{const [stocks,runs]=await Promise.all([request<Instrument[]>(`/api/admin/v1/instruments?q=${encodeURIComponent(query)}`),request<Sync[]>("/api/admin/v1/market/syncs")]);setList(stocks);setTasks(runs)}catch(e){fail(e)}finally{setBusy(false)}}
useEffect(()=>{void load()},[])
return <Card className="mt-6"><CardHeader><CardTitle>A 股自动行情同步</CardTitle></CardHeader><CardContent className="space-y-5"><p className="text-sm leading-7 text-muted-foreground">启用公开行情适配器后,后端启动即同步沪深股票,此后每五分钟更新。此接口用于开发研究,价格按供应商时间展示,休市或同步失败时保留上次快照。不会把抓取时间当成实时行情时间。</p><form className="flex gap-3" onSubmit={e=>{e.preventDefault();void load()}}><input className="w-full rounded-lg border px-3 py-2 text-sm" placeholder="按股票代码或名称搜索" value={query} onChange={e=>setQuery(e.target.value)}/><Button disabled={busy}>查询 / 刷新</Button></form>{error&&<p className="text-destructive text-sm">{error}</p>}<div className="max-h-96 overflow-auto"><table className="w-full text-left text-sm"><thead><tr>{["股票","价格","行情时间"].map(v=><th key={v} className="p-3">{v}</th>)}</tr></thead><tbody>{list.map(v=><tr className="border-t" key={v.id}><td className="p-3">{v.name}<p className="text-xs text-muted-foreground">{v.exchange}:{v.symbol}</p></td><td>{v.price==="0.00"?"无报价":v.price+" 元"}</td><td className="text-xs">{new Date(v.observed_at).toLocaleString("zh-CN")}</td></tr>)}</tbody></table>{list.length===0&&<p className="p-3 text-muted-foreground">暂无同步数据,请检查系统设置中的行情源与同步记录。</p>}</div><details><summary className="cursor-pointer text-sm">最近同步记录</summary><div className="mt-3 space-y-2">{tasks.length===0&&<p className="text-sm text-muted-foreground">还没有同步记录。</p>}{tasks.map(t=><div key={t.id} className="rounded-lg bg-muted p-3 text-xs">{new Date(t.finished_at).toLocaleString("zh-CN")} · {t.status==="COMPLETED"?`同步 ${t.instrument_count} 个证券`:t.message}</div>)}</div></details></CardContent></Card>
return <Card className="mt-6"><CardHeader><CardTitle>沪深股票池 · 行情与日线</CardTitle></CardHeader><CardContent className="space-y-5"><p className="text-sm leading-7 text-muted-foreground">启用公开行情适配器后,后端启动即同步沪深股票,此后每五分钟更新。此接口用于开发研究,价格按供应商时间展示,休市或同步失败时保留上次快照。不会把抓取时间当成实时行情时间。</p><form className="flex gap-3" onSubmit={e=>{e.preventDefault();void load()}}><input className="w-full rounded-lg border px-3 py-2 text-sm" placeholder="按股票代码或名称搜索" value={query} onChange={e=>setQuery(e.target.value)}/><Button disabled={busy}>查询 / 刷新</Button></form>{error&&<p className="text-destructive text-sm">{error}</p>}<div className="max-h-[560px] overflow-auto"><table className="w-full text-left text-sm"><thead><tr>{["股票","价格","行情时间"].map(v=><th key={v} className="p-3">{v}</th>)}</tr></thead><tbody>{list.map(v=><tr className="border-t" key={v.id}><td className="p-3"><button className="text-left text-base font-medium hover:text-primary" onClick={()=>setSelected(v)}>{v.name}</button><p className="text-xs text-muted-foreground">{v.exchange}:{v.symbol}</p></td><td className="font-mono text-lg">{v.price==="0.00"?"无报价":v.price+" 元"}</td><td className="text-xs">{new Date(v.observed_at).toLocaleString("zh-CN")}</td></tr>)}</tbody></table>{list.length===0&&<p className="p-3 text-muted-foreground">暂无同步数据,请检查系统设置中的行情源与同步记录。</p>}</div>{selected&&<div><div className="mb-4 flex items-end justify-between"><div><h2 className="text-xl font-semibold">{selected.name}</h2><p className="mt-1 font-mono text-xs text-muted-foreground">{selected.exchange}:{selected.symbol}</p></div><strong className="font-mono text-3xl">¥{selected.price}</strong></div><StockHistoryChart symbol={selected.symbol} exchange={selected.exchange} onSessionExpired={onSessionExpired}/></div>}<details><summary className="cursor-pointer text-sm">最近同步记录</summary><div className="mt-3 space-y-2">{tasks.length===0&&<p className="text-sm text-muted-foreground">还没有同步记录。</p>}{tasks.map(t=><div key={t.id} className="rounded-lg bg-muted p-3 text-xs">{new Date(t.finished_at).toLocaleString("zh-CN")} · {t.status==="COMPLETED"?`同步 ${t.instrument_count} 个证券`:t.message}</div>)}</div></details></CardContent></Card>
}
@@ -0,0 +1,18 @@
import {useEffect,useRef,useState} from "react"
import {ScanLine} from "lucide-react"
import {request,ApiError} from "@/lib/auth"
import {Button} from "./ui/button"
type RunState={state:string;targets:number;completed:number;generated:number;skipped:number;failed:number;started_at:string|null;finished_at:string|null;message:string}
export function ScreeningRunControl({onSessionExpired,onCompleted}:{onSessionExpired:()=>void;onCompleted:()=>void}){
const [run,setRun]=useState<RunState|null>(null),[busy,setBusy]=useState(false),[error,setError]=useState("")
const lastFinished=useRef<string|null>(null),completed=useRef(onCompleted)
completed.current=onCompleted
function fail(e:unknown){if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof Error?e.message:"筛选状态读取失败")}
useEffect(()=>{const controller=new AbortController();let fetching=false
const refresh=async()=>{if(fetching)return;fetching=true;try{const result=await request<RunState>("/api/admin/v1/strategy-screening-status",{signal:controller.signal});if(controller.signal.aborted)return;setRun(result);if(result.finished_at&&lastFinished.current!==result.finished_at){lastFinished.current=result.finished_at;completed.current()}}catch(e){if(!controller.signal.aborted)fail(e)}finally{fetching=false}}
void refresh();const timer=setInterval(()=>void refresh(),3000);return()=>{controller.abort();clearInterval(timer)}
},[])
async function scan(){setBusy(true);setError("");try{setRun(await request<RunState>("/api/admin/v1/strategy-screenings/run",{method:"POST",body:"{}"}))}catch(e){fail(e)}finally{setBusy(false)}}
const running=run?.state==="RUNNING"||run?.state==="QUEUED"
return <div className="rounded-xl border border-primary/20 bg-accent/40 p-5"><div className="flex flex-wrap items-center justify-between gap-4"><div><p className="font-medium">自动筛选 → 人工审核 → App 发布</p><p className="mt-2 text-sm text-muted-foreground">从已启用的策略范围获取真实日线,自动填写依据与价格规则,无需逐条录入股票。</p></div><Button disabled={busy||running} onClick={()=>void scan()}><ScanLine className={running?"animate-pulse":""}/>{running?"正在自动筛选":"立即自动筛选"}</Button></div><p className="mt-4 text-sm" aria-live="polite">{run?.finished_at&&"最近一批:"}{run?.message??"正在读取筛选状态…"}{run?.finished_at&&<span className="ml-3 text-xs text-muted-foreground">{new Date(run.finished_at).toLocaleString("zh-CN")}</span>}</p>{run?.state==="COMPLETED"&&run.targets===0&&<p className="mt-2 text-xs text-muted-foreground">当前没有新的可处理日线:已处理的结果会保留,数据陈旧或策略未启用时会等待下一次更新。</p>}{error&&<p role="alert" className="mt-3 text-sm text-destructive">{error}</p>}</div>
}
@@ -0,0 +1,32 @@
import {useEffect, useState} from "react"
import {request, ApiError} from "@/lib/auth"
import {Button} from "@/components/ui/button"
type Security = {id:string;symbol:string;exchange:string;name:string}
export function ScreeningSecurities({value,onChange,onSessionExpired}:{value:string;onChange:(value:string)=>void;onSessionExpired:()=>void}) {
const [query,setQuery]=useState("")
const [matches,setMatches]=useState<Security[]>([])
const [error,setError]=useState("")
const [loading,setLoading]=useState(false)
const selected=value.trim().split(/\s+/).filter(Boolean)
useEffect(()=>{
const controller=new AbortController()
if(!query.trim()){setMatches([]);setError("");setLoading(false);return()=>controller.abort()}
const timer=setTimeout(()=>{
setLoading(true);setError("")
request<Security[]>(`/api/admin/v1/instruments?q=${encodeURIComponent(query.trim())}`,{signal:controller.signal})
.then(result=>{if(!controller.signal.aborted)setMatches(result.slice(0,8))})
.catch(e=>{if(controller.signal.aborted)return;if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof Error?e.message:"搜索失败")})
.finally(()=>{if(!controller.signal.aborted)setLoading(false)})
},250)
return()=>{clearTimeout(timer);controller.abort()}
},[query])
return <div className="space-y-3">
<label className="block text-sm">从自动同步股票中选择<input value={query} onChange={e=>{setQuery(e.target.value);setMatches([])}} placeholder="搜索股票名称或代码" className="mt-2 w-full rounded-lg border bg-background px-3 py-2"/></label>
{loading&&<p className="text-xs text-muted-foreground">查询中…</p>}
{error&&<p role="alert" className="text-sm text-destructive">{error}</p>}
{matches.map(stock=>{const code=`${stock.exchange}:${stock.symbol}`;return <div key={stock.id} className="flex items-center justify-between gap-3 rounded-lg bg-muted px-3 py-2 text-sm"><span>{stock.name} · {code}</span><Button type="button" variant="outline" disabled={selected.includes(code)||selected.length>=100} onClick={()=>onChange([...selected,code].join("\n"))}>{selected.includes(code)?"已选择":"添加"}</Button></div>})}
<label className="block text-sm">股票范围(最多 100 只,每行如 SSE:600000 / SZSE:000001)<textarea required className="mt-2 w-full rounded-lg border bg-background px-3 py-2 text-sm" rows={4} value={value} onChange={e=>onChange(e.target.value)}/></label>
</div>
}
@@ -0,0 +1,14 @@
import {useEffect,useState} from "react"
import {request,ApiError} from "@/lib/auth"
type Bar={date:string;open:string;high:string;low:string;close:string}
type History={source:string;adjustment:string;received_at:string;bars:Bar[]}
export function StockHistoryChart({symbol,exchange,onSessionExpired}:{symbol:string;exchange:string;onSessionExpired:()=>void}){
const [data,setData]=useState<History|null>(null),[error,setError]=useState("")
useEffect(()=>{const c=new AbortController();setData(null);setError("");const end=new Date(),start=new Date();start.setDate(start.getDate()-90);request<History>(`/api/admin/v1/market/history?symbol=${symbol}&exchange=${exchange}&start=${start.toISOString().slice(0,10)}&end=${end.toISOString().slice(0,10)}`,{signal:c.signal}).then(result=>{if(!c.signal.aborted)setData(result)}).catch(e=>{if(c.signal.aborted)return;if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof Error?e.message:"日线读取失败")});return()=>c.abort()},[symbol,exchange])
const bars=data?.bars.slice(-40)??[]
// Integer cents for price parsing; division below only maps values to SVG pixels.
const cents=(price:string)=>{const [whole,fraction=""]=price.split(".");return Number(whole)*100+Number(fraction.padEnd(2,"0"))}
const values=bars.map(b=>cents(b.close)),min=Math.min(...values),max=Math.max(...values),range=max-min||1
const points=values.map((value,i)=>`${20+i*600/Math.max(values.length-1,1)},${150-(value-min)*120/range}`).join(" ")
return <div className="rounded-xl border bg-card p-4"><div className="flex items-center justify-between"><strong className="text-sm">日线收盘走势</strong><span className="text-xs text-muted-foreground">未复权 · 最近 {bars.length} 个交易日</span></div>{error?<p className="mt-5 text-sm text-destructive">{error}</p>:!data?<p className="py-12 text-center text-sm text-muted-foreground">正在获取真实日线…</p>:bars.length===0?<p className="py-12 text-center text-sm text-muted-foreground">此范围没有可用日线。</p>:<><svg viewBox="0 0 640 185" className="mt-4 w-full" role="img" aria-label={`${exchange}:${symbol} 真实日线收盘价`}><path d="M20 30H620 M20 90H620 M20 150H620" fill="none" stroke="var(--color-border)" strokeDasharray="4 4"/><polyline points={points} fill="none" stroke="var(--primary)" strokeWidth="2.5" strokeLinejoin="round"/><text x="20" y="178" fontSize="11" fill="#79736c">{bars[0].date}</text><text x="620" y="178" textAnchor="end" fontSize="11" fill="#79736c">{bars[bars.length-1].date}</text></svg><p className="text-xs text-muted-foreground">末日收盘 ¥{bars[bars.length-1].close} · 来源 {data.source} · 接收 {new Date(data.received_at).toLocaleTimeString("zh-CN")}</p></>}</div>
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,26 @@
import {useEffect,useRef,useState} from "react"
import {ArrowUpRight,RefreshCw} from "lucide-react"
import {request,ApiError} from "@/lib/auth"
import type {Candidate} from "@/lib/candidates"
import {Button} from "./ui/button"
import {Badge} from "./ui/badge"
import {Card,CardHeader,CardTitle,CardContent} from "./ui/card"
import {StockHistoryChart} from "./StockHistoryChart"
import {ScreeningRunControl} from "./ScreeningRunControl"
type Stock={id:string;name:string;symbol:string;exchange:string;price:string;observed_at:string}
type Sync={id:string;status:string;instrument_count:number;finished_at:string;message:string}
type Strategy={id:string;status:string;name:string}
type Data={stocks:Stock[];candidates:Candidate[];syncs:Sync[];strategies:Strategy[]}
export function TradingOverview({onSessionExpired,onNavigate}:{onSessionExpired:()=>void;onNavigate:(page:string,symbol?:string,candidate?:string)=>void}){
const [data,setData]=useState<Data|null>(null),[error,setError]=useState(""),[loading,setLoading]=useState(false),[selected,setSelected]=useState<Stock|null>(null)
const controller=useRef<AbortController|null>(null)
async function refresh(){controller.current?.abort();const c=new AbortController();controller.current=c;setLoading(true);try{const [stocks,candidates,syncs,strategies]=await Promise.all([request<Stock[]>("/api/admin/v1/instruments",{signal:c.signal}),request<Candidate[]>("/api/admin/v1/candidates",{signal:c.signal}),request<Sync[]>("/api/admin/v1/market/syncs",{signal:c.signal}),request<Strategy[]>("/api/admin/v1/strategies",{signal:c.signal})]);if(c.signal.aborted)return;setData({stocks,candidates,syncs,strategies});setSelected(current=>current??stocks[0]??null);setError("")}catch(e){if(!c.signal.aborted){if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof Error?e.message:"股票数据读取失败")}}finally{if(!c.signal.aborted)setLoading(false)}}
useEffect(()=>{void refresh();return()=>controller.current?.abort()},[])
const success=data?.syncs.find(x=>x.status==="COMPLETED"),pending=data?.candidates.filter(c=>c.status==="PENDING_REVIEW")??[],published=data?.candidates.filter(c=>c.is_active_publication)??[]
return <div className="space-y-6"><div className="flex items-center justify-between"><div className="flex gap-2"><Badge variant="outline">沪深 A 股</Badge><Badge variant="outline">公开行情 · 开发研究</Badge></div><Button variant="outline" disabled={loading} onClick={()=>void refresh()}><RefreshCw className={loading?"animate-spin":""}/>刷新数据</Button></div>{error&&<p role="alert" className="text-sm text-destructive">{error}</p>}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">{[{title:"已同步股票",value:success?.instrument_count??"—",note:success?`最近成功同步 ${new Date(success.finished_at).toLocaleTimeString("zh-CN")}`:"等待行情源同步",page:"股票行情"},{title:"待审核股票候选",value:data?pending.length:"—",note:"最近 100 条候选修订",page:"候选审核"},{title:"有效发布候选",value:data?published.length:"—",note:"最近 100 条候选修订",page:"候选审核"},{title:"已启用研究策略",value:data?data.strategies.filter(s=>s.status==="ACTIVE").length:"—",note:"最近 100 个策略 · 非收益指标",page:"策略中心"}].map(x=><button key={x.title} className="rounded-xl border bg-card p-5 text-left transition hover:border-primary/40" onClick={()=>onNavigate(x.page)}><div className="flex items-center justify-between"><span className="text-sm text-muted-foreground">{x.title}</span><ArrowUpRight size={15} className="text-muted-foreground"/></div><p className="my-3 font-mono text-4xl font-semibold tracking-tight">{x.value}</p><p className="text-xs text-muted-foreground">{x.note}</p></button>)}</div>
<ScreeningRunControl onSessionExpired={onSessionExpired} onCompleted={()=>void refresh()}/>
<div className="grid items-start gap-6 xl:grid-cols-[1.35fr_1fr]"><Card><CardHeader><div className="flex items-center justify-between"><CardTitle>股票观察</CardTitle><Button variant="ghost" size="sm" onClick={()=>onNavigate("股票行情")}>查看股票池<ArrowUpRight/></Button></div><p className="mt-2 text-xs text-muted-foreground">按证券代码排序的行情样本 · 非涨幅排名或投资推荐</p></CardHeader><CardContent>{selected&&<div className="mb-5"><div className="mb-4 flex items-end justify-between"><div><button className="text-xl font-semibold hover:text-primary" onClick={()=>onNavigate("股票行情",selected.symbol)}>{selected.name}</button><p className="mt-1 font-mono text-xs text-muted-foreground">{selected.exchange} · {selected.symbol}</p></div><div className="text-right"><p className="font-mono text-3xl font-semibold">¥{selected.price}</p><p className="mt-1 text-xs text-muted-foreground">报价 {new Date(selected.observed_at).toLocaleString("zh-CN")}</p></div></div><StockHistoryChart symbol={selected.symbol} exchange={selected.exchange} onSessionExpired={onSessionExpired}/></div>}<div className="overflow-auto"><table className="w-full text-left text-sm"><thead className="text-xs text-muted-foreground"><tr><th className="pb-3">股票 / 代码</th><th className="pb-3 text-right">最新报价</th><th className="pb-3 text-right">行情时间</th></tr></thead><tbody>{data?.stocks.slice(0,8).map(stock=><tr key={stock.id} className="border-t"><td className="py-3"><button className="text-left hover:text-primary" onClick={()=>setSelected(stock)}><strong>{stock.name}</strong><p className="mt-1 font-mono text-xs text-muted-foreground">{stock.exchange}:{stock.symbol}</p></button></td><td className="text-right font-mono">¥{stock.price}</td><td className="text-right text-xs text-muted-foreground">{new Date(stock.observed_at).toLocaleTimeString("zh-CN")}</td></tr>)}</tbody></table>{data?.stocks.length===0&&<p className="py-8 text-sm text-muted-foreground">暂无股票快照,请在行情源设置中检查同步配置。</p>}</div></CardContent></Card>
<Card><CardHeader><div className="flex items-center justify-between"><CardTitle>待审核信号</CardTitle><Button variant="ghost" size="sm" onClick={()=>onNavigate("候选审核")}>进入审核<ArrowUpRight/></Button></div><p className="mt-2 text-xs text-muted-foreground">从股票、参考区间与信号依据开始审核</p></CardHeader><CardContent className="space-y-3">{pending.length===0&&<div className="rounded-xl border border-dashed p-6 text-sm leading-7 text-muted-foreground">当前没有待审核股票。点击“立即自动筛选”后,真实日线命中结果会自动进入这里;不符合规则的股票不会生成候选。</div>}{pending.slice(0,8).map(c=><button key={c.id} className="w-full rounded-xl border p-4 text-left hover:border-primary/40" onClick={()=>onNavigate("候选审核",undefined,c.id)}><div className="flex items-center justify-between"><strong className="text-lg">{c.content.name}</strong><Badge variant="outline">待审核</Badge></div><p className="mt-1 font-mono text-xs text-muted-foreground">{c.content.exchange} · {c.content.symbol}</p><p className="my-3 font-mono text-xl">¥{c.content.reference_plan.entry_low}–{c.content.reference_plan.entry_high}</p><p className="text-xs leading-6 text-muted-foreground">{c.screening?`${c.screening.strategy_name} · 日线 ${c.screening.bar_date}`:"人工补充研究"}<br/>有效至 {new Date(c.valid_until).toLocaleString("zh-CN")}</p></button>)}</CardContent></Card></div>
<p className="text-xs leading-6 text-muted-foreground">价格按供应商观察时间展示,收盘后保留收盘快照。曲线来自实际未复权日线;当前未接入指数、涨跌幅、成交额与行业分类,因此不展示相应统计。</p></div>
}
+1 -1
View File
@@ -1,6 +1,6 @@
import {request} from "./auth"
export type CandidateContent={symbol:string;exchange:string;name:string;strategy_code:string;title:string;thesis:string;evidence:{source_url:string;locator:string;summary:string}[];counter_evidence:string;invalidation:string;reference_plan:{entry_low:string;entry_high:string;stop_loss:string;target_price:string}}
export type Candidate={id:string;candidate_id:string;revision:number;version:number;status:string;data_mode:"simulation";content:CandidateContent;observed_at:string;valid_until:string;published_at:string|null;is_latest:boolean;is_active_publication:boolean;reviews?:{id:string;decision:string;reason:string;input_version:number;created_at:string}[]}
export type Candidate={screening?:{candidate_revision_id:string;strategy_version_id:string;strategy_name:string;strategy_revision:number;bar_date:string;source_adapter:string;source_version:number;input_hash:string};id:string;candidate_id:string;revision:number;version:number;status:string;data_mode:"simulation";content:CandidateContent;observed_at:string;valid_until:string;published_at:string|null;is_latest:boolean;is_active_publication:boolean;reviews?:{id:string;decision:string;reason:string;input_version:number;created_at:string}[]}
export type Draft={content:CandidateContent;observed_at:string;valid_until:string}
export const states:Record<string,string>={DRAFT:"草稿",PENDING_REVIEW:"待审核",PUBLISHED:"已发布",RETURNED:"已退回",WITHDRAWN:"已撤回"}
export function getCandidates(signal?:AbortSignal,status=""){return request<Candidate[]>(`/api/admin/v1/candidates${status?`?status=${encodeURIComponent(status)}`:""}`,{signal})}