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" import {Badge} from "./ui/badge" import {Card,CardContent,CardHeader,CardTitle,CardDescription} from "./ui/card" import {ApiError} from "@/lib/auth" import {actOnCandidate,getCandidate,getCandidates,saveDraft,states,type Candidate,type CandidateContent} from "@/lib/candidates" const control="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" const actions:Record={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,initialCandidateID}:{onSessionExpired:()=>void;initialCandidateID?:string}){ const [list,setList]=useState([]),[selected,setSelected]=useState(null),[status,setStatus]=useState("") const [error,setError]=useState(""),[busy,setBusy]=useState(false),[loading,setLoading]=useState(false),[editing,setEditing]=useState(false),[source,setSource]=useState() const [content,setContent]=useState(blank),[observed,setObserved]=useState(()=>localTime(new Date())),[until,setUntil]=useState(()=>localTime(new Date(Date.now()+86400000))) const [action,setAction]=useState(""),[reason,setReason]=useState("") const controller=useRef(null),detailController=useRef(null) 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
void refresh()}/>
模拟候选

自动筛选形成队列,审核股票依据后发布。

{error&&

{error}

} {editing&&{source?`创建第 ${source.revision+1} 次修订` : "录入观察候选"}原修订保留。证据、反证与参考价格需完整填写;此表单为手工录入;自动筛选结果从策略中心进入待审核。
void save(e)} className="space-y-5">
{([["证券代码","symbol"],["证券名称","name"],["候选标题","title"]] as const).map(([label,key])=>)}
{([["观察逻辑","thesis"],["反证与风险","counter_evidence"],["失效条件","invalidation"]] as const).map(([label,key])=>