feat: 完成模拟交易闭环、App 与 A 股行情接入
Checks / verify (push) Failing after 9m52s

统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
This commit is contained in:
2026-10-08 22:58:52 +08:00
parent bffdb3858b
commit 92329ddce2
145 changed files with 18593 additions and 587 deletions
+4
View File
@@ -0,0 +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=
+6
View File
@@ -0,0 +1,6 @@
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli
+37
View File
@@ -0,0 +1,37 @@
# 见势 · Veyra App
React Native + Expo + TypeScript,白底、石墨黑与柔紫。源码路由采用 Expo Router,服务端查询使用 TanStack Query,当前账户使用 Zustand。
当前已接通开发登录、原生安全会话恢复/过期/退出、账户查询、创建和切换。五个主导航与提醒入口已建立;候选、计划、成交、持仓明细、通知、偏好与复盘业务尚未开放,页面明确展示实际状态,不展示原型股票或预设收益。
## 开发运行
在仓库根目录执行 `npm --prefix apps/mobile ci`,使用 Node 22.18 或更高版本(测试使用 Node 的 TypeScript 支持)。Go 后端初始化完成后运行 `go run . -command user-create -role USER` 创建 App 的 USER 测试身份,再运行 `go run .`。管理员身份无法登录 App,登录信息从本地环境配置获取,不打包到客户端。
```sh
cp apps/mobile/.env.example apps/mobile/.env
npm --prefix apps/mobile run start
```
填写 `apps/mobile/.env` 中 `EXPO_PUBLIC_API_URL`:
- iOS 模拟器:`http://127.0.0.1:8080`。
- Android 默认模拟器:`http://10.0.2.2:8080`。
- 真机:开发电脑的内网 IP,例如 `http://<开发电脑内网IP>:8080`,后端 `HTTP_ADDR` 需绑定可访问的监听地址;与数据库地址无关。
Expo 的 `i` / `a` 可打开已经安装的 iOS / Android 模拟器,也可用兼容 SDK 的 Expo Go 扫码运行。地址变化后重启 Metro。接口地址留空时会明确提示未配置。正式环境应配置 HTTPS;当前开发密码接口只在 local/test 后端开放。
`EXPO_PUBLIC_*` 会进入客户端包,只允许公开 API 地址,禁止放入数据库、Redis、模型或行情源密钥。供应商由管理员在后台配置,App 不直接调用供应商。
## 验证与产物
```sh
npm --prefix apps/mobile run typecheck && npm --prefix apps/mobile run test
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 SecureStore](https://docs.expo.dev/versions/v55.0.0/sdk/securestore/),不会写进 Zustand、URL 或日志。退出撤销服务端会话;网络中断保留原始会话供恢复,401 或到期清理身份与查询缓存。旧会话的迟到响应不会污染新登录用户。切换账户会中断旧查询、清理账户详情缓存;金额使用十进制字符串与整数校验,不做浮点账本计算。
+10
View File
@@ -0,0 +1,10 @@
{
"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}
}
}
+12
View File
@@ -0,0 +1,12 @@
import {Tabs} from "expo-router"
import Ionicons from "@expo/vector-icons/Ionicons"
import {colors} from "../../src/components/ui"
export default function TabLayout(){
return <Tabs screenOptions={{headerShown:false,tabBarActiveTintColor:colors.accent,tabBarInactiveTintColor:colors.muted,tabBarStyle:{backgroundColor:colors.background,borderTopColor:colors.border},tabBarLabelStyle:{fontSize:11},tabBarItemStyle:{paddingTop:5}}}>
<Tabs.Screen name="index" options={{title:"首页",tabBarIcon:({color,size})=><Ionicons name="home-outline" color={color} size={size}/>}}/>
<Tabs.Screen name="opportunities" options={{title:"机会",tabBarIcon:({color,size})=><Ionicons name="compass-outline" color={color} size={size}/>}}/>
<Tabs.Screen name="plans" options={{title:"计划",tabBarIcon:({color,size})=><Ionicons name="list-outline" color={color} size={size}/>}}/>
<Tabs.Screen name="portfolio" options={{title:"持仓",tabBarIcon:({color,size})=><Ionicons name="pie-chart-outline" color={color} size={size}/>}}/>
<Tabs.Screen name="profile" options={{title:"我的",tabBarIcon:({color,size})=><Ionicons name="person-outline" color={color} size={size}/>}}/>
</Tabs>
}
+6
View File
@@ -0,0 +1,6 @@
import {router} from "expo-router"
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>}
+11
View File
@@ -0,0 +1,11 @@
import {Text,View} from "react-native"
import {useQuery} from "@tanstack/react-query"
import {useRouter} from "expo-router"
import {useSession} from "../../src/state/session"
import {Body,Button,EmptyState,ErrorMessage,Panel,Screen,Title,styles} from "../../src/components/ui"
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>
}
+16
View File
@@ -0,0 +1,16 @@
import {useState} from "react"
import {Text} from "react-native"
import {useQuery,useQueryClient} from "@tanstack/react-query"
import {useRouter} from "expo-router"
import {useSession} from "../../src/state/session"
import {useAccounts} from "../../src/state/accounts"
import {Body,Button,EmptyState,ErrorMessage,Panel,Screen,Title,styles} from "../../src/components/ui"
import {planStates,type Plan} from "../../src/lib/plans"
export default function Plans(){
const {session,client}=useSession(),{selected}=useAccounts(),router=useRouter(),cache=useQueryClient()
const [confirming,setConfirming]=useState<Plan|null>(null),[busy,setBusy]=useState(false),[error,setError]=useState("")
const query=useQuery({queryKey:["plans",session?.user.id,selected?.id],enabled:!!session,queryFn:({signal})=>client<Plan[]>("/api/v1/plans",{signal}),refetchInterval:15000})
const plans=query.data?.filter(p=>p.account_id===selected?.id)
async function cancel(){if(!confirming)return;setBusy(true);setError("");try{await client(`/api/v1/plans/${confirming.id}/cancel`,{method:"POST",body:{expected_version:confirming.version}});setConfirming(null);await query.refetch();await cache.invalidateQueries({queryKey:["budget"]})}catch(e){setError(e instanceof Error?e.message:"取消失败")}finally{setBusy(false)}}
return <Screen><Title>计划</Title><Body>{selected?`当前账户:${selected.name}`:"先选择账户,再从候选创建计划。"}计划保留预算,成交需另行记录。</Body><Button secondary title={query.isFetching?"刷新中…":"刷新计划"} disabled={query.isFetching||busy} onPress={()=>void query.refetch()}/>{query.error&&<ErrorMessage message={query.error.message}/>}<ErrorMessage message={error}/>{plans?.length===0&&<><EmptyState title="暂无交易计划" detail="从已发布候选查看依据,再预览数量与资金预算。"/><Button title="查看候选" onPress={()=>router.push("/(tabs)/opportunities")}/></>}{plans?.map(p=><Panel key={p.id}><Text style={styles.badge}>{planStates[p.status]??p.status} · 模拟计划</Text><Text style={styles.subtitle}>{p.candidate_content.name} · {p.candidate_content.symbol}</Text><Body>数量 {p.quantity} 股 · 已记录 {p.filled_quantity} 股</Body><Body>保留预算 ¥{p.reserved_amount} · 有效截止 {new Date(p.valid_until).toLocaleString("zh-CN")}</Body>{p.status!=="COMPLETED"&&p.status!=="CANCELLED"&&<Button title="记录此计划的实际模拟买入" onPress={()=>router.push({pathname:"/fills/create",params:{plan_id:p.id,symbol:p.candidate_content.symbol,exchange:p.candidate_content.exchange,name:p.candidate_content.name}})}/>}<Button secondary title="查看原始候选依据" onPress={()=>router.push({pathname:"/candidates/[id]",params:{id:p.candidate_revision_id}})}/>{["ACTIVE","PART_FILLED"].includes(p.status)&&<Button secondary title="取消计划" disabled={busy} onPress={()=>{setConfirming(p);setError("")}}/>}{confirming?.id===p.id&&<><Body>确认取消 {p.candidate_content.name} 的计划?保留额度将释放;已记录成交保留。</Body><Button title="确认取消" busy={busy} onPress={()=>void cancel()}/><Button secondary title="保留计划" disabled={busy} onPress={()=>setConfirming(null)}/></>}</Panel>)}</Screen>
}
+17
View File
@@ -0,0 +1,17 @@
import {useState} from "react"
import {Text} from "react-native"
import {useQuery,useQueryClient} from "@tanstack/react-query"
import {useRouter} from "expo-router"
import {useSession} from "../../src/state/session"
import {useAccounts} from "../../src/state/accounts"
import {Body,Button,EmptyState,ErrorMessage,Field,Panel,Screen,Title,styles} from "../../src/components/ui"
import type {Fill,Portfolio} from "../../src/lib/fills"
export default function PortfolioScreen(){
const {session,client}=useSession(),{selected:account}=useAccounts(),router=useRouter(),cache=useQueryClient(),enabled=!!session&&account?.mode==="simulation"
const [reversing,setReversing]=useState<Fill|null>(null),[reason,setReason]=useState(""),[busy,setBusy]=useState(false),[error,setError]=useState("")
const portfolio=useQuery({queryKey:["portfolio",session?.user.id,account?.id],enabled,queryFn:({signal})=>client<Portfolio>(`/api/v1/accounts/${account!.id}/portfolio`,{signal})})
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>
}
+6
View File
@@ -0,0 +1,6 @@
import {useState} from "react"
import {router} from "expo-router"
import {Text,View} from "react-native"
import {useSession} from "../../src/state/session"
import {Body,Button,ErrorMessage,Panel,Screen,Title,styles} from "../../src/components/ui"
export default function Profile(){const {session,logout}=useSession();const [busy,setBusy]=useState(false);const [error,setError]=useState("");async function signOut(){setBusy(true);setError("");try{await logout()}catch(e){setError(e instanceof Error?e.message:"退出失败,请重试。")}finally{setBusy(false)}}return <Screen><Title>我的</Title><Panel><View style={styles.row}><Text style={styles.subtitle}>{session?.user.display_name}</Text><Text style={styles.badge}>开发测试身份</Text></View><Body>会话有效期至 {session?new Date(session.expires_at).toLocaleString("zh-CN"):"—"}</Body></Panel><Button title="账户管理与切换" secondary onPress={()=>router.push("/settings/accounts")}/><Button title="提醒中心" secondary onPress={()=>router.push("/notifications")}/><View style={styles.divider}/><Text style={styles.subtitle}>关于见势</Text><Body>见势 · Veyra 帮助理解依据与记录决策。投资偏好、通知设置和复盘功能将随对应服务接入开放。</Body><ErrorMessage message={error}/><Button title="退出登录" busy={busy} onPress={()=>void signOut()}/></Screen>}
+15
View File
@@ -0,0 +1,15 @@
import {Stack} from "expo-router"
import {StatusBar} from "expo-status-bar"
import {SafeAreaProvider} from "react-native-safe-area-context"
import {SessionProvider,useSession} from "../src/state/session"
import {Body,Button,ErrorMessage,Screen,Title,colors} from "../src/components/ui"
function Routes(){
const {session,loading,restoreError,retry}=useSession()
if(loading)return <Screen><Title>见势 · Veyra</Title><Body>正在恢复会话…</Body></Screen>
if(restoreError)return <Screen><Title>连接暂时中断</Title><ErrorMessage message={restoreError}/><Button title="重新连接" onPress={retry}/></Screen>
return <Stack screenOptions={{headerStyle:{backgroundColor:colors.background},headerTintColor:colors.text,headerShadowVisible:false,contentStyle:{backgroundColor:colors.background}}}>
<Stack.Protected guard={!session}><Stack.Screen name="login" options={{headerShown:false}}/></Stack.Protected>
<Stack.Protected guard={!!session}><Stack.Screen name="(tabs)" options={{headerShown:false}}/><Stack.Screen name="settings/accounts" options={{title:"账户管理"}}/><Stack.Screen name="fills/create" options={{title:"成交录入"}}/><Stack.Screen name="plans/create" options={{title:"计划预算"}}/><Stack.Screen name="candidates/[id]" options={{title:"候选证据"}}/><Stack.Screen name="notifications" options={{title:"提醒中心"}}/></Stack.Protected>
</Stack>
}
export default function RootLayout(){return <SafeAreaProvider><SessionProvider><StatusBar style="dark"/><Routes/></SessionProvider></SafeAreaProvider>}
+14
View File
@@ -0,0 +1,14 @@
import {Linking,Text} from "react-native"
import {useLocalSearchParams,useRouter} from "expo-router"
import {useQuery} from "@tanstack/react-query"
import {useState} from "react"
import {useSession} from "../../src/state/session"
import {Body,Button,ErrorMessage,Panel,Screen,Title,styles} from "../../src/components/ui"
import {publicationAvailable,type Candidate} from "../../src/lib/candidates"
export default function CandidateDetail(){
const {id}=useLocalSearchParams<{id:string}>(),{session,client}=useSession(),[linkError,setLinkError]=useState("")
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><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>参考价格来自手工录入,当前不代表实时行情或已确认交易计划。</Body></Panel></>}</Screen>
}
+23
View File
@@ -0,0 +1,23 @@
import {useRef,useState} from "react"
import {Text,View} from "react-native"
import {useLocalSearchParams,useRouter} from "expo-router"
import {useQueryClient} from "@tanstack/react-query"
import * as Crypto from "expo-crypto"
import {useSession} from "../../src/state/session"
import {useAccounts} from "../../src/state/accounts"
import {Body,Button,ErrorMessage,Field,Panel,Screen,Title,styles} from "../../src/components/ui"
import type {Fill} from "../../src/lib/fills"
export default function RecordFill(){
const params=useLocalSearchParams<{plan_id?:string;symbol?:string;exchange?:string;name?:string;side?:string}>(),router=useRouter(),cache=useQueryClient(),{client}=useSession(),{selected:account}=useAccounts()
const [side,setSide]=useState(params.side==="SELL"?"SELL":"BUY"),[symbol,setSymbol]=useState(params.symbol??""),[exchange,setExchange]=useState(params.exchange??"SSE"),[name,setName]=useState(params.name??"")
const [quantity,setQuantity]=useState(""),[price,setPrice]=useState(""),[fee,setFee]=useState(""),[occurred,setOccurred]=useState(()=>new Date().toISOString()),[sellable,setSellable]=useState(""),[note,setNote]=useState("")
const [review,setReview]=useState(false),[busy,setBusy]=useState(false),[error,setError]=useState("")
const pending=useRef<{account_id:string;idempotency_key:string;[key:string]:unknown}|null>(null)
async function save(){if(!account)return;setBusy(true);setError("");try{
if(!pending.current)pending.current={account_id:account.id,plan_id:side==="BUY"&&params.plan_id?params.plan_id:null,idempotency_key:Crypto.randomUUID(),side,symbol,exchange,name,quantity,price,fee,occurred_at:new Date(occurred).toISOString(),sellable_at:side==="BUY"?new Date(sellable).toISOString():null,note}
await client<Fill>("/api/v1/fills",{method:"POST",body:pending.current});await Promise.all(["portfolio","fills","accounts","budget","plans"].map(key=>cache.invalidateQueries({queryKey:[key]})));router.replace("/(tabs)/portfolio")
}catch(e){setError(e instanceof Error?e.message:"录入失败,请重试。")}finally{setBusy(false)}}
async function check(){if(!pending.current)return;setBusy(true);setError("");try{const fills=await client<Fill[]>(`/api/v1/accounts/${pending.current.account_id}/fills`);if(fills.some(f=>f.idempotency_key===pending.current!.idempotency_key)){await cache.invalidateQueries();router.replace("/(tabs)/portfolio")}else{pending.current=null;setReview(false)}}catch(e){setError(e instanceof Error?e.message:"暂时无法核对,请重试。")}finally{setBusy(false)}}
const disabled=busy||!!pending.current
return <Screen><Title>记录模拟成交</Title><Panel accent><Body>只记录已发生的模拟成交,不提交券商订单。当前使用手工设定的模拟可卖时间,不自动推断交易日或真实 T+1。</Body></Panel><Body>当前账户:{account?.name??"未选择"}。成交录入按发生时间顺序;纠错采用冲正,原记录保留。</Body>{account?.mode!=="simulation"&&<ErrorMessage message="请先选择模拟账户;真实记录账本尚未接通。"/>}<View style={styles.row}><Button secondary={side!=="BUY"} title="买入" disabled={disabled||!!params.plan_id} onPress={()=>{setSide("BUY");setReview(false)}}/><Button secondary={side!=="SELL"} title="卖出" disabled={disabled||!!params.plan_id} onPress={()=>{setSide("SELL");setReview(false)}}/></View><View style={styles.row}><Button secondary={exchange!=="SSE"} title="上交所" disabled={disabled||!!params.plan_id} onPress={()=>{setExchange("SSE");setReview(false)}}/><Button secondary={exchange!=="SZSE"} title="深交所" disabled={disabled||!!params.plan_id} onPress={()=>{setExchange("SZSE");setReview(false)}}/></View>{([["证券代码",symbol,setSymbol],["证券名称",name,setName],["成交数量",quantity,setQuantity],["成交价格(元)",price,setPrice],["实际费用(元,允许 0)",fee,setFee],["成交时间(ISO 格式,含时区)",occurred,setOccurred]] as const).map(([label,value,set])=><Field key={label} label={label} value={value} editable={!disabled} autoCapitalize="none" onChangeText={v=>{set(v);setReview(false)}}/>)}{side==="BUY"&&<Field label="模拟可卖时间(ISO 格式,含时区)" value={sellable} editable={!disabled} autoCapitalize="none" onChangeText={v=>{setSellable(v);setReview(false)}}/>}<Field label="备注 / 偏离计划说明" value={note} editable={!disabled} multiline onChangeText={v=>{setNote(v);setReview(false)}}/><ErrorMessage message={error}/>{!review&&<Button title="核对成交记录" disabled={account?.mode!=="simulation"||!quantity||!price||!fee||!symbol||!name||side==="BUY"&&!sellable} onPress={()=>{setReview(true);setError("")}}/>}{review&&<Panel><Text style={styles.subtitle}>确认入账:{side==="BUY"?"买入":"卖出"} {name}</Text><Body>{exchange}:{symbol} · {quantity} 股 · ¥{price} / 股 · 费用 ¥{fee}</Body><Body>记录后会同步现金与持仓。买入使用现金并建立批次;卖出按 FIFO 分摊成本。已失效计划的实际成交仍可录入,需填写说明。</Body><Button title={pending.current?"重试同一笔记录":"确认成交入账"} busy={busy} onPress={()=>void save()}/>{pending.current?<Button secondary title="刷新账本核对提交结果" disabled={busy} onPress={()=>void check()}/>:<Button secondary title="返回修改" disabled={busy} onPress={()=>setReview(false)}/>}</Panel>}</Screen>
}
+9
View File
@@ -0,0 +1,9 @@
import {useState} from "react"
import {Text,View} from "react-native"
import {Body,Button,ErrorMessage,Field,Panel,Screen,Title,styles} from "../src/components/ui"
import {useSession} from "../src/state/session"
export default function Login(){
const {login}=useSession();const [name,setName]=useState("");const [password,setPassword]=useState("");const [busy,setBusy]=useState(false);const [error,setError]=useState("")
async function submit(){if(busy)return;setBusy(true);setError("");try{await login(name,password);setPassword("")}catch(e){setError(e instanceof Error?e.message:"登录失败,请重试。")}finally{setBusy(false)}}
return <Screen><View style={{height:42}}/><Text style={styles.badge}>从依据出发</Text><Title>见势 · Veyra</Title><Body>看清机会,理解风险。{"\n"}让每一步投资决策都有迹可循。</Body><Panel accent><Text style={styles.subtitle}>开发环境登录</Text><Body>使用开发测试身份连接服务。正式手机号登录将在身份服务完成后开放。</Body></Panel><Field label="登录名" value={name} onChangeText={setName} autoCapitalize="none" autoCorrect={false} autoComplete="username" maxLength={64} editable={!busy}/><Field label="密码" value={password} onChangeText={setPassword} secureTextEntry autoComplete="current-password" maxLength={72} editable={!busy} returnKeyType="go" onSubmitEditing={()=>void submit()}/><ErrorMessage message={error}/><Button title="登录" busy={busy} disabled={!name.trim()||!password} onPress={()=>void submit()}/><Body>账户、计划与成交记录均由服务端保存。请勿使用数据库账号作为 App 登录身份。</Body></Screen>
}
+11
View File
@@ -0,0 +1,11 @@
import {useState} from "react"
import {Text,TextInput} from "react-native"
import {useQuery} from "@tanstack/react-query"
import {Screen,Title,Body,Panel,Button,ErrorMessage,EmptyState} from "../src/components/ui"
import {useSession} from "../src/state/session"
type Instrument={id:string;symbol:string;exchange:string;name:string;price:string;observed_at:string;received_at:string}
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>
}
+15
View File
@@ -0,0 +1,15 @@
import {Text} from "react-native"
import {useQuery,useQueryClient} from "@tanstack/react-query"
import {useState} from "react"
import {useRouter} from "expo-router"
import {useSession} from "../src/state/session"
import {useSwitchAccount} from "../src/state/accounts"
import {Body,Button,EmptyState,ErrorMessage,Panel,Screen,Title,styles} from "../src/components/ui"
import type {Plan} from "../src/lib/plans"
type Notification={id:string;title:string;body:string;plan_id:string|null;read_at:string|null;created_at:string}
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>
}
+19
View File
@@ -0,0 +1,19 @@
import {useState} from "react"
import {Text} from "react-native"
import {useLocalSearchParams,useRouter} from "expo-router"
import {useQuery,useQueryClient} from "@tanstack/react-query"
import {useSession} from "../../src/state/session"
import {useAccounts} from "../../src/state/accounts"
import {Body,Button,ErrorMessage,Field,Panel,Screen,Title,styles} from "../../src/components/ui"
import {publicationAvailable,type Candidate} from "../../src/lib/candidates"
import type {Budget,Preview} from "../../src/lib/plans"
export default function CreatePlan(){
const {candidate_id}=useLocalSearchParams<{candidate_id:string}>(),{session,client}=useSession(),{selected:account}=useAccounts(),router=useRouter(),cache=useQueryClient()
const [quantity,setQuantity]=useState(""),[fee,setFee]=useState(""),[preview,setPreview]=useState<Preview|null>(null),[busy,setBusy]=useState(false),[error,setError]=useState("")
const candidate=useQuery({queryKey:["candidate",session?.user.id,candidate_id],enabled:!!session&&typeof candidate_id==="string",queryFn:({signal})=>client<Candidate>(`/api/v1/candidates/${encodeURIComponent(candidate_id)}`,{signal})})
const budget=useQuery({queryKey:["budget",session?.user.id,account?.id],enabled:account?.mode==="simulation",queryFn:({signal})=>client<Budget>(`/api/v1/accounts/${account!.id}/budget`,{signal})})
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>
}
+22
View File
@@ -0,0 +1,22 @@
import {useState} from "react"
import {useQueryClient} from "@tanstack/react-query"
import {Text,View} from "react-native"
import {useAccounts,useSwitchAccount} from "../../src/state/accounts"
import {useSession} from "../../src/state/session"
import {useAccountSelection} from "../../src/state/account"
import {formatMoney,validInitialCash} from "../../src/lib/money"
import type {Account} from "../../src/lib/types"
import {Body,Button,ErrorMessage,Field,Panel,Screen,Title,styles} from "../../src/components/ui"
export default function Accounts(){
const {client,session}=useSession();const {accounts,selected,isPending,isError,error:queryError,refetch}=useAccounts();const switchAccount=useSwitchAccount();const queryClient=useQueryClient()
const [mode,setMode]=useState<Account["mode"]>("simulation");const [name,setName]=useState("");const [cash,setCash]=useState("");const [error,setError]=useState("");const [notice,setNotice]=useState("");const [busy,setBusy]=useState(false)
const alreadyExists=accounts.some(account=>account.mode===mode)
async function submit(){if(busy||alreadyExists)return;setBusy(true);setError("");setNotice("");try{
if(!name.trim())throw new Error("请填写账户名称。")
if(mode==="simulation"&&!validInitialCash(cash))throw new Error("初始资金应大于 0,不超过 10 亿元,最多两位小数。")
const account=await client<Account>("/api/v1/accounts",{method:"POST",body:{mode,name:name.trim(),...(mode==="simulation"?{initial_cash:cash}:{})}})
useAccountSelection.getState().select(account.id);setName("");setCash("");setNotice("账户已创建,初始资金以服务端账本为准。");await queryClient.invalidateQueries({queryKey:["accounts",session?.user.id]})
}catch(e){setError(e instanceof Error?e.message:"创建账户失败,请重试。");void refetch()}finally{setBusy(false)}}
async function select(id:string){setError("");try{await switchAccount(id);setNotice("已切换账户。")}catch{setError("账户切换失败,请重试。")}}
return <Screen><Title>账户管理</Title><Body>模拟账户和真实记录账户独立保存,每种模式各可创建一个。</Body>{isPending?<Body>正在读取账户…</Body>:isError?<Panel><ErrorMessage message={queryError.message}/><Button title="重试" secondary onPress={()=>void refetch()}/></Panel>:accounts.map(account=><Panel key={account.id}><View style={styles.row}><Text style={styles.subtitle}>{account.name}</Text><Text style={styles.badge}>{account.mode==="simulation"?"模拟":"手工真实记录"}</Text></View><Body>账本现金:{formatMoney(account.cash_balance)}{account.cash_balance!==null?" 元":""}</Body><Button title={selected?.id===account.id?"当前账户":"切换到此账户"} secondary disabled={selected?.id===account.id||busy} onPress={()=>void select(account.id)}/></Panel>)}{notice&&<Text accessibilityRole="alert" style={{color:"#6554A5"}}>{notice}</Text>}<ErrorMessage message={error}/><View style={styles.divider}/><Text style={styles.subtitle}>创建账户</Text><View style={styles.row}><Button title="模拟账户" secondary={mode!=="simulation"} disabled={busy} onPress={()=>{setMode("simulation");setError("")}}/><Button title="真实记录" secondary={mode!=="manual_real"} disabled={busy} onPress={()=>{setMode("manual_real");setError("")}}/></View>{alreadyExists?<Body>该模式的账户已存在,可在上方切换使用。</Body>:<><Field label="账户名称" value={name} onChangeText={setName} maxLength={80} editable={!busy}/>{mode==="simulation"?<Field label="初始模拟资金(元)" value={cash} onChangeText={setCash} keyboardType="decimal-pad" maxLength={13} editable={!busy}/>:<Panel accent><Body>真实记录由你手工维护,未经券商核验。创建时不注入模拟资金,现金余额为未知。</Body></Panel>}<Button title={mode==="simulation"?"确认创建模拟账户":"确认创建真实记录账户"} busy={busy} disabled={isPending||isError||!name.trim()||(mode==="simulation"&&!cash)} onPress={()=>void submit()}/></>}</Screen>
}
+4
View File
@@ -0,0 +1,4 @@
module.exports = function (api) {
api.cache(true)
return { presets: ["babel-preset-expo"] }
}
+2
View File
@@ -0,0 +1,2 @@
const { getDefaultConfig } = require("expo/metro-config")
module.exports = getDefaultConfig(__dirname)
+9010
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@veyra/mobile",
"version": "0.1.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"typecheck": "tsc --noEmit",
"build": "expo export --platform all",
"test": "node --test tests/*.test.ts"
},
"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",
"zustand": "^5.0.0"
},
"devDependencies": {
"@types/react": "~19.2.0",
"typescript": "~5.9.2"
},
"type": "module",
"expo": {
"install": {
"exclude": [
"react"
]
}
},
"overrides": {
"react-server-dom-webpack": "19.2.4",
"react-dom": "19.2.4"
}
}
@@ -0,0 +1,12 @@
import {router} from "expo-router"
import {Text,View} from "react-native"
import {useAccounts} from "../state/accounts"
import {formatMoney} from "../lib/money"
import {Body,Button,EmptyState,ErrorMessage,Panel,styles} from "./ui"
export function AccountSummary(){
const {selected,isPending,isError,error,refetch,isRefetching}=useAccounts()
if(isPending)return <Body>正在读取账户…</Body>
if(isError)return <Panel><ErrorMessage message={error.message}/><Button title="重新读取账户" secondary busy={isRefetching} onPress={()=>void refetch()}/></Panel>
if(!selected)return <Panel><EmptyState title="从一个账户开始" detail="创建模拟账户练习决策,或使用真实记录账户记录自行发生的交易。"/><Button title="创建账户" onPress={()=>router.push("/settings/accounts")}/></Panel>
return <Panel><View style={styles.row}><Text style={styles.subtitle}>{selected.name}</Text><Text style={styles.badge}>{selected.mode==="simulation"?"模拟账户":"手工真实记录"}</Text></View><Body>{selected.mode==="simulation"?"账本可用现金(元)":"账本估算可用现金(元)"}</Body><Text style={styles.money}>{formatMoney(selected.cash_balance)}</Text><Body>{selected.mode==="manual_real"?"用户记录,未经券商核验。现金账本尚不完整,余额显示未知。":"现金由初始资金与账本流水计算;提醒不会生成成交。"}</Body><View style={styles.row}><Button title="管理账户" secondary onPress={()=>router.push("/settings/accounts")}/><Button title="刷新" secondary busy={isRefetching} onPress={()=>void refetch()}/></View></Panel>
}
+13
View File
@@ -0,0 +1,13 @@
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 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>}
export function Button({title,onPress,disabled=false,secondary=false,busy=false}:{title:string;onPress:()=>void;disabled?:boolean;secondary?:boolean;busy?:boolean}){return <Pressable accessibilityRole="button" accessibilityState={{disabled:disabled||busy,busy}} disabled={disabled||busy} onPress={onPress} style={({pressed})=>[styles.button,secondary&&styles.secondary,(disabled||busy||pressed)&&{opacity:0.55}]}>{busy?<ActivityIndicator color={secondary?colors.text:"#FFFFFF"}/>:<Text style={[styles.buttonText,secondary&&{color:colors.text}]}>{title}</Text>}</Pressable>}
export function Field({label,...props}:TextInputProps&{label:string}){return <View style={styles.field}><Text style={styles.label}>{label}</Text><TextInput accessibilityLabel={label} placeholderTextColor={colors.muted} style={styles.input} {...props}/></View>}
export function ErrorMessage({message}:{message:string}){return message?<Text accessibilityRole="alert" style={styles.error}>{message}</Text>:null}
export function EmptyState({title,detail}: {title:string;detail:string}){return <View style={styles.empty}><Text style={styles.subtitle}>{title}</Text><Body>{detail}</Body></View>}
export const styles=StyleSheet.create({safe:{flex:1,backgroundColor:colors.background},screen:{padding:24,paddingBottom:40,gap:22,flexGrow:1},title:{fontSize:29,fontWeight:"700",color:colors.text,lineHeight:38},subtitle:{fontSize:19,fontWeight:"600",color:colors.text},body:{fontSize:14,lineHeight:24,color:colors.muted},panel:{backgroundColor:colors.layer,padding:22,borderRadius:18,gap:14},button:{backgroundColor:colors.action,minHeight:50,paddingVertical:13,paddingHorizontal:18,borderRadius:12,alignItems:"center",justifyContent:"center"},secondary:{backgroundColor:colors.layer},buttonText:{fontSize:15,fontWeight:"600",color:"#FFFFFF"},field:{gap:10},label:{fontSize:14,color:colors.text},input:{minHeight:52,borderRadius:12,borderWidth:1,borderColor:colors.border,paddingHorizontal:15,paddingVertical:12,fontSize:16,color:colors.text,backgroundColor:"#FFFFFF"},error:{fontSize:14,color:colors.danger,lineHeight:23},empty:{paddingVertical:35,gap:12},row:{flexDirection:"row",alignItems:"center",justifyContent:"space-between",gap:12},money:{fontSize:34,fontWeight:"600",color:colors.text},badge:{fontSize:12,color:colors.accent,backgroundColor:colors.accentSoft,paddingHorizontal:10,paddingVertical:6,borderRadius:8,alignSelf:"flex-start"},divider:{height:1,backgroundColor:colors.border}})
+37
View File
@@ -0,0 +1,37 @@
export class ApiError extends Error {
readonly status:number
readonly code:string
constructor(status:number,code:string,message:string){super(message);this.name="ApiError";this.status=status;this.code=code}
}
export function normalizeBaseURL(value:string):string {
if(!value.trim())throw new ApiError(0,"NOT_CONFIGURED","请先配置 App 的 API 地址。")
let url:URL
try{url=new URL(value.trim())}catch{throw new ApiError(0,"INVALID_URL","API 地址格式不正确。")}
if(!["http:","https:"].includes(url.protocol)||url.username||url.password||url.search||url.hash||url.pathname!=="/")throw new ApiError(0,"INVALID_URL","API 地址应只包含协议、主机和端口。")
return url.origin
}
export type Client = <T>(path:string,options?:{method?:string;body?:unknown;signal?:AbortSignal})=>Promise<T>
export function createClient(baseURL:string,token?:string,onExpired?:()=>void,transport:typeof fetch=fetch,isCurrent:()=>boolean=()=>true):Client {
return async <T>(path:string,options:{method?:string;body?:unknown;signal?:AbortSignal}={}):Promise<T>=>{
const base=normalizeBaseURL(baseURL)
if(!path.startsWith("/api/v1/")||path.includes("?")||path.includes("#"))throw new ApiError(0,"INVALID_PATH","请求地址无效。")
const controller=new AbortController()
const cancel=()=>controller.abort(options.signal?.reason)
if(options.signal?.aborted)cancel();else options.signal?.addEventListener("abort",cancel,{once:true})
const timer=setTimeout(()=>controller.abort(),10000)
try{
const response=await transport(base+path,{method:options.method??"GET",signal:controller.signal,credentials:"omit",redirect:"error",headers:{Accept:"application/json",...(token?{Authorization:`Bearer ${token}`} : {}),...(options.body!==undefined?{"Content-Type":"application/json"}:{})},...(options.body!==undefined?{body:JSON.stringify(options.body)}:{})})
if(token&&!isCurrent())throw new ApiError(401,"SESSION_CHANGED","会话已变更,请重新进入。")
if(response.status===401)onExpired?.()
let envelope:unknown
try{envelope=await response.json()}catch{throw new ApiError(response.status,"INVALID_RESPONSE","服务响应格式异常,请稍后重试。")}
if(!response.ok){const data=envelope as {error?:{code?:string;message?:string}};throw new ApiError(response.status,data?.error?.code??"REQUEST_FAILED",data?.error?.message??"请求失败,请稍后重试。")}
if(!envelope||typeof envelope!=="object"||!("data" in envelope))throw new ApiError(response.status,"INVALID_RESPONSE","服务响应格式异常,请稍后重试。")
return (envelope as {data:T}).data
}catch(error){
if(error instanceof ApiError)throw error
if(options.signal?.aborted)throw error
throw new ApiError(0,"NETWORK_ERROR",controller.signal.aborted?"请求超时,请检查网络后重试。":"无法连接服务,请检查 API 地址与网络。")
}finally{clearTimeout(timer);options.signal?.removeEventListener("abort",cancel)}
}
}
+2
View File
@@ -0,0 +1,2 @@
export type Candidate={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}
+2
View File
@@ -0,0 +1,2 @@
export type Fill={id:string;account_id:string;plan_id:string|null;idempotency_key:string;side:"BUY"|"SELL";symbol:string;exchange:string;name:string;quantity:string;price:string;fee:string;cash_delta:string;realized_pnl:string;occurred_at:string;sellable_at:string|null;note:string;reversed:boolean}
export type Portfolio={account_id:string;cash_balance:string;realized_pnl:string;review:{trade_count:number;sell_count:number;profitable_sell_count:number;losing_sell_count:number;buy_outflow:string;sell_inflow:string;total_fees:string};positions:{symbol:string;exchange:string;name:string;quantity:string;sellable_quantity:string;cost_basis:string;market_value:string|null;unrealized_pnl:string|null}[]}
+13
View File
@@ -0,0 +1,13 @@
// Money stays a decimal string across the transport and rendering boundary.
export function formatMoney(value:string|null):string {
if(value===null)return "未知"
if(!/^\d+\.\d{2}$/.test(value))return "数据异常"
const [whole,fraction]=value.split(".")
return `${whole.replace(/\B(?=(\d{3})+(?!\d))/g,",")}.${fraction}`
}
export function validInitialCash(value:string):boolean {
if(!/^\d{1,10}(\.\d{1,2})?$/.test(value))return false
const [whole,fraction=""]=value.split(".")
const cents=BigInt(whole)*100n+BigInt(fraction.padEnd(2,"0"))
return cents>0n&&cents<=100000000000n
}
+5
View File
@@ -0,0 +1,5 @@
import type {Candidate} from "./candidates"
export type Preview={id:string;account_id:string;candidate_revision_id:string;quantity:string;reserved_amount:string;fee_budget:string;expires_at:string}
export type Plan={id:string;account_id:string;candidate_revision_id:string;quantity:string;filled_quantity:string;reserved_amount:string;status:string;version:number;valid_until:string;candidate_content:Candidate["content"]}
export type Budget={cash_balance:string;reserved_amount:string;available_amount:string}
export const planStates:Record<string,string>={ACTIVE:"等待条件",PART_FILLED:"部分成交",COMPLETED:"已记录完成",CANCELLED:"已取消",INVALIDATED:"已失效"}
+11
View File
@@ -0,0 +1,11 @@
import type {Session} from "./types.ts"
export function parseSession(value:unknown):Session|null {
if(!value||typeof value!=="object")return null
const data=value as Partial<Session>
if(typeof data.access_token!=="string"||!/^[A-Za-z0-9_-]{43}$/.test(data.access_token)||typeof data.expires_at!=="string"||!Number.isFinite(Date.parse(data.expires_at))||!data.user||typeof data.user.id!=="string"||!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(data.user.id)||typeof data.user.display_name!=="string"||data.user.status!=="ACTIVE")return null
return data as Session
}
export function createStorageQueue(){
let queue:Promise<void>=Promise.resolve()
return (operation:()=>Promise<void>)=>{const next=queue.catch(()=>{}).then(operation);queue=next;return next}
}
+11
View File
@@ -0,0 +1,11 @@
import * as SecureStore from "expo-secure-store"
import {parseSession} from "./session-data"
import type {Session} from "./types"
const key="veyra.app.session.v1"
export async function readSession():Promise<Session|null>{
const text=await SecureStore.getItemAsync(key)
if(!text)return null
try{return parseSession(JSON.parse(text))}catch{return null}
}
export const saveSession=(session:Session)=>SecureStore.setItemAsync(key,JSON.stringify(session),{keychainAccessible:SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY})
export const clearSession=()=>SecureStore.deleteItemAsync(key)
+4
View File
@@ -0,0 +1,4 @@
export type User = {id:string;display_name:string;status:"ACTIVE"|"DISABLED"}
export type Session = {user:User;access_token:string;expires_at:string}
export type Account = {id:string;user_id:string;mode:"simulation"|"manual_real";name:string;currency:"CNY";ledger_status:"COMPLETE"|"INCOMPLETE";cash_balance:string|null;created_at:string}
export type Health = {status:string;components:{postgresql:{status:string};redis:{status:string}};checked_at:string}
+2
View File
@@ -0,0 +1,2 @@
import {create} from "zustand"
export const useAccountSelection=create<{accountID:string|null;select:(id:string|null)=>void}>(set=>({accountID:null,select:accountID=>set({accountID})}))
+11
View File
@@ -0,0 +1,11 @@
import {useQuery,useQueryClient} from "@tanstack/react-query"
import {useSession} from "./session"
import {useAccountSelection} from "./account"
import type {Account} from "../lib/types"
export function useAccounts(){
const {session,client}=useSession();const selectedID=useAccountSelection(state=>state.accountID)
const query=useQuery({queryKey:["accounts",session?.user.id],enabled:!!session,queryFn:({signal})=>client<Account[]>("/api/v1/accounts",{signal})})
const accounts=query.data??[];const selected=accounts.find(account=>account.id===selectedID)??accounts[0]??null
return {...query,accounts,selected}
}
export function useSwitchAccount(){const queryClient=useQueryClient();return async(id:string)=>{await queryClient.cancelQueries();queryClient.removeQueries({predicate:query=>query.queryKey[0]!=="accounts"});useAccountSelection.getState().select(id)}}
+47
View File
@@ -0,0 +1,47 @@
import {createContext,useCallback,useContext,useEffect,useMemo,useRef,useState,type PropsWithChildren} from "react"
import {AppState} from "react-native"
import {QueryClient,QueryClientProvider,focusManager} from "@tanstack/react-query"
import {ApiError,createClient,type Client} from "../lib/api"
import {readSession,saveSession,clearSession} from "../lib/session-storage"
import {parseSession,createStorageQueue} from "../lib/session-data"
import type {Session,User} from "../lib/types"
import {useAccountSelection} from "./account"
const baseURL=process.env.EXPO_PUBLIC_API_URL??""
const SessionContext=createContext<{session:Session|null;loading:boolean;restoreError:string;client:Client;retry:()=>void;login:(name:string,password:string)=>Promise<void>;logout:()=>Promise<void>}|null>(null)
export function SessionProvider({children}:PropsWithChildren){
const [queryClient]=useState(()=>new QueryClient({defaultOptions:{queries:{retry:(count,error)=>!(error instanceof ApiError&&[401,403,404,422].includes(error.status))&&count<1,staleTime:15000}}}))
const [session,setSession]=useState<Session|null>(null)
const [loading,setLoading]=useState(true)
const [restoreError,setRestoreError]=useState("")
const [attempt,setAttempt]=useState(0)
const active=useRef<string|null>(null)
const mounted=useRef(true)
// SecureStore writes must be ordered so an expired session cannot erase a newer login.
const [persist]=useState(()=>createStorageQueue())
const reset=useCallback(()=>{active.current=null;setSession(null);void queryClient.cancelQueries();queryClient.clear();useAccountSelection.getState().select(null);void persist(clearSession).catch(()=>setRestoreError("无法清除本机会话,请重试。"))},[persist,queryClient])
useEffect(()=>{mounted.current=true;return()=>{mounted.current=false}},[])
useEffect(()=>{
let cancelled=false;setLoading(true);setRestoreError("")
async function restore(){try{
const saved=await readSession();if(cancelled)return
if(!saved){setSession(null);return}
if(Date.parse(saved.expires_at)<=Date.now()){await persist(clearSession);return}
const user=await createClient(baseURL,saved.access_token)<User>("/api/v1/me")
if(!cancelled){active.current=saved.access_token;setSession({...saved,user})}
}catch(error){if(cancelled)return;if(error instanceof ApiError&&error.status===401){try{await persist(clearSession)}catch{setRestoreError("无法清除已过期的本机会话,请重试。")}}else setRestoreError(error instanceof Error?error.message:"会话恢复失败,请重试。")}finally{if(!cancelled)setLoading(false)}}
void restore();return()=>{cancelled=true}
},[attempt,persist])
const client=useMemo(()=>{const token=session?.access_token;return createClient(baseURL,token,()=>{if(token&&active.current===token&&mounted.current)reset()},fetch,()=>active.current===token)},[session?.access_token,reset])
useEffect(()=>{const subscription=AppState.addEventListener("change",state=>{focusManager.setFocused(state==="active");if(state==="active"&&session&&Date.parse(session.expires_at)<=Date.now())reset()});return()=>subscription.remove()},[session,reset])
useEffect(()=>{if(!session)return;const timer=setTimeout(reset,Math.max(0,Date.parse(session.expires_at)-Date.now()));return()=>clearTimeout(timer)},[session,reset])
async function login(name:string,password:string){
const response=await createClient(baseURL)<unknown>("/api/v1/auth/login",{method:"POST",body:{login_name:name.trim(),password}})
const result=parseSession(response)
if(!result)throw new ApiError(502,"INVALID_RESPONSE","服务返回的登录信息无效,请重试。")
try{await persist(()=>saveSession(result))}catch{await createClient(baseURL,result.access_token)("/api/v1/auth/logout",{method:"POST"}).catch(()=>{});throw new Error("无法安全保存会话,请检查设备后重试。")}
if(mounted.current){active.current=result.access_token;queryClient.clear();useAccountSelection.getState().select(null);setRestoreError("");setSession(result)}
}
async function logout(){await client("/api/v1/auth/logout",{method:"POST"});active.current=null;setSession(null);await queryClient.cancelQueries();queryClient.clear();useAccountSelection.getState().select(null);try{await persist(clearSession)}catch{setRestoreError("服务端会话已注销,但本机会话清理失败,请重试。")}}
return <QueryClientProvider client={queryClient}><SessionContext.Provider value={{session,loading,restoreError,client,retry:()=>setAttempt(v=>v+1),login,logout}}>{children}</SessionContext.Provider></QueryClientProvider>
}
export function useSession(){const value=useContext(SessionContext);if(!value)throw new Error("SessionProvider is required");return value}
+42
View File
@@ -0,0 +1,42 @@
import {test} from "node:test"
import assert from "node:assert/strict"
import {ApiError,createClient,normalizeBaseURL} from "../src/lib/api.ts"
test("API URL does not carry credentials or route state",()=>{
assert.equal(normalizeBaseURL(" https://api.example.com/ "),"https://api.example.com")
for(const value of ["","ftp://api.example.com","https://user:secret@api.example.com","https://api.example.com?token=secret","https://api.example.com/path"]){assert.throws(()=>normalizeBaseURL(value),ApiError)}
})
test("App uses its bearer token and the server confirmed envelope",async()=>{
let observed:RequestInit|undefined
const transport:typeof fetch=async(input,options)=>{assert.equal(input,"https://api.example.com/api/v1/accounts");observed=options;return new Response(JSON.stringify({data:[{cash_balance:"100000.01"}],meta:{request_id:"test"}}),{status:200})}
const client=createClient("https://api.example.com","app-token",undefined,transport)
const result=await client<Array<{cash_balance:string}>>("/api/v1/accounts",{method:"POST",body:{mode:"simulation",initial_cash:"100000.01"}})
assert.equal(result[0].cash_balance,"100000.01")
assert.equal(new Headers(observed?.headers).get("Authorization"),"Bearer app-token")
assert.equal(observed?.credentials,"omit");assert.equal(observed?.redirect,"error")
assert.equal(JSON.parse(observed?.body as string).initial_cash,"100000.01")
})
test("401 expires the session, infrastructure failures do not",async()=>{
let expired=0
for(const status of [503,401]){
const client=createClient("https://api.example.com","token",()=>expired++,async()=>new Response(JSON.stringify({error:{code:"TEST_FAILURE",message:"服务错误"}}),{status}))
await assert.rejects(()=>client("/api/v1/me"),(e:unknown)=>e instanceof ApiError&&e.status===status)
}
assert.equal(expired,1)
})
test("stale responses cannot expire or populate a newer session",async()=>{
let expired=0
const client=createClient("https://api.example.com","previous-token",()=>expired++,async()=>new Response(JSON.stringify({data:{id:"old-user"}}),{status:200}),()=>false)
await assert.rejects(()=>client("/api/v1/me"),(e:unknown)=>e instanceof ApiError&&e.code==="SESSION_CHANGED")
assert.equal(expired,0)
})
test("broken proxy response and private transport errors are sanitized",async()=>{
const client=createClient("https://api.example.com",undefined,undefined,async()=>new Response("<html>proxy</html>",{status:502}))
await assert.rejects(()=>client("/api/v1/me"),(e:unknown)=>e instanceof ApiError&&e.code==="INVALID_RESPONSE")
const failed=createClient("https://api.example.com",undefined,undefined,async()=>{throw new Error("private request details")})
await assert.rejects(()=>failed("/api/v1/me"),(e:unknown)=>e instanceof ApiError&&e.code==="NETWORK_ERROR"&&!e.message.includes("private"))
})
test("caller cancellation stays distinguishable from a network failure",async()=>{
const controller=new AbortController();controller.abort()
const transport:typeof fetch=async(_url,options)=>{assert.equal(options?.signal?.aborted,true);throw new DOMException("Aborted","AbortError")}
await assert.rejects(()=>createClient("https://api.example.com",undefined,undefined,transport)("/api/v1/accounts",{signal:controller.signal}),(e:unknown)=>e instanceof DOMException&&e.name==="AbortError")
})
+11
View File
@@ -0,0 +1,11 @@
import test from "node:test"
import assert from "node:assert/strict"
import {publicationAvailable} from "../src/lib/candidates.ts"
test("withdrawn, superseded and expired candidate links remain unavailable",()=>{
const now=Date.now(),candidate={status:"PUBLISHED",is_active_publication:true,valid_until:new Date(now+1000).toISOString()}
assert.equal(publicationAvailable(candidate,now),true)
assert.equal(publicationAvailable({...candidate,status:"WITHDRAWN"},now),false)
assert.equal(publicationAvailable({...candidate,is_active_publication:false},now),false)
assert.equal(publicationAvailable(candidate,now+1000),false)
assert.equal(publicationAvailable({...candidate,valid_until:"invalid"},now),false)
})
+13
View File
@@ -0,0 +1,13 @@
import {test} from "node:test"
import assert from "node:assert/strict"
import {formatMoney,validInitialCash} from "../src/lib/money.ts"
test("cash formatting preserves cents without floating point",()=>{
assert.equal(formatMoney("100000.01"),"100,000.01")
assert.equal(formatMoney("9007199254740993.09"),"9,007,199,254,740,993.09")
assert.equal(formatMoney(null),"未知")
assert.equal(formatMoney("invalid"),"数据异常")
})
test("initial cash validates decimal limits exactly",()=>{
for(const value of ["0.01","100000.01","1000000000.00"])assert.equal(validInitialCash(value),true,value)
for(const value of ["0","0.00","-1","1e4","1.001","1000000000.01","",".1"," 10"]){assert.equal(validInitialCash(value),false,value)}
})
+20
View File
@@ -0,0 +1,20 @@
import {test} from "node:test"
import assert from "node:assert/strict"
import {createStorageQueue,parseSession} from "../src/lib/session-data.ts"
const valid={access_token:"a".repeat(43),expires_at:"2026-12-01T10:00:00Z",user:{id:"7f8fd1ca-30e8-4dfb-82a9-8655fbd354ac",display_name:"测试用户",status:"ACTIVE"}}
test("session restoration requires a valid app credential and active identity",()=>{
assert.deepEqual(parseSession(valid),valid)
for(const value of [null,{}, {...valid,access_token:"admin cookie"},{...valid,expires_at:"invalid"},{...valid,user:{...valid.user,status:"DISABLED"}},{...valid,user:{...valid.user,id:"other"}}])assert.equal(parseSession(value),null)
})
test("ordered secret storage prevents expiration cleanup from erasing a newer login",async()=>{
const persist=createStorageQueue();let stored="old-session";const operations:string[]=[]
let release:()=>void=()=>{}
const barrier=new Promise<void>(resolve=>{release=resolve})
const clear=persist(async()=>{await barrier;stored="";operations.push("clear")})
const login=persist(async()=>{stored="new-session";operations.push("save")})
release();await Promise.all([clear,login]);assert.deepEqual(operations,["clear","save"]);assert.equal(stored,"new-session")
})
test("a failed storage operation does not prevent later recovery",async()=>{
const persist=createStorageQueue();await assert.rejects(persist(async()=>{throw new Error("storage failure")}))
let recovered=false;await persist(async()=>{recovered=true});assert.equal(recovered,true)
})
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": [
"app/**/*.tsx",
"src/**/*.ts",
"src/**/*.tsx",
"tests/**/*.ts",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}