feat: integrate AI candidate review and automatic publication

This commit is contained in:
2026-10-09 22:33:38 +08:00
parent 04dbeb2ac6
commit 86f1e7a897
46 changed files with 2460 additions and 2362 deletions
+1 -1
View File
@@ -3,4 +3,4 @@ import {Text,View} from "react-native"
import {AccountSummary} from "../../src/components/AccountSummary"
import {Body,Button,Panel,Screen,Title,styles} from "../../src/components/ui"
import {useSession} from "../../src/state/session"
export default function Home(){const {session}=useSession();return <Screen><View style={styles.row}><Title>见势 <Text style={{fontSize:18,fontWeight:"400"}}>· Veyra</Text></Title><Button title="提醒" secondary onPress={()=>router.push("/notifications")}/></View><Body>{session?.user.display_name},让判断建立在依据之上。</Body><Panel accent><Text style={styles.badge}>AI 观察</Text><Text style={styles.subtitle}>先理解,再行动。</Text><Body>研究与行情服务尚未接入,当前没有可供展示的在线分析。供应商由管理员在后台配置。</Body><Button title="发现机会" onPress={()=>router.push("/(tabs)/opportunities")}/></Panel><AccountSummary/><View style={styles.divider}/><Text style={styles.subtitle}>每一步,都可追溯</Text><Body>观察候选 → 查看证据 → 制订计划 → 条件提醒 → 记录成交 → 复盘。当前已开放模拟候选、计划预算、站内提醒与成交账本;自动行情和 AI 分析仍待接入。</Body></Screen>}
export default function Home(){const {session}=useSession();return <Screen><View style={styles.row}><Title>见势 <Text style={{fontSize:18,fontWeight:"400"}}>· Veyra</Text></Title><Button title="提醒" secondary onPress={()=>router.push("/notifications")}/></View><Body>{session?.user.display_name},让判断建立在依据之上。</Body><Panel accent><Text style={styles.badge}>AI 观察</Text><Text style={styles.subtitle}>先理解,再行动。</Text><Body>系统自动同步 A 股行情、按策略筛选候选,并由配置的模型分析与审核。通过规则校验的候选可在机会页查看,暂无通过结果时保持空列表。</Body><Button title="发现机会" onPress={()=>router.push("/(tabs)/opportunities")}/></Panel><AccountSummary/><View style={styles.divider}/><Text style={styles.subtitle}>每一步,都可追溯</Text><Body>观察候选 → 查看证据 → 制订计划 → 条件提醒 → 记录成交 → 复盘。当前已开放模拟候选、计划预算、站内提醒与成交账本;行情、自动筛选和 AI 审核由后台配置;计划与成交由你确认。</Body></Screen>}
+1 -1
View File
@@ -7,5 +7,5 @@ import type {Candidate} from "../../src/lib/candidates"
export default function Opportunities(){
const {session,client}=useSession(),router=useRouter()
const query=useQuery({queryKey:["candidates",session?.user.id],enabled:!!session,queryFn:({signal})=>client<Candidate[]>("/api/v1/candidates",{signal}),refetchInterval:30000})
return <Screen><Title>机会</Title><Body>从证据、反证与失效条件理解一个候选。</Body><Panel accent><Body>模拟研究候选 · 由后台自动日线筛选或手工录入,经人工审核后发布。自动筛选仅为研究线索。</Body></Panel><Button secondary title="查看自动同步的 A 股行情" onPress={()=>router.push("/market")}/><Button secondary title={query.isFetching?"刷新中…":"刷新候选"} disabled={query.isFetching} onPress={()=>void query.refetch()}/>{query.isPending&&<Body>正在读取已发布候选…</Body>}{query.error&&<ErrorMessage message={query.error.message}/>} {query.data?.length===0&&<EmptyState title="当前没有有效候选" detail="审核发布且未过期的模拟候选会显示在这里。"/>}{query.data?.map(c=><Panel key={c.id}><View style={styles.row}><Text style={styles.subtitle}>{c.content.name}</Text><Text style={styles.badge}>模拟候选</Text></View><Body>{c.content.exchange}:{c.content.symbol} · 修订 {c.revision}</Body><Text style={styles.subtitle}>{c.content.title}</Text><Body>{c.content.thesis}</Body><Body>有效截止 {new Date(c.valid_until).toLocaleString("zh-CN")}</Body><Button title="查看证据与风险" onPress={()=>router.push({pathname:"/candidates/[id]",params:{id:c.id}})}/></Panel>)}</Screen>
return <Screen><Title>机会</Title><Body>从证据、反证与失效条件理解一个候选。</Body><Panel accent><Body>模拟研究候选 · 由后台自动日线筛选或手工录入,经 AI 审核与规则校验自动发布,或由后台人工复核发布。自动筛选仅为研究线索。</Body></Panel><Button secondary title="查看自动同步的 A 股行情" onPress={()=>router.push("/market")}/><Button secondary title={query.isFetching?"刷新中…":"刷新候选"} disabled={query.isFetching} onPress={()=>void query.refetch()}/>{query.isPending&&<Body>正在读取已发布候选…</Body>}{query.error&&<ErrorMessage message={query.error.message}/>}{query.data?.length===0&&<EmptyState title="当前没有有效候选" detail="审核发布且未过期的模拟候选会显示在这里。"/>}{query.data?.map(c=><Panel key={c.id}><View style={styles.row}><Text style={styles.subtitle}>{c.content.name}</Text><Text style={styles.badge}>{c.ai_analysis?"AI 研究候选":"模拟候选"}</Text></View><Body>{c.content.exchange}:{c.content.symbol} · 修订 {c.revision}</Body><Text style={styles.subtitle}>{c.content.title}</Text><Body>{c.content.thesis}</Body><Body>有效截止 {new Date(c.valid_until).toLocaleString("zh-CN")}</Body><Button title="查看证据与风险" onPress={()=>router.push({pathname:"/candidates/[id]",params:{id:c.id}})}/></Panel>)}</Screen>
}
+1 -1
View File
@@ -13,5 +13,5 @@ export default function PortfolioScreen(){
const fills=useQuery({queryKey:["fills",session?.user.id,account?.id],enabled,queryFn:({signal})=>client<Fill[]>(`/api/v1/accounts/${account!.id}/fills`,{signal})})
const latest=fills.data?.find(f=>!f.reversed)
async function reverse(){if(!reversing)return;setBusy(true);setError("");try{await client(`/api/v1/fills/${reversing.id}/reverse`,{method:"POST",body:{reason}});setReversing(null);await Promise.all(["portfolio","fills","accounts","budget","plans"].map(key=>cache.invalidateQueries({queryKey:[key]})))}catch(e){setError(e instanceof Error?e.message:"冲正失败")}finally{setBusy(false)}}
return <Screen><Title>持仓与账本</Title><Body>当前账户:{account?.name??"未选择"}。现金与已实现盈亏来自记录的成交;行情未接入,市值和浮盈亏未知。</Body>{!account&&<Button title="选择账户" onPress={()=>router.push("/settings/accounts")}/>} {account?.mode==="manual_real"&&<EmptyState title="真实记录账本尚未接通" detail="当前成交与持仓闭环仅用于模拟账户,不展示预设资产或盈亏。"/>}{enabled&&<><Button title="记录模拟成交" onPress={()=>router.push("/fills/create")}/><Button secondary title="刷新账本" disabled={portfolio.isFetching||fills.isFetching||busy} onPress={()=>{void portfolio.refetch();void fills.refetch()}}/>{portfolio.error&&<ErrorMessage message={portfolio.error.message}/>} {portfolio.data&&<><Panel accent><Body>账本现金</Body><Text style={styles.money}>¥{portfolio.data.cash_balance}</Text><Body>累计已实现盈亏(含录入费用) ¥{portfolio.data.realized_pnl}</Body></Panel><Panel><Text style={styles.subtitle}>账本复盘 · 累计</Text><Body>未冲正成交 {portfolio.data.review.trade_count} 笔 · 卖出 {portfolio.data.review.sell_count} 笔</Body><Body>买入现金支出 ¥{portfolio.data.review.buy_outflow} · 卖出现金收入 ¥{portfolio.data.review.sell_inflow}</Body><Body>录入费用合计 ¥{portfolio.data.review.total_fees}</Body><Body>盈利卖出 {portfolio.data.review.profitable_sell_count} 笔 · 亏损卖出 {portfolio.data.review.losing_sell_count} 笔</Body><Body>按卖出记录统计,包含已录入费用;不等于独立策略胜率,也不包含未实现盈亏或尚未录入的费用。</Body></Panel>{portfolio.data.positions.length===0&&<EmptyState title="当前没有记录的持仓" detail="记录买入后建立持仓批次,卖出与冲正同步更新。"/>}{portfolio.data.positions.map(p=><Panel key={p.exchange+p.symbol}><Text style={styles.subtitle}>{p.name} · {p.symbol}</Text><Body>持仓 {p.quantity} 股 · 按模拟可卖时间可卖 {p.sellable_quantity} 股</Body><Body>剩余成本 ¥{p.cost_basis} · 市值与浮盈亏未知</Body><Button secondary title="记录卖出成交" disabled={p.sellable_quantity==="0"} onPress={()=>router.push({pathname:"/fills/create",params:{side:"SELL",symbol:p.symbol,exchange:p.exchange,name:p.name}})}/></Panel>)}</>}<Text style={styles.subtitle}>成交记录</Text><Body>最近 100 笔。仅允许冲正账户最新一笔未冲正成交,避免破坏后续成本分摊;冲正不自动恢复原计划。</Body>{fills.error&&<ErrorMessage message={fills.error.message}/>}<ErrorMessage message={error}/>{fills.data?.map(f=><Panel key={f.id}><Text style={styles.badge}>{f.reversed?"已冲正":f.side==="BUY"?"买入":"卖出"}</Text><Text style={styles.subtitle}>{f.name} · {f.symbol}</Text><Body>{f.quantity} 股 · ¥{f.price} / 股 · 费用 ¥{f.fee}</Body><Body>现金变动 ¥{f.cash_delta}{f.side==="SELL"?` · 已实现盈亏 ¥${f.realized_pnl}`:""}</Body><Body>{new Date(f.occurred_at).toLocaleString("zh-CN")}{f.note?` · ${f.note}`:""}</Body>{latest?.id===f.id&&!f.reversed&&<Button secondary title="纠错:冲正此记录" disabled={busy} onPress={()=>{setReversing(f);setReason("");setError("")}}/>}{reversing?.id===f.id&&<><Body>确认冲正此笔 {f.name} {f.quantity} 股成交?现金和持仓将反向恢复,原成交记录保留。</Body><Field label="冲正原因" value={reason} editable={!busy} onChangeText={setReason} multiline/><Button title="确认冲正" disabled={!reason.trim()} busy={busy} onPress={()=>void reverse()}/><Button secondary title="取消" disabled={busy} onPress={()=>setReversing(null)}/></>}</Panel>)}</>}</Screen>
return <Screen><Title>持仓与账本</Title><Body>当前账户:{account?.name??"未选择"}。现金与已实现盈亏来自记录的成交;行情未接入,市值和浮盈亏未知。</Body>{!account&&<Button title="选择账户" onPress={()=>router.push("/settings/accounts")}/>}{account?.mode==="manual_real"&&<EmptyState title="真实记录账本尚未接通" detail="当前成交与持仓闭环仅用于模拟账户,不展示预设资产或盈亏。"/>}{enabled&&<><Button title="记录模拟成交" onPress={()=>router.push("/fills/create")}/><Button secondary title="刷新账本" disabled={portfolio.isFetching||fills.isFetching||busy} onPress={()=>{void portfolio.refetch();void fills.refetch()}}/>{portfolio.error&&<ErrorMessage message={portfolio.error.message}/>}{portfolio.data&&<><Panel accent><Body>账本现金</Body><Text style={styles.money}>¥{portfolio.data.cash_balance}</Text><Body>累计已实现盈亏(含录入费用) ¥{portfolio.data.realized_pnl}</Body></Panel><Panel><Text style={styles.subtitle}>账本复盘 · 累计</Text><Body>未冲正成交 {portfolio.data.review.trade_count} 笔 · 卖出 {portfolio.data.review.sell_count} 笔</Body><Body>买入现金支出 ¥{portfolio.data.review.buy_outflow} · 卖出现金收入 ¥{portfolio.data.review.sell_inflow}</Body><Body>录入费用合计 ¥{portfolio.data.review.total_fees}</Body><Body>盈利卖出 {portfolio.data.review.profitable_sell_count} 笔 · 亏损卖出 {portfolio.data.review.losing_sell_count} 笔</Body><Body>按卖出记录统计,包含已录入费用;不等于独立策略胜率,也不包含未实现盈亏或尚未录入的费用。</Body></Panel>{portfolio.data.positions.length===0&&<EmptyState title="当前没有记录的持仓" detail="记录买入后建立持仓批次,卖出与冲正同步更新。"/>}{portfolio.data.positions.map(p=><Panel key={p.exchange+p.symbol}><Text style={styles.subtitle}>{p.name} · {p.symbol}</Text><Body>持仓 {p.quantity} 股 · 按模拟可卖时间可卖 {p.sellable_quantity} 股</Body><Body>剩余成本 ¥{p.cost_basis} · 市值与浮盈亏未知</Body><Button secondary title="记录卖出成交" disabled={p.sellable_quantity==="0"} onPress={()=>router.push({pathname:"/fills/create",params:{side:"SELL",symbol:p.symbol,exchange:p.exchange,name:p.name}})}/></Panel>)}</>}<Text style={styles.subtitle}>成交记录</Text><Body>最近 100 笔。仅允许冲正账户最新一笔未冲正成交,避免破坏后续成本分摊;冲正不自动恢复原计划。</Body>{fills.error&&<ErrorMessage message={fills.error.message}/>}<ErrorMessage message={error}/>{fills.data?.map(f=><Panel key={f.id}><Text style={styles.badge}>{f.reversed?"已冲正":f.side==="BUY"?"买入":"卖出"}</Text><Text style={styles.subtitle}>{f.name} · {f.symbol}</Text><Body>{f.quantity} 股 · ¥{f.price} / 股 · 费用 ¥{f.fee}</Body><Body>现金变动 ¥{f.cash_delta}{f.side==="SELL"?` · 已实现盈亏 ¥${f.realized_pnl}`:""}</Body><Body>{new Date(f.occurred_at).toLocaleString("zh-CN")}{f.note?` · ${f.note}`:""}</Body>{latest?.id===f.id&&!f.reversed&&<Button secondary title="纠错:冲正此记录" disabled={busy} onPress={()=>{setReversing(f);setReason("");setError("")}}/>}{reversing?.id===f.id&&<><Body>确认冲正此笔 {f.name} {f.quantity} 股成交?现金和持仓将反向恢复,原成交记录保留。</Body><Field label="冲正原因" value={reason} editable={!busy} onChangeText={setReason} multiline/><Button title="确认冲正" disabled={!reason.trim()} busy={busy} onPress={()=>void reverse()}/><Button secondary title="取消" disabled={busy} onPress={()=>setReversing(null)}/></>}</Panel>)}</>}</Screen>
}
+1 -1
View File
@@ -10,5 +10,5 @@ export default function CandidateDetail(){
const query=useQuery({queryKey:["candidate",session?.user.id,id],enabled:!!session&&typeof id==="string",queryFn:({signal})=>client<Candidate>(`/api/v1/candidates/${encodeURIComponent(id)}`,{signal}),refetchInterval:15000})
const c=query.data,router=useRouter()
async function open(url:string){setLinkError("");try{const parsed=new URL(url);if(parsed.protocol!=="https:"||parsed.username||parsed.password)throw new Error("证据链接无效。");await Linking.openURL(url)}catch{setLinkError("无法打开证据来源,请稍后重试。")}}
return <Screen><Title>{c?.content.name??"候选详情"}</Title><Button secondary title={query.isFetching?"刷新中…":"刷新状态"} disabled={query.isFetching} onPress={()=>void query.refetch()}/>{query.isPending&&<Body>正在读取修订…</Body>}{query.error&&<ErrorMessage message={query.error.message}/>} {c&&<><Text style={styles.badge}>模拟候选 · 修订 {c.revision}</Text>{!publicationAvailable(c)&&<Panel accent><Text style={styles.subtitle}>此候选已失效</Text><Body>{c.status==="WITHDRAWN"?c.withdrawal_reason||"管理员已撤回此修订。":"此修订已过期、被替代,或关联策略已暂停 / 切换版本。"}历史依据保留供查看。</Body></Panel>}<Button title="预览模拟买入计划" disabled={!publicationAvailable(c)||query.isFetching||!!query.error} onPress={()=>router.push({pathname:"/plans/create",params:{candidate_id:c.id}})}/><Text style={styles.subtitle}>{c.content.title}</Text><Body>{c.content.exchange}:{c.content.symbol} · 观察 {new Date(c.observed_at).toLocaleString("zh-CN")} · 有效截止 {new Date(c.valid_until).toLocaleString("zh-CN")}</Body><Panel>{c.screening&&<Body>自动筛选 · {c.screening.strategy_name} 修订 {c.screening.strategy_revision} · 日线 {c.screening.bar_date} · {c.screening.source_adapter} 配置版本 {c.screening.source_version}</Body>}<Text style={styles.subtitle}>观察逻辑</Text><Body>{c.content.thesis}</Body></Panel><Panel><Text style={styles.subtitle}>反证与风险</Text><Body>{c.content.counter_evidence}</Body><Text style={styles.subtitle}>失效条件</Text><Body>{c.content.invalidation}</Body></Panel><Text style={styles.subtitle}>证据来源</Text>{c.content.evidence.map((e,i)=><Panel key={i}><Text style={styles.subtitle}>{e.locator}</Text><Body>{e.summary}</Body><Button secondary title="打开原始来源" onPress={()=>void open(e.source_url)}/></Panel>)}<ErrorMessage message={linkError}/><Panel><Text style={styles.subtitle}>参考价格</Text><Body>区间 ¥{c.content.reference_plan.entry_low}–{c.content.reference_plan.entry_high}</Body><Body>风险价格 ¥{c.content.reference_plan.stop_loss} · 目标观察 ¥{c.content.reference_plan.target_price}</Body><Body>{c.screening&&c.screening.candidate_revision_id===c.id?"参考价格按后台策略规则从日线收盘价生成,经审核发布;不代表实时行情或已确认交易计划。":"参考价格经人工录入或修订,不代表实时行情或已确认交易计划。"}</Body></Panel></>}</Screen>
return <Screen><Title>{c?.content.name??"候选详情"}</Title><Button secondary title={query.isFetching?"刷新中…":"刷新状态"} disabled={query.isFetching} onPress={()=>void query.refetch()}/>{query.isPending&&<Body>正在读取修订…</Body>}{query.error&&<ErrorMessage message={query.error.message}/>}{c&&<><Text style={styles.badge}>模拟候选 · 修订 {c.revision}</Text>{!publicationAvailable(c)&&<Panel accent><Text style={styles.subtitle}>此候选已失效</Text><Body>{c.status==="WITHDRAWN"?c.withdrawal_reason||"管理员已撤回此修订。":"此修订已过期、被替代,或关联策略已暂停 / 切换版本。"}历史依据保留供查看。</Body></Panel>}<Button title="预览模拟买入计划" disabled={!publicationAvailable(c)||query.isFetching||!!query.error} onPress={()=>router.push({pathname:"/plans/create",params:{candidate_id:c.id}})}/>{c.ai_analysis&&<Panel accent><Text style={styles.subtitle}>AI 分析与审核</Text><Body>{c.ai_analysis.analysis?.summary}</Body><Body>风险:{c.ai_analysis.analysis?.risks}</Body><Body>复核:{c.ai_analysis.review?.summary}</Body><Body>资料缺口:{c.ai_analysis.analysis?.missing_information.join(";")||"未报告"}</Body><Body>{c.ai_analysis.primary_model} / {c.ai_analysis.review_model} · {new Date(c.ai_analysis.updated_at).toLocaleString("zh-CN")}</Body></Panel>}<Text style={styles.subtitle}>{c.content.title}</Text><Body>{c.content.exchange}:{c.content.symbol} · 观察 {new Date(c.observed_at).toLocaleString("zh-CN")} · 有效截止 {new Date(c.valid_until).toLocaleString("zh-CN")}</Body><Panel>{c.screening&&<Body>自动筛选 · {c.screening.strategy_name} 修订 {c.screening.strategy_revision} · 日线 {c.screening.bar_date} · {c.screening.source_adapter} 配置版本 {c.screening.source_version}</Body>}<Text style={styles.subtitle}>观察逻辑</Text><Body>{c.content.thesis}</Body></Panel><Panel><Text style={styles.subtitle}>反证与风险</Text><Body>{c.content.counter_evidence}</Body><Text style={styles.subtitle}>失效条件</Text><Body>{c.content.invalidation}</Body></Panel><Text style={styles.subtitle}>证据来源</Text>{c.content.evidence.map((e,i)=><Panel key={i}><Text style={styles.subtitle}>{e.locator}</Text><Body>{e.summary}</Body><Button secondary title="打开原始来源" onPress={()=>void open(e.source_url)}/></Panel>)}<ErrorMessage message={linkError}/><Panel><Text style={styles.subtitle}>参考价格</Text><Body>区间 ¥{c.content.reference_plan.entry_low}–{c.content.reference_plan.entry_high}</Body><Body>风险价格 ¥{c.content.reference_plan.stop_loss} · 目标观察 ¥{c.content.reference_plan.target_price}</Body><Body>{c.screening&&c.screening.candidate_revision_id===c.id?"参考价格按后台策略规则从日线收盘价生成,经审核发布;不代表实时行情或已确认交易计划。":"参考价格经人工录入或修订,不代表实时行情或已确认交易计划。"}</Body></Panel></>}</Screen>
}
+1 -1
View File
@@ -7,5 +7,5 @@ type Instrument={id:string;symbol:string;exchange:string;name:string;price:strin
export default function Market(){
const {session,client}=useSession(),[search,setSearch]=useState(""),[query,setQuery]=useState("")
const stocks=useQuery({queryKey:["market",session?.user.id,query],enabled:!!session,queryFn:({signal})=>client<Instrument[]>(`/api/v1/instruments?q=${encodeURIComponent(query)}`,{signal}),refetchInterval:60000})
return <Screen><Title>A 股行情</Title><Body>公开行情数据 · 开发研究。报价每五分钟同步,以下时间为供应商行情时间。休市、停牌或源不可用时,快照不代表当前可交易价格。</Body><TextInput accessibilityLabel="搜索股票" placeholder="股票代码或名称" value={search} onChangeText={setSearch} style={{borderWidth:1,borderColor:"#e5e5ec",borderRadius:12,padding:14,backgroundColor:"#fff",color:"#1b1b20"}}/><Button title="搜索" onPress={()=>setQuery(search.trim())}/><Button secondary disabled={stocks.isFetching} title={stocks.isFetching?"读取中…":"刷新行情"} onPress={()=>void stocks.refetch()}/>{stocks.error&&<ErrorMessage message={stocks.error.message}/>} {stocks.data?.length===0&&<EmptyState title="暂无行情" detail="行情源启用后,后台会自动同步;也可以更换搜索条件。"/>}{stocks.data?.map(v=><Panel key={v.id}><Text style={{fontSize:18,fontWeight:"600",color:"#1b1b20"}}>{v.name} · {v.symbol}</Text><Title>{v.price==="0.00"?"无有效报价":v.price+" 元"}</Title><Body>{v.exchange} · 行情时间 {new Date(v.observed_at).toLocaleString("zh-CN")}</Body>{Date.now()-Date.parse(v.observed_at)>5*60000&&<Body>历史快照,当前价格尚未更新。</Body>}</Panel>)}</Screen>
return <Screen><Title>A 股行情</Title><Body>公开行情数据 · 开发研究。报价每五分钟同步,以下时间为供应商行情时间。休市、停牌或源不可用时,快照不代表当前可交易价格。</Body><TextInput accessibilityLabel="搜索股票" placeholder="股票代码或名称" value={search} onChangeText={setSearch} style={{borderWidth:1,borderColor:"#e5e5ec",borderRadius:12,padding:14,backgroundColor:"#fff",color:"#1b1b20"}}/><Button title="搜索" onPress={()=>setQuery(search.trim())}/><Button secondary disabled={stocks.isFetching} title={stocks.isFetching?"读取中…":"刷新行情"} onPress={()=>void stocks.refetch()}/>{stocks.error&&<ErrorMessage message={stocks.error.message}/>}{stocks.data?.length===0&&<EmptyState title="暂无行情" detail="行情源启用后,后台会自动同步;也可以更换搜索条件。"/>}{stocks.data?.map(v=><Panel key={v.id}><Text style={{fontSize:18,fontWeight:"600",color:"#1b1b20"}}>{v.name} · {v.symbol}</Text><Title>{v.price==="0.00"?"无有效报价":v.price+" 元"}</Title><Body>{v.exchange} · 行情时间 {new Date(v.observed_at).toLocaleString("zh-CN")}</Body>{Date.now()-Date.parse(v.observed_at)>5*60000&&<Body>历史快照,当前价格尚未更新。</Body>}</Panel>)}</Screen>
}
+1 -1
View File
@@ -11,5 +11,5 @@ export default function Notifications(){
const {session,client}=useSession(),router=useRouter(),switchAccount=useSwitchAccount(),cache=useQueryClient(),[error,setError]=useState(""),[busy,setBusy]=useState(false)
const query=useQuery({queryKey:["notifications",session?.user.id],enabled:!!session,queryFn:({signal})=>client<Notification[]>("/api/v1/notifications",{signal}),refetchInterval:10000})
async function read(n:Notification,open=false){setBusy(true);setError("");try{await client(`/api/v1/notifications/${n.id}/read`,{method:"POST",body:{}});await cache.invalidateQueries({queryKey:["notifications"]});if(open&&n.plan_id){const plans=await client<Plan[]>("/api/v1/plans");const plan=plans.find(p=>p.id===n.plan_id);if(plan){await switchAccount(plan.account_id);router.push("/(tabs)/plans")}else setError("原计划不在当前列表中,请到计划页查看历史状态。")}}catch(e){setError(e instanceof Error?e.message:"操作失败")}finally{setBusy(false)}}
return <Screen><Title>提醒中心</Title><Body>站内提醒来自计划条件或候选状态变化。提醒不代表交易;打开后请核对最新状态。远程推送尚未接入。</Body><Button secondary title={query.isFetching?"刷新中…":"刷新提醒"} disabled={query.isFetching||busy} onPress={()=>void query.refetch()}/>{query.error&&<ErrorMessage message={query.error.message}/>}<ErrorMessage message={error}/>{query.data?.length===0&&<EmptyState title="暂无站内提醒" detail="模拟价格进入已确认计划的参考区间,或对应候选被撤回时,会记录提醒。"/>}{query.data?.map(n=><Panel key={n.id} accent={!n.read_at}><Text style={styles.subtitle}>{n.title}</Text><Body>{n.body}</Body><Body>{new Date(n.created_at).toLocaleString("zh-CN")} · {n.read_at?"已读":"未读"}</Body>{n.plan_id&&<Button title="核对当前计划" disabled={busy} onPress={()=>void read(n,true)}/>} {!n.read_at&&<Button secondary title="标记已读" disabled={busy} onPress={()=>void read(n)}/>}</Panel>)}</Screen>
return <Screen><Title>提醒中心</Title><Body>站内提醒来自计划条件或候选状态变化。提醒不代表交易;打开后请核对最新状态。远程推送尚未接入。</Body><Button secondary title={query.isFetching?"刷新中…":"刷新提醒"} disabled={query.isFetching||busy} onPress={()=>void query.refetch()}/>{query.error&&<ErrorMessage message={query.error.message}/>}<ErrorMessage message={error}/>{query.data?.length===0&&<EmptyState title="暂无站内提醒" detail="模拟价格进入已确认计划的参考区间,或对应候选被撤回时,会记录提醒。"/>}{query.data?.map(n=><Panel key={n.id} accent={!n.read_at}><Text style={styles.subtitle}>{n.title}</Text><Body>{n.body}</Body><Body>{new Date(n.created_at).toLocaleString("zh-CN")} · {n.read_at?"已读":"未读"}</Body>{n.plan_id&&<Button title="核对当前计划" disabled={busy} onPress={()=>void read(n,true)}/>}{!n.read_at&&<Button secondary title="标记已读" disabled={busy} onPress={()=>void read(n)}/>}</Panel>)}</Screen>
}
+1 -1
View File
@@ -15,5 +15,5 @@ export default function CreatePlan(){
const c=candidate.data,eligible=!!c&&publicationAvailable(c)&&account?.mode==="simulation",currentPreview=preview?.account_id===account?.id?preview:null
async function calculate(){if(!c||!account)return;setBusy(true);setError("");setPreview(null);try{setPreview(await client<Preview>("/api/v1/plan-previews",{method:"POST",body:{account_id:account.id,candidate_revision_id:c.id,expected_candidate_version:c.version,quantity,fee_budget:fee}}))}catch(e){setError(e instanceof Error?e.message:"预览失败");void candidate.refetch();void budget.refetch()}finally{setBusy(false)}}
async function confirm(){if(!currentPreview)return;setBusy(true);setError("");try{await client(`/api/v1/plan-previews/${currentPreview.id}/confirm`,{method:"POST",body:{}});await cache.invalidateQueries({queryKey:["plans"]});await cache.invalidateQueries({queryKey:["budget"]});router.replace("/(tabs)/plans")}catch(e){setError(e instanceof Error?e.message:"确认失败");setPreview(null);void candidate.refetch();void budget.refetch()}finally{setBusy(false)}}
return <Screen><Title>创建模拟买入计划</Title><Body>确认计划仅保留资金预算;提醒与计划不会产生交易或扣款。</Body>{candidate.error&&<ErrorMessage message={candidate.error.message}/>} {c&&<Panel><Text style={styles.subtitle}>{c.content.name} · {c.content.symbol}</Text><Body>候选修订 {c.revision} · 区间 ¥{c.content.reference_plan.entry_low}–{c.content.reference_plan.entry_high}</Body><Body>风险价格 ¥{c.content.reference_plan.stop_loss} · 有效截止 {new Date(c.valid_until).toLocaleString("zh-CN")}</Body></Panel>}{!account&&<><Body>请先创建或选择模拟账户。</Body><Button secondary title="账户管理" onPress={()=>router.push("/settings/accounts")}/></>}{account&&<Body>当前账户:{account.name} {account.mode!=="simulation"?"· 真实记录账户暂不支持模拟计划":""}</Body>}{c&&!publicationAvailable(c)&&<ErrorMessage message="此候选已失效,不能创建新计划。"/>}{budget.error&&<ErrorMessage message={budget.error.message}/>} {budget.data&&<Panel><Body>账本现金 ¥{budget.data.cash_balance}</Body><Body>其他计划保留 ¥{budget.data.reserved_amount} · 可用 ¥{budget.data.available_amount}</Body></Panel>}<Field label="计划数量(模拟规则:100 股整数倍)" keyboardType="number-pad" editable={!busy} value={quantity} onChangeText={v=>{setQuantity(v);setPreview(null)}}/><Field label="预计费用预算(元,允许 0)" keyboardType="decimal-pad" editable={!busy} value={fee} onChangeText={v=>{setFee(v);setPreview(null)}}/><ErrorMessage message={error}/><Button title="预览资金预算" disabled={!eligible||!quantity||!fee} busy={busy} onPress={()=>void calculate()}/>{currentPreview&&<Panel accent><Text style={styles.subtitle}>确认前核对</Text><Body>数量 {currentPreview.quantity} 股 · 预计费用 ¥{currentPreview.fee_budget}</Body><Body>按区间上限保留 ¥{currentPreview.reserved_amount},预览截止 {new Date(currentPreview.expires_at).toLocaleTimeString("zh-CN")}</Body><Body>确认时会重新检查候选版本与现金;确认后不代表成交,实际发生交易后另行记录。</Body><Button title="确认计划并保留预算" disabled={!eligible||Date.parse(currentPreview.expires_at)<=Date.now()} busy={busy} onPress={()=>void confirm()}/></Panel>}</Screen>
return <Screen><Title>创建模拟买入计划</Title><Body>确认计划仅保留资金预算;提醒与计划不会产生交易或扣款。</Body>{candidate.error&&<ErrorMessage message={candidate.error.message}/>}{c&&<Panel><Text style={styles.subtitle}>{c.content.name} · {c.content.symbol}</Text><Body>候选修订 {c.revision} · 区间 ¥{c.content.reference_plan.entry_low}–{c.content.reference_plan.entry_high}</Body><Body>风险价格 ¥{c.content.reference_plan.stop_loss} · 有效截止 {new Date(c.valid_until).toLocaleString("zh-CN")}</Body></Panel>}{!account&&<><Body>请先创建或选择模拟账户。</Body><Button secondary title="账户管理" onPress={()=>router.push("/settings/accounts")}/></>}{account&&<Body>当前账户:{account.name} {account.mode!=="simulation"?"· 真实记录账户暂不支持模拟计划":""}</Body>}{c&&!publicationAvailable(c)&&<ErrorMessage message="此候选已失效,不能创建新计划。"/>}{budget.error&&<ErrorMessage message={budget.error.message}/>}{budget.data&&<Panel><Body>账本现金 ¥{budget.data.cash_balance}</Body><Body>其他计划保留 ¥{budget.data.reserved_amount} · 可用 ¥{budget.data.available_amount}</Body></Panel>}<Field label="计划数量(模拟规则:100 股整数倍)" keyboardType="number-pad" editable={!busy} value={quantity} onChangeText={v=>{setQuantity(v);setPreview(null)}}/><Field label="预计费用预算(元,允许 0)" keyboardType="decimal-pad" editable={!busy} value={fee} onChangeText={v=>{setFee(v);setPreview(null)}}/><ErrorMessage message={error}/><Button title="预览资金预算" disabled={!eligible||!quantity||!fee} busy={busy} onPress={()=>void calculate()}/>{currentPreview&&<Panel accent><Text style={styles.subtitle}>确认前核对</Text><Body>数量 {currentPreview.quantity} 股 · 预计费用 ¥{currentPreview.fee_budget}</Body><Body>按区间上限保留 ¥{currentPreview.reserved_amount},预览截止 {new Date(currentPreview.expires_at).toLocaleTimeString("zh-CN")}</Body><Body>确认时会重新检查候选版本与现金;确认后不代表成交,实际发生交易后另行记录。</Body><Button title="确认计划并保留预算" disabled={!eligible||Date.parse(currentPreview.expires_at)<=Date.now()} busy={busy} onPress={()=>void confirm()}/></Panel>}</Screen>
}