统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
This commit is contained in:
@@ -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})}))
|
||||
@@ -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)}}
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user