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
+19
View File
@@ -0,0 +1,19 @@
build:
name: Veyra iPhone unsigned IPA for personal signing
steps:
- eas/checkout
- eas/install_node_modules
- run:
name: Validate App
command: npm run typecheck && npm run test
- eas/prebuild
- run:
name: Install CocoaPods dependencies
command: cd ios && pod install
- run:
name: Build and package unsigned device app
command: bash scripts/build-ios-unsigned.sh
- eas/upload_artifact:
inputs:
type: application-archive
path: artifacts/Veyra-unsigned.ipa
+1 -1
View File
@@ -1,4 +1,4 @@
# 仅公开 API 地址,禁止在 EXPO_PUBLIC_* 中存放数据库/Redis/供应商凭据。
# iOS 模拟器可用 http://127.0.0.1:8080,Android 模拟器使用 http://10.0.2.2:8080。
# 真机填写开发电脑的内网 IP,后端 HTTP_ADDR 需允许局域网访问。
EXPO_PUBLIC_API_URL=
EXPO_PUBLIC_API_URL=https://veyra.sundynix.cn
+39 -2
View File
@@ -2,7 +2,7 @@
React Native + Expo + TypeScript,白底、石墨黑与柔紫。源码路由采用 Expo Router,服务端查询使用 TanStack Query,当前账户使用 Zustand。
当前已接通开发登录、原生安全会话恢复/过期/退出、账户查询、创建和切换。五个主导航与提醒入口已建立;候选、计划、成交、持仓明细、通知、偏好与复盘业务尚未开放,页面明确展示实际状态,不展示原型股票或预设收益。
当前已接通开发登录、安全会话、账户、候选证据、计划预算、成交及冲正、持仓、站内通知和复盘。偏好、正式认证与远程推送仍待开发。未完成 iPhone 真机验证,尚未生成可安装 IPA。
## 开发运行
@@ -32,6 +32,43 @@ npm --prefix apps/mobile run build
类型检查与测试命令 检查 TypeScript 与 API/金额边界测试;构建命令 导出 iOS 和 Android Metro/Hermes bundle 到本目录 `dist`,验证路由和原生依赖可打包。导出包不等于 APK、IPA,也不代表已完成真机或商店验证;后续原生包需通过开发构建或 EAS/本地编译生成。
依赖按 [Expo SDK 配套版本](https://docs.expo.dev/versions/v55.0.0/)选取,原生模块通过 `expo install` 安装。当前锁定 SDK 55 / React Native 0.83.10 / React 19.2.4。管理端与 App 统一使用 React 19.2.4,分别独立安装依赖。SDK 55 默认建议 React 19.2.0;这里显式固定同 minor 的补丁版本并按 [Expo 版本比较配置](https://docs.expo.dev/versions/latest/config/package-json/#installexclude)配置 expo.install.exclude,仅排除 React 的建议版本比较。React Native 0.83.10 的公开 peer 约束是 ^19.2.0;继续执行类型、客户端、双平台 bundle 与依赖重复检查,真机验证仍待完成。App 的 package.json、锁文件和依赖全部位于本目录,Metro 与 Babel 使用本目录的 Expo Router。
依赖已按 Expo SDK 57 配套更新:expo 57.0.27、React Native 0.86.3、React 19.2.3。管理端依赖独立维护。类型检查、12 项测试、双平台 Hermes 导出、Expo Doctor 21/21 已通过;真机运行仍等待实际扫码验证。
会话使用 [Expo SecureStore](https://docs.expo.dev/versions/v55.0.0/sdk/securestore/),不会写进 Zustand、URL 或日志。退出撤销服务端会话;网络中断保留原始会话供恢复,401 或到期清理身份与查询缓存。旧会话的迟到响应不会污染新登录用户。切换账户会中断旧查询、清理账户详情缓存;金额使用十进制字符串与整数校验,不做浮点账本计算。
## iPhone 当前测试:Expo Go
手机 Expo Go 57.0.9 支持 SDK 57,项目已匹配。不需要本机 Xcode,也不生成独立 IPA。Mac 与手机连接同一 Wi-Fi,启动 Metro 后用 iPhone 相机扫码,允许 Expo Go 本地网络访问。
```sh
REACT_NATIVE_PACKAGER_HOSTNAME="$(ipconfig getifaddr en0)" npm --prefix apps/mobile run start -- --go --lan --port 8081
```
Expo Go 与电脑 CLI 必须登录同一个 Expo 账号。在 App 目录执行 `npx expo login --browser`,完成后重启 Metro;登录令牌由 CLI 本机保存,不加入 Git。
当前入口 `exp://192.168.100.2:8081`,二维码 `/tmp/veyra-expo57-qr.png`。电脑换网后重新获取 IP;使用 VPN 时显式指定真实局域网地址。App API 连接公开域名,但 Expo Go 仍需要本机 Metro 在线,不能当作独立安装包或域名直接下载。
## 后续独立安装(普通 Apple ID)
当前公开 API 为 `https://veyra.sundynix.cn`,已写入环境模板和本机私有配置;地址通过配置读取,不写死到请求代码。App 使用 USER 身份,管理员不能登录。
普通 Apple ID 可以通过 Xcode Personal Team 在自己的 iPhone 上运行 App,不需要上架。免费设备签名有效期为 7 天,到期需要重新安装。参见 [Apple 开发者账号说明](https://developer.apple.com/help/account/basics/about-your-developer-account)。
1. Mac 从 App Store 安装完整 Xcode,打开并完成首次初始化,在 Xcode Settings → Accounts 登录自己的 Apple ID。
2. 数据线连接 iPhone,信任电脑,按系统提示开启 Developer Mode。
3. 在仓库根目录执行 `npm --prefix apps/mobile run ios:device`,选择手机和个人开发团队。使用 Release 配置,安装完成后无需电脑 Metro 持续运行;后端通过 HTTPS 域名连接。
4. 若自动签名需要在 Xcode 中确认,在生成的 `apps/mobile/ios` 工程中选择目标,Signing & Capabilities 选择 Personal Team 并开启自动签名,再选择手机运行。
个人签名和生成的原生工程不提交。当前本机为 Intel / macOS 14.8.2,只有 Command Line Tools;完整 Xcode 还须符合 SDK 57 的系统要求。此路线尚未验证,当前先采用上述 Expo Go 测试。
Gitea Release 可以托管安装包,但下载 IPA 本身不会绕过 iOS 签名。tag 自动上传可安装 iOS 包仍需要 macOS/Xcode 构建环境与有效签名;现有 Linux 后端 CI 只检查 Hermes 导出,并未生成 IPA。当前采用 Expo Go 测试;tag 自动上传独立安装包尚未接入,不把 Hermes bundle 冒充 IPA。
## iPhone 安装包
Expo 云构建项目为 `@blizzard-zhang/veyra`。`ios-unsigned` 使用云端 Xcode 编译设备版本并嵌入 JS,生成 `Veyra-unsigned.ipa`,无需本机 Xcode或云端 Apple 签名凭据。该 IPA 未签名,不能直接从网页安装;普通 Apple ID 需要在个人签名工具中重签后安装,安装期限由 Apple 个人签名规则决定。`preview` 为有签名凭据时的内部安装构建,不上架。
```sh
npx eas-cli@24.12.1 build --platform ios --profile ios-unsigned
```
构建脚本必须在云端 macOS/Xcode 执行。Hermes 导出和模拟器 `.app` 均不是设备 IPA。不要将 `.env`、签名证书或后端配置上传到构建服务;首次实际云构建使用独立 App 源码目录上传。
+34 -8
View File
@@ -1,10 +1,36 @@
{
"expo":{
"name":"见势 · Veyra","slug":"veyra","scheme":"veyra","version":"0.1.0","orientation":"portrait","userInterfaceStyle":"light",
"platforms":["ios","android"],
"ios":{"supportsTablet":true,"bundleIdentifier":"cn.sundynix.veyra"},
"android":{"package":"cn.sundynix.veyra"},
"plugins":["expo-router","expo-secure-store"],
"experiments":{"typedRoutes":true}
}
"expo": {
"name": "见势 · Veyra",
"slug": "veyra",
"scheme": "veyra",
"version": "0.1.0",
"orientation": "portrait",
"userInterfaceStyle": "light",
"platforms": [
"ios",
"android"
],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "cn.sundynix.veyra"
},
"android": {
"package": "cn.sundynix.veyra"
},
"plugins": [
"expo-router",
"expo-secure-store",
"expo-font"
],
"experiments": {
"typedRoutes": true
},
"extra": {
"router": {},
"eas": {
"projectId": "b643afa5-a096-40ba-9fec-06ba1e645b41"
}
},
"owner": "blizzard-zhang"
}
}
+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>
}
+19
View File
@@ -0,0 +1,19 @@
{
"cli": {"version": ">=24.12.1", "appVersionSource": "local"},
"build": {
"ios-unsigned": {
"distribution": "internal",
"withoutCredentials": true,
"node": "22.22.0",
"env": {"EXPO_PUBLIC_API_URL": "https://veyra.sundynix.cn"},
"ios": {"image": "latest", "config": "ios-unsigned.yml", "simulator": false}
},
"preview": {
"distribution": "internal",
"node": "22.22.0",
"env": {"EXPO_PUBLIC_API_URL": "https://veyra.sundynix.cn"},
"ios": {"image": "latest"},
"android": {"buildType": "apk"}
}
}
}
+1082 -2294
View File
File diff suppressed because it is too large Load Diff
+21 -28
View File
@@ -9,41 +9,34 @@
"ios": "expo start --ios",
"typecheck": "tsc --noEmit",
"build": "expo export --platform all",
"test": "node --test tests/*.test.ts"
"test": "node --test tests/*.test.ts",
"ios:device": "expo run:ios --device --configuration Release"
},
"dependencies": {
"@expo/vector-icons": "^15.0.2",
"@tanstack/react-query": "^5.90.0",
"expo": "~55.0.0",
"expo-constants": "~55.0.17",
"expo-crypto": "~55.0.19",
"expo-linking": "~55.0.17",
"expo-router": "~55.0.18",
"expo-secure-store": "~55.0.18",
"expo-status-bar": "~55.0.6",
"react": "19.2.4",
"react-native": "0.83.10",
"react-native-gesture-handler": "~2.30.0",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0",
"react-native-worklets": "0.7.4",
"expo": "~57.0.27",
"expo-constants": "~57.0.21",
"expo-crypto": "~57.0.3",
"expo-font": "~57.0.4",
"expo-linking": "~57.0.12",
"expo-router": "~57.0.25",
"expo-secure-store": "~57.0.4",
"expo-status-bar": "~57.0.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.3",
"react-native-gesture-handler": "~2.32.0",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-worklets": "0.10.1",
"zustand": "^5.0.0"
},
"devDependencies": {
"@types/node": "^22.20.5",
"@types/react": "~19.2.0",
"typescript": "~5.9.2"
"typescript": "~6.0.3"
},
"type": "module",
"expo": {
"install": {
"exclude": [
"react"
]
}
},
"overrides": {
"react-server-dom-webpack": "19.2.4",
"react-dom": "19.2.4"
}
"type": "module"
}
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
# This is a device build with its JS bundle embedded, never a simulator archive.
workspace=$(find ios -maxdepth 1 -name '*.xcworkspace' -print -quit)
if [[ -z "$workspace" ]]; then echo 'iOS workspace missing after prebuild' >&2; exit 1; fi
scheme=$(basename "$workspace" .xcworkspace)
xcodebuild -workspace "$workspace" -scheme "$scheme" -configuration Release \
-sdk iphoneos -destination 'generic/platform=iOS' -derivedDataPath ios/unsigned-build \
CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY='' build
app_path=$(find ios/unsigned-build/Build/Products/Release-iphoneos -maxdepth 1 -name '*.app' -print -quit)
if [[ -z "$app_path" || ! -f "$app_path/main.jsbundle" ]]; then
echo 'Device app or embedded JS bundle missing' >&2; exit 1
fi
mkdir -p artifacts/Payload
cp -R "$app_path" artifacts/Payload/
(cd artifacts && zip -qry Veyra-unsigned.ipa Payload)
shasum -a 256 artifacts/Veyra-unsigned.ipa > artifacts/Veyra-unsigned.ipa.sha256
+1 -1
View File
@@ -2,7 +2,7 @@ import type {PropsWithChildren} from "react"
import {ActivityIndicator,KeyboardAvoidingView,Platform,Pressable,ScrollView,StyleSheet,Text,TextInput,View,type TextInputProps,type ViewStyle} from "react-native"
import {SafeAreaView} from "react-native-safe-area-context"
export const colors={background:"#FFFFFF",text:"#1B1B20",muted:"#74747F",layer:"#F5F5F7",action:"#202025",accent:"#6554A5",accentSoft:"#F1EDF9",border:"#EAEAF0",danger:"#C54655"}
export function Screen({children}:PropsWithChildren){return <SafeAreaView style={styles.safe} edges={["top","left","right"]}><KeyboardAvoidingView style={styles.safe} behavior={Platform.OS==="ios"?"padding":undefined}><ScrollView contentContainerStyle={styles.screen} keyboardShouldPersistTaps="handled">{children}</ScrollView></KeyboardAvoidingView></SafeAreaView>}
export function Screen({children}:PropsWithChildren){return <SafeAreaView style={styles.safe} edges={["top","left","right"]}><KeyboardAvoidingView style={styles.safe} behavior={Platform.OS==="ios"?"padding":undefined}><ScrollView showsVerticalScrollIndicator={false} showsHorizontalScrollIndicator={false} contentContainerStyle={styles.screen} keyboardShouldPersistTaps="handled">{children}</ScrollView></KeyboardAvoidingView></SafeAreaView>}
export function Title({children}:PropsWithChildren){return <Text accessibilityRole="header" style={styles.title}>{children}</Text>}
export function Body({children}:PropsWithChildren){return <Text style={styles.body}>{children}</Text>}
export function Panel({children,accent=false,style}:PropsWithChildren<{accent?:boolean;style?:ViewStyle}>){return <View style={[styles.panel,accent&&{backgroundColor:colors.accentSoft},style]}>{children}</View>}
+1 -1
View File
@@ -1,2 +1,2 @@
export type Candidate={screening?:{candidate_revision_id:string;strategy_version_id:string;strategy_name:string;strategy_revision:number;bar_date:string;source_adapter:string;source_version:number;input_hash:string};id:string;candidate_id:string;revision:number;version:number;status:string;data_mode:"simulation";valid_until:string;observed_at:string;is_active_publication:boolean;withdrawal_reason:string;content:{symbol:string;exchange:string;name: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={ai_analysis?:{status:string;primary_model:string;review_model:string;updated_at:string;analysis?:{summary:string;risks:string;missing_information:string[]};review?:{summary:string;risks:string;missing_information:string[]}};screening?:{candidate_revision_id:string;strategy_version_id:string;strategy_name:string;strategy_revision:number;bar_date:string;source_adapter:string;source_version:number;input_hash:string};id:string;candidate_id:string;revision:number;version:number;status:string;data_mode:"simulation";valid_until:string;observed_at:string;is_active_publication:boolean;withdrawal_reason:string;content:{symbol:string;exchange:string;name: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 function publicationAvailable(candidate:Pick<Candidate,"status"|"is_active_publication"|"valid_until">,now=Date.now()){return candidate.status==="PUBLISHED"&&candidate.is_active_publication&&Date.parse(candidate.valid_until)>now}
+32
View File
@@ -0,0 +1,32 @@
import test from "node:test"
import assert from "node:assert/strict"
import {readdirSync,readFileSync} from "node:fs"
import {join} from "node:path"
import {fileURLToPath} from "node:url"
import ts from "typescript"
// Native containers cannot render literal text, including a same-line JSX space.
const textComponents=new Set(["Text","Body","Title"])
function rawText(source:string,file="fixture.tsx"):string[]{
const ast=ts.createSourceFile(file,source,ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX),errors:string[]=[]
function visit(node:ts.Node){
if(ts.isJsxText(node)&&node.text&&(!/^\s*$/.test(node.text)||!/[\r\n]/.test(node.text))){
const parent=node.parent,name=ts.isJsxElement(parent)?parent.openingElement.tagName.getText(ast):"fragment"
if(!textComponents.has(name)){const {line}=ast.getLineAndCharacterOfPosition(node.pos);errors.push(`${file}:${line+1} ${name}: ${JSON.stringify(node.text)}`)}
}
ts.forEachChild(node,visit)
}
visit(ast);return errors
}
function files(dir:string):string[]{return readdirSync(dir,{withFileTypes:true}).flatMap(e=>e.isDirectory()?files(join(dir,e.name)):e.name.endsWith(".tsx")?[join(dir,e.name)]:[])}
test("native text check distinguishes literal spaces from formatting and Text content",()=>{
assert.equal(rawText('<View><Text>hello world</Text> <Text>next</Text></View>').length,1)
assert.equal(rawText('<View>bare text</View>').length,1)
assert.equal(rawText('<><Text>hello</Text> </>').length,1)
assert.deepEqual(rawText('<View>\n <Text>hello world</Text>\n <Body>body</Body>\n</View>'),[])
})
test("App screens and components contain no text outside text components",()=>{
const root=fileURLToPath(new URL("../",import.meta.url))
const errors=[...files(join(root,"app")),...files(join(root,"src"))].flatMap(file=>rawText(readFileSync(file,"utf8"),file))
assert.deepEqual(errors,[],errors.join("\n"))
})
+4 -1
View File
@@ -5,7 +5,10 @@
"noEmit": true,
"allowImportingTsExtensions": true,
"noUnusedLocals": true,
"noUnusedParameters": true
"noUnusedParameters": true,
"types": [
"node"
]
},
"include": [
"app/**/*.tsx",