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
+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>
}