统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
This commit is contained in:
Generated
+2621
File diff suppressed because it is too large
Load Diff
@@ -27,5 +27,8 @@
|
||||
"tailwindcss": "4.2.4",
|
||||
"@tailwindcss/vite": "4.2.4",
|
||||
"tw-animate-css": "1.4.0"
|
||||
},
|
||||
"overrides": {
|
||||
"rollup": "4.62.4"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-5
@@ -5,6 +5,14 @@ import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } 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 {MarketPanel} from "@/components/MarketPanel"
|
||||
import {BacktestsPanel} from "@/components/BacktestsPanel"
|
||||
import {StrategiesPanel} from "@/components/StrategiesPanel"
|
||||
import {CandidatesPanel} from "@/components/CandidatesPanel"
|
||||
import {AuditPanel} from "@/components/AuditPanel"
|
||||
import {UsersPanel} from "@/components/UsersPanel"
|
||||
|
||||
const workspaces = [
|
||||
@@ -39,13 +47,13 @@ export default function App({user,onLogout,onSessionExpired}:{user:User;onLogout
|
||||
<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>
|
||||
<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>
|
||||
</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">
|
||||
{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 ? "查看服务连接与建设进度,从可靠的基础开始。" : "该工作区的业务功能正在建设中。"}</p></div>{overview && <Button variant="outline" onClick={()=>void refresh()} disabled={loading}><RefreshCw className={loading ? "animate-spin" : ""}/>{loading ? "检查中" : "刷新状态"}</Button>}</div>
|
||||
<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>
|
||||
{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">{[
|
||||
@@ -53,9 +61,9 @@ 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==="用户管理" ? <UsersPanel 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.1</span></footer>
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import {useEffect,useState} from "react"
|
||||
import {ApiError} from "@/lib/auth"
|
||||
import {getAuditEvents,type AuditEvent} from "@/lib/providers"
|
||||
import {Card,CardContent} from "@/components/ui/card"
|
||||
export function AuditPanel({onSessionExpired}:{onSessionExpired:()=>void}){
|
||||
const [events,setEvents]=useState<AuditEvent[]|null>(null)
|
||||
const [error,setError]=useState("")
|
||||
useEffect(()=>{const controller=new AbortController();getAuditEvents(controller.signal).then(setEvents).catch(e=>{if(controller.signal.aborted)return;if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof Error?e.message:"日志读取失败")});return()=>controller.abort()},[onSessionExpired])
|
||||
if(error)return <p role="alert" className="text-sm text-destructive">{error}</p>
|
||||
if(!events)return <p className="text-sm text-muted-foreground">正在读取日志…</p>
|
||||
return <Card><CardContent className="overflow-x-auto p-6"><p className="mb-5 text-sm text-muted-foreground">最近 100 条操作记录。密钥内容不写入日志。</p>{events.length===0?<p className="py-10 text-center text-sm text-muted-foreground">暂无操作记录</p>:<table className="w-full text-left text-sm"><thead><tr className="border-b text-muted-foreground"><th className="p-3">时间</th><th className="p-3">操作</th><th className="p-3">操作者</th><th className="p-3">对象</th><th className="p-3">记录详情</th></tr></thead><tbody>{events.map(event=><tr key={event.id} className="border-b align-top"><td className="whitespace-nowrap p-3">{new Date(event.created_at).toLocaleString("zh-CN")}</td><td className="p-3">{event.action==="PROVIDER_CONFIG_SAVED"?"保存供应商配置":event.action}</td><td className="p-3 text-xs">{event.actor_id}</td><td className="p-3">{event.object_type}<span className="mt-1 block text-xs text-muted-foreground">{event.object_id}</span></td><td className="p-3"><pre className="whitespace-pre-wrap text-xs">{JSON.stringify(event.details,null,2)}</pre></td></tr>)}</tbody></table>}</CardContent></Card>
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,40 @@
|
||||
import {useEffect,useRef,useState,type FormEvent} from "react"
|
||||
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<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}){
|
||||
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)))
|
||||
const [action,setAction]=useState(""),[reason,setReason]=useState("")
|
||||
const controller=useRef<AbortController|null>(null),detailController=useRef<AbortController|null>(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])
|
||||
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>
|
||||
{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>
|
||||
{([["观察逻辑","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">金额最多两位小数;风险价格 < 下限 ≤ 上限 < 目标价格。观察时间限最近 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="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>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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)
|
||||
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>
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {useEffect,useRef,useState} from "react"
|
||||
import {Button} from "./ui/button"
|
||||
import {Badge} from "./ui/badge"
|
||||
import {Card,CardContent,CardHeader,CardTitle,CardDescription} from "./ui/card"
|
||||
import {ApiError,request} from "@/lib/auth"
|
||||
type Task={id:string;event_type:string;aggregate_type:string;created_at:string;processed_at:string|null;recipient_count:number}
|
||||
export function NotificationTasksPanel({onSessionExpired}:{onSessionExpired:()=>void}){
|
||||
const [tasks,setTasks]=useState<Task[]>([]),[error,setError]=useState(""),[loading,setLoading]=useState(false),controller=useRef<AbortController|null>(null)
|
||||
async function refresh(){controller.current?.abort();const next=new AbortController();controller.current=next;setLoading(true);try{const list=await request<Task[]>("/api/admin/v1/notification-tasks",{signal:next.signal});if(!next.signal.aborted){setTasks(list);setError("")}}catch(e){if(!next.signal.aborted){if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof Error?e.message:"读取失败")}}finally{if(!next.signal.aborted)setLoading(false)}}
|
||||
useEffect(()=>{void refresh();const timer=setInterval(()=>void refresh(),10000);return()=>{clearInterval(timer);controller.current?.abort()}},[])
|
||||
return <Card><CardHeader><div className="flex items-center justify-between"><CardTitle>事件与站内通知任务</CardTitle><Button variant="outline" disabled={loading} onClick={()=>void refresh()}>刷新</Button></div><CardDescription>最近 100 条持久化事件。后台每 2 秒处理;已处理不代表发送了远程推送,部分事件无需通知用户。</CardDescription></CardHeader><CardContent>{error&&<p role="alert" className="mb-4 text-sm text-destructive">{error}</p>}<p className="mb-5 text-sm text-muted-foreground">本页范围内待处理 {tasks.filter(t=>!t.processed_at).length} 条 · 已处理 {tasks.filter(t=>t.processed_at).length} 条 · 站内通知 {tasks.reduce((n,t)=>n+t.recipient_count,0)} 条</p><div className="overflow-auto"><table className="w-full text-left text-sm"><thead><tr className="border-b text-muted-foreground"><th className="p-3">事件</th><th className="p-3">状态</th><th className="p-3">站内通知数</th><th className="p-3">产生时间</th></tr></thead><tbody>{tasks.map(t=><tr key={t.id} className="border-b"><td className="p-3">{t.event_type}<p className="mt-1 text-xs text-muted-foreground">{t.aggregate_type}</p></td><td className="p-3"><Badge variant="outline">{t.processed_at?"已处理":"待处理"}</Badge></td><td className="p-3">{t.recipient_count}</td><td className="p-3 text-muted-foreground">{new Date(t.created_at).toLocaleString("zh-CN")}</td></tr>)}</tbody></table>{tasks.length===0&&!loading&&<p className="py-10 text-center text-sm text-muted-foreground">暂无事件任务。</p>}</div></CardContent></Card>
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {useEffect,useState} from "react"
|
||||
import {ApiError} from "@/lib/auth"
|
||||
import {getProviders,saveProvider,type ProviderConfig,type ProviderKey} from "@/lib/providers"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Card,CardContent,CardHeader,CardTitle,CardDescription} from "@/components/ui/card"
|
||||
const titles:Record<ProviderKey,string>={model_primary:"主模型",model_backup:"备用模型",model_review:"审核模型",market_primary:"行情源"}
|
||||
export function ProvidersPanel({kind,onSessionExpired}:{kind:"model"|"market";onSessionExpired:()=>void}){
|
||||
const [configs,setConfigs]=useState<ProviderConfig[]>([])
|
||||
const [loading,setLoading]=useState(true)
|
||||
const [error,setError]=useState("")
|
||||
const [generation,setGeneration]=useState(0)
|
||||
const [notice,setNotice]=useState("")
|
||||
useEffect(()=>{const controller=new AbortController();setLoading(true);setError("");getProviders(controller.signal).then(setConfigs).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)});return()=>controller.abort()},[generation,onSessionExpired])
|
||||
const keys:ProviderKey[]=kind==="model"?["model_primary","model_review","model_backup"]:["market_primary"]
|
||||
if(loading)return <p className="text-sm text-muted-foreground">正在读取配置…</p>
|
||||
if(error)return <div role="alert" className="space-y-4"><p className="text-sm text-destructive">{error}</p><Button variant="outline" onClick={()=>setGeneration(v=>v+1)}>重新读取</Button></div>
|
||||
return <div className="space-y-6">{notice&&<p role="status" className="text-sm">{notice}</p>}<p className="text-sm leading-7 text-muted-foreground">供应商信息由管理员填写,初始留空。启用表示允许后续业务使用该配置,不代表连接已验证。{kind==="model"&&"模型接口按 OpenAI 兼容协议配置。"}</p>{keys.map(key=><ProviderForm key={`${key}:${configs.find(c=>c.config_key===key)?.version??0}`} configKey={key} config={configs.find(c=>c.config_key===key)} onSessionExpired={onSessionExpired} onSaved={saved=>{setConfigs(prev=>[...prev.filter(c=>c.config_key!==key),saved]);setNotice(`${titles[key]}配置已保存。`)}} onReload={()=>setGeneration(v=>v+1)}/>)}</div>
|
||||
}
|
||||
function ProviderForm({configKey,config,onSaved,onReload,onSessionExpired}:{configKey:ProviderKey;config?:ProviderConfig;onSaved:(c:ProviderConfig)=>void;onReload:()=>void;onSessionExpired:()=>void}){
|
||||
const [quoteURL,setQuoteURL]=useState(config?.quote_base_url??"")
|
||||
const [marketAdapter,setMarketAdapter]=useState(config?.market_adapter??""),[historyURL,setHistoryURL]=useState(config?.history_base_url??"")
|
||||
const [name,setName]=useState(config?.name??"")
|
||||
const [baseURL,setBaseURL]=useState(config?.base_url??"")
|
||||
const [modelName,setModelName]=useState(config?.model_name??"")
|
||||
const [wireAPI,setWireAPI]=useState<"responses"|"chat_completions">(config?.wire_api??"responses")
|
||||
const [catalogURL,setCatalogURL]=useState(config?.model_catalog_url??"")
|
||||
const [disableStorage,setDisableStorage]=useState(config?.disable_response_storage??true)
|
||||
const [requiresAuth,setRequiresAuth]=useState(config?.requires_openai_auth??true)
|
||||
const [apiKey,setAPIKey]=useState("")
|
||||
const [clearKey,setClearKey]=useState(false)
|
||||
const [enabled,setEnabled]=useState(config?.enabled??false)
|
||||
const [timeout,setTimeoutValue]=useState(config?.timeout_seconds??15)
|
||||
const [saving,setSaving]=useState(false)
|
||||
const [error,setError]=useState("")
|
||||
async function submit(event:React.FormEvent){event.preventDefault();setSaving(true);setError("");try{const result=await saveProvider(configKey,{quote_base_url:quoteURL,market_adapter:marketAdapter,history_base_url:historyURL,name,base_url:baseURL,model_name:modelName,wire_api:wireAPI,model_catalog_url:catalogURL,disable_response_storage:disableStorage,requires_openai_auth:requiresAuth,enabled,timeout_seconds:timeout,clear_api_key:clearKey,expected_version:config?.version??0,...(apiKey?{api_key:apiKey}:{})});setAPIKey("");setClearKey(false);onSaved(result)}catch(e){if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof ApiError&&e.status===409?"配置已被其他管理员修改,请重新读取。":e instanceof Error?e.message:"保存失败")}finally{setSaving(false)}}
|
||||
const fieldClass="space-y-2 text-sm"
|
||||
return <Card><CardHeader><div className="flex items-center justify-between"><CardTitle>{titles[configKey]}</CardTitle><Badge variant="outline">{config?.enabled?"已启用 · 未验证":"未启用"}</Badge></div><CardDescription>{config?`版本 ${config.version} · 更新于 ${new Date(config.updated_at).toLocaleString("zh-CN")}`:"尚未配置"}</CardDescription></CardHeader><CardContent><form onSubmit={submit} className="space-y-5"><fieldset disabled={saving} className="grid gap-5 sm:grid-cols-2">{configKey==="market_primary"&&<><label className={fieldClass}>行情适配器<select className="flex h-10 w-full rounded-md border bg-background px-3 text-sm" value={marketAdapter} onChange={e=>setMarketAdapter(e.target.value)}><option value="">尚未选择</option><option value="sina_public">新浪公开数据(开发研究)</option><option value="eastmoney_public">东方财富公开数据(开发研究)</option></select></label><label className={fieldClass}>报价地址(HTTPS)<Input type="url" value={quoteURL} onChange={e=>setQuoteURL(e.target.value)}/></label><label className={fieldClass}>历史行情地址(HTTPS)<Input type="url" value={historyURL} onChange={e=>setHistoryURL(e.target.value)}/></label></>}<label className={fieldClass}>供应商名称<Input value={name} onChange={e=>setName(e.target.value)} maxLength={80}/></label><label className={fieldClass}>接口地址(HTTPS)<Input value={baseURL} onChange={e=>setBaseURL(e.target.value)} type="url" maxLength={2048}/></label>{configKey!=="market_primary"&&<label className={fieldClass}>模型名称<Input value={modelName} onChange={e=>setModelName(e.target.value)} maxLength={120}/></label>}{configKey!=="market_primary"&&<><label className={fieldClass}>API 协议<select aria-label="API 协议" value={wireAPI} onChange={e=>setWireAPI(e.target.value as "responses"|"chat_completions")} className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm"><option value="responses">Responses</option><option value="chat_completions">Chat Completions</option></select></label><label className={`${fieldClass} sm:col-span-2`}>模型目录地址(同源 HTTPS,可留空)<Input value={catalogURL} onChange={e=>setCatalogURL(e.target.value)} type="url" maxLength={2048}/></label><label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={disableStorage} onChange={e=>setDisableStorage(e.target.checked)}/>禁止供应商存储响应</label><label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={requiresAuth} onChange={e=>setRequiresAuth(e.target.checked)}/>使用 API 密钥认证</label></>}<label className={fieldClass}>请求超时(秒)<Input value={timeout} onChange={e=>setTimeoutValue(Number(e.target.value))} type="number" min={1} max={30} required/></label><label className={`${fieldClass} sm:col-span-2`}>API 密钥<Input type="password" autoComplete="new-password" value={apiKey} onChange={e=>{setAPIKey(e.target.value);setClearKey(false)}} maxLength={4096} placeholder={config?.has_api_key?"已保存密钥;留空保留原值":"尚未填写"}/><span className="block text-xs leading-6 text-muted-foreground">密钥加密保存。更换接口地址时请重新填写或清除已有密钥。</span></label><label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={enabled} onChange={e=>setEnabled(e.target.checked)}/>启用此配置</label>{config?.has_api_key&&<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={clearKey} onChange={e=>{setClearKey(e.target.checked);if(e.target.checked)setAPIKey("")}}/>清除已保存密钥</label>}</fieldset>{error&&<div role="alert" className="space-y-3 text-sm text-destructive"><p>{error}</p>{error.includes("重新读取")&&<Button type="button" variant="outline" onClick={onReload}>重新读取</Button>}</div>}<Button type="submit" disabled={saving}>{saving?"正在保存…":"保存配置"}</Button></form></CardContent></Card>
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import {useEffect,useRef,useState,type FormEvent} from "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,request} from "@/lib/auth"
|
||||
type Quote={id:string;symbol:string;exchange:string;price:string;observed_at:string;version:number}
|
||||
export function SimulationQuotesPanel({onSessionExpired}:{onSessionExpired:()=>void}){
|
||||
const [quotes,setQuotes]=useState<Quote[]>([]),[symbol,setSymbol]=useState(""),[exchange,setExchange]=useState("SSE"),[price,setPrice]=useState(""),[error,setError]=useState(""),[busy,setBusy]=useState(false),controller=useRef<AbortController|null>(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;try{const result=await request<Quote[]>("/api/admin/v1/simulation-quotes",{signal:next.signal});if(!next.signal.aborted)setQuotes(result)}catch(e){if(!next.signal.aborted)failed(e)}}
|
||||
useEffect(()=>{void refresh();return()=>controller.current?.abort()},[])
|
||||
async function save(e:FormEvent){e.preventDefault();setBusy(true);setError("");try{await request("/api/admin/v1/simulation-quotes",{method:"POST",body:JSON.stringify({symbol,exchange,price,observed_at:new Date().toISOString()})});setPrice("");await refresh()}catch(e){failed(e)}finally{setBusy(false)}}
|
||||
return <Card className="mt-6"><CardHeader><div className="flex items-center gap-3"><CardTitle>模拟价格回放</CardTitle><Badge variant="outline">手工数据</Badge></div><CardDescription>用于验证计划条件和站内提醒。价格由此表单录入,不代表在线行情;触发不改变现金或持仓。</CardDescription></CardHeader><CardContent className="space-y-5"><form onSubmit={e=>void save(e)} className="grid items-end gap-4 sm:grid-cols-4"><label className="space-y-2 text-sm">证券代码<Input required value={symbol} pattern="[0-9]{6}" maxLength={6} disabled={busy} onChange={e=>setSymbol(e.target.value)}/></label><label className="space-y-2 text-sm">交易所<select value={exchange} disabled={busy} onChange={e=>setExchange(e.target.value)} className="h-9 w-full rounded-md border bg-background px-3"><option value="SSE">上交所</option><option value="SZSE">深交所</option></select></label><label className="space-y-2 text-sm">模拟价格(元)<Input required value={price} inputMode="decimal" disabled={busy} onChange={e=>setPrice(e.target.value)}/></label><Button type="submit" disabled={busy}>{busy?"提交中":"录入模拟价格"}</Button></form>{error&&<p role="alert" className="text-sm text-destructive">{error}</p>}<div className="overflow-auto"><table className="w-full text-left text-sm"><thead><tr className="border-b text-muted-foreground"><th className="p-3">证券</th><th className="p-3">价格</th><th className="p-3">观察时间</th><th className="p-3">版本</th></tr></thead><tbody>{quotes.map(q=><tr key={q.id} className="border-b"><td className="p-3">{q.exchange}:{q.symbol}</td><td className="p-3">¥{q.price}</td><td className="p-3 text-muted-foreground">{new Date(q.observed_at).toLocaleString("zh-CN")}</td><td className="p-3">{q.version}</td></tr>)}</tbody></table>{quotes.length===0&&<p className="py-6 text-center text-sm text-muted-foreground">尚未录入模拟价格。</p>}</div></CardContent></Card>
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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"
|
||||
export type Parameters={short_window:number;long_window:number;fee_bps:number;slippage_bps:number;lot_size:number}
|
||||
export type StrategyVersion={id:string;revision:number;algorithm:string;parameters:Parameters;description:string;created_at:string}
|
||||
export type Strategy={id:string;code:string;name:string;status:string;version:number;latest_revision:number;active_version_id:string|null}
|
||||
export type StrategyDetail=Strategy&{versions:StrategyVersion[]}
|
||||
const initial={code:"",name:"",description:"",short_window:"",long_window:"",fee_bps:"",slippage_bps:"",lot_size:""}
|
||||
const fieldClass="mt-2 w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
export function StrategiesPanel({onSessionExpired}:{onSessionExpired:()=>void}) {
|
||||
const [list,setList]=useState<Strategy[]>([]),[detail,setDetail]=useState<StrategyDetail|null>(null),[form,setForm]=useState(initial),[editing,setEditing]=useState(false),[error,setError]=useState(""),[busy,setBusy]=useState(false),[reason,setReason]=useState("")
|
||||
function fail(e:unknown){if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof Error?e.message:"操作失败")}
|
||||
async function refresh(){setList(await request<Strategy[]>("/api/admin/v1/strategies"))}
|
||||
useEffect(()=>{const c=new AbortController();request<Strategy[]>("/api/admin/v1/strategies",{signal:c.signal}).then(setList).catch(e=>{if(!c.signal.aborted)fail(e)});return()=>c.abort()},[])
|
||||
async function open(id:string){setError("");try{setDetail(await request<StrategyDetail>(`/api/admin/v1/strategies/${id}`));setEditing(false);setReason("")}catch(e){fail(e)}}
|
||||
function edit(){if(!detail)return;const v=detail.versions[0];setForm({code:detail.code,name:detail.name,description:v.description,...Object.fromEntries(Object.entries(v.parameters).map(([k,v])=>[k,String(v)]))} as typeof initial);setEditing(true)}
|
||||
async function save(){setBusy(true);setError("");try{const p:Parameters={short_window:Number(form.short_window),long_window:Number(form.long_window),fee_bps:Number(form.fee_bps),slippage_bps:Number(form.slippage_bps),lot_size:Number(form.lot_size)};if(Object.values(form).some(v=>!v.trim())||Object.values(p).some(v=>!Number.isSafeInteger(v)))throw new Error("请填写完整的整数参数和说明。");const result=await request<StrategyDetail>(detail?`/api/admin/v1/strategies/${detail.id}/versions`:"/api/admin/v1/strategies",{method:"POST",body:JSON.stringify({code:form.code,name:form.name,description:form.description,parameters:p,expected_version:detail?.version??0})});setDetail(result);setEditing(false);await refresh()}catch(e){fail(e)}finally{setBusy(false)}}
|
||||
async function deploy(status:string,versionID:string){if(!detail)return;setBusy(true);setError("");try{if(!reason.trim())throw new Error("请填写变更原因。");const result=await request<StrategyDetail>(`/api/admin/v1/strategies/${detail.id}/deploy`,{method:"POST",body:JSON.stringify({version_id:versionID,status,expected_version:detail.version,reason})});setDetail(result);setReason("");await refresh()}catch(e){fail(e)}finally{setBusy(false)}}
|
||||
return <div className="space-y-6"><p className="text-sm leading-7 text-muted-foreground">维护均线交叉研究参数与不可覆盖的版本。选用版本用于研究管理;当前手工候选和既有计划独立维护,暂停研究策略不会取消这些计划。</p>{error&&<p role="alert" className="text-sm text-destructive">{error}</p>}<div className="flex gap-3"><Button onClick={()=>{setDetail(null);setForm(initial);setEditing(true);setError("")}}>新建策略</Button><Button variant="outline" onClick={()=>void refresh().catch(fail)}>刷新</Button></div><div className="grid gap-6 xl:grid-cols-[1fr_1.5fr]"><Card><CardHeader><CardTitle>研究策略</CardTitle></CardHeader><CardContent className="space-y-3">{list.length===0&&<p className="text-sm text-muted-foreground">尚未录入策略。</p>}{list.map(s=><button key={s.id} onClick={()=>void open(s.id)} className="w-full rounded-lg border p-4 text-left hover:bg-muted"><strong>{s.name}</strong><p className="mt-2 text-xs text-muted-foreground">{s.code} · 修订 {s.latest_revision} · {s.status==="ACTIVE"?"已选用研究版本":"已暂停"}</p></button>)}</CardContent></Card><Card><CardHeader><CardTitle>{editing?(detail?"创建新版本":"新建策略"):detail?.name??"版本详情"}</CardTitle></CardHeader><CardContent>{editing?<form onSubmit={e=>{e.preventDefault();void save()}} className="space-y-4"><div className="grid gap-4 sm:grid-cols-2">{([["code","策略编码"],["name","策略名称"],["short_window","短均线窗口(≥2)"],["long_window","长均线窗口(≤200)"],["fee_bps","交易费率(基点)"],["slippage_bps","滑点(基点)"],["lot_size","交易数量单位"]] as const).map(([k,label])=><label key={k} className="text-sm">{label}<input required disabled={busy||(k==="code"&&!!detail)} className={fieldClass} value={form[k]} type={k==="code"||k==="name"?"text":"number"} min={0} step={1} onChange={e=>setForm({...form,[k]:e.target.value})}/></label>)}</div><label className="block text-sm">版本说明<textarea required className={fieldClass} rows={4} value={form.description} onChange={e=>setForm({...form,description:e.target.value})}/></label><div className="flex gap-3"><Button disabled={busy} type="submit">保存新版本</Button><Button type="button" variant="outline" disabled={busy} onClick={()=>setEditing(false)}>取消</Button></div></form>:detail?<div className="space-y-5"><Button variant="outline" onClick={edit}>修订参数</Button><label className="block text-sm">选用 / 暂停原因<textarea className={fieldClass} value={reason} onChange={e=>setReason(e.target.value)} maxLength={2000}/></label>{detail.status==="ACTIVE"&&<Button variant="outline" disabled={busy} onClick={()=>void deploy("PAUSED","")}>暂停研究策略</Button>}{detail.versions.map(v=><div key={v.id} className="rounded-lg border p-4"><div className="flex items-center justify-between"><strong>修订 {v.revision}</strong><span className="text-xs text-primary">{detail.active_version_id===v.id?(detail.status==="ACTIVE"?"当前选用":"暂停前版本"):"历史版本"}</span></div><p className="my-3 whitespace-pre-wrap text-sm">{v.description}</p><p className="text-xs leading-6 text-muted-foreground">均线 {v.parameters.short_window} / {v.parameters.long_window} · 费率 {v.parameters.fee_bps} 基点 · 滑点 {v.parameters.slippage_bps} 基点 · 单位 {v.parameters.lot_size}<br/>{new Date(v.created_at).toLocaleString("zh-CN")}</p><Button className="mt-3" variant="outline" disabled={busy||(detail.active_version_id===v.id&&detail.status==="ACTIVE")} onClick={()=>void deploy("ACTIVE",v.id)}>选用此研究版本</Button></div>)}</div>:<p className="text-sm text-muted-foreground">选择策略查看历史,或录入新的研究参数。</p>}</CardContent></Card></div></div>
|
||||
}
|
||||
@@ -3,7 +3,7 @@ type Envelope<T> = {data:T;meta:{request_id:string;server_time?:string}}
|
||||
export class ApiError extends Error {
|
||||
constructor(public status:number,public code:string,message:string) {super(message);this.name="ApiError"}
|
||||
}
|
||||
async function request<T>(path:string,init?:RequestInit):Promise<T> {
|
||||
export async function request<T>(path:string,init?:RequestInit):Promise<T> {
|
||||
const response=await fetch(path,{...init,credentials:"same-origin",headers:{Accept:"application/json",...(init?.body ? {"Content-Type":"application/json"} : {}),...init?.headers}})
|
||||
const data=await response.json()
|
||||
if (!response.ok) throw new ApiError(response.status,data.error?.code ?? "REQUEST_FAILED",data.error?.message ?? "请求失败,请稍后重试。")
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
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 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})}
|
||||
export function getCandidate(id:string,signal?:AbortSignal){return request<Candidate>(`/api/admin/v1/candidates/${id}`,{signal})}
|
||||
export function saveDraft(input:Draft,source?:Candidate){return request<Candidate>(source?`/api/admin/v1/candidates/${source.id}/revisions`:"/api/admin/v1/candidates",{method:"POST",body:JSON.stringify({...input,...(source?{expected_version:source.version}:{})})})}
|
||||
export function actOnCandidate(candidate:Candidate,action:string,reason:string){return request<Candidate>(`/api/admin/v1/candidates/${candidate.id}/${action}`,{method:"POST",body:JSON.stringify({expected_version:candidate.version,reason})})}
|
||||
@@ -0,0 +1,8 @@
|
||||
import {request} from "./auth"
|
||||
export type ProviderKey = "model_primary" | "model_backup" | "model_review" | "market_primary"
|
||||
export type ProviderConfig = {id:string;config_key:ProviderKey;quote_base_url?:string;market_adapter?:string;history_base_url?:string;name:string;base_url:string;model_name:string;wire_api:"responses"|"chat_completions";model_catalog_url:string;disable_response_storage:boolean;requires_openai_auth:boolean;enabled:boolean;timeout_seconds:number;has_api_key:boolean;version:number;last_test_status:string;updated_at:string}
|
||||
export type ProviderInput = {quote_base_url?:string;market_adapter?:string;history_base_url?:string;name:string;base_url:string;model_name:string;wire_api:"responses"|"chat_completions";model_catalog_url:string;disable_response_storage:boolean;requires_openai_auth:boolean;enabled:boolean;timeout_seconds:number;api_key?:string;clear_api_key:boolean;expected_version:number}
|
||||
export const getProviders = (signal?:AbortSignal)=>request<ProviderConfig[]>("/api/admin/v1/providers",{signal})
|
||||
export const saveProvider = (key:ProviderKey,input:ProviderInput)=>request<ProviderConfig>(`/api/admin/v1/providers/${key}`,{method:"PUT",body:JSON.stringify(input)})
|
||||
export type AuditEvent = {id:string;actor_id:string;action:string;object_type:string;object_id:string;request_id:string;details:Record<string,unknown>;created_at:string}
|
||||
export const getAuditEvents = (signal?:AbortSignal)=>request<AuditEvent[]>("/api/admin/v1/audit-events",{signal})
|
||||
Reference in New Issue
Block a user