11import { useEffect , useState , useCallback } from 'react'
2- import { RefreshCw , Power , RotateCcw , X , TrendingUp , TrendingDown , Trophy , BarChart3 , Wallet , Activity , Play } from 'lucide-react'
2+ import { RefreshCw , Power , RotateCcw , X , TrendingUp , TrendingDown , Trophy , BarChart3 , Wallet , Activity , Play , Bell } from 'lucide-react'
33import {
44 paperTradingApi ,
55 type PaperTradingAccountResponse ,
66 type PaperTradingPositionItem ,
77 type PaperTradingTradeItem ,
88 type EquityCurvePoint ,
99 type StrategyPerformanceItem ,
10+ type NotifyChannelItem ,
1011} from '@panwatch/api'
1112import { Button } from '@panwatch/base-ui/components/ui/button'
13+ import { Switch } from '@panwatch/base-ui/components/ui/switch'
14+ import { Dialog , DialogContent , DialogHeader , DialogTitle , DialogDescription } from '@panwatch/base-ui/components/ui/dialog'
1215import { useToast } from '@panwatch/base-ui/components/ui/toast'
1316
1417const EXIT_REASON_MAP : Record < string , string > = {
@@ -112,6 +115,17 @@ export default function PaperTradingPage() {
112115 const [ tradesPage , setTradesPage ] = useState ( 0 )
113116 const tradesPageSize = 20
114117
118+ // 通知设置
119+ const [ notifyOpen , setNotifyOpen ] = useState ( false )
120+ const [ notifyEnabled , setNotifyEnabled ] = useState ( false )
121+ const [ notifyRealtime , setNotifyRealtime ] = useState ( true )
122+ const [ notifyPremarket , setNotifyPremarket ] = useState ( true )
123+ const [ notifySummary , setNotifySummary ] = useState ( true )
124+ const [ notifyChannels , setNotifyChannels ] = useState < NotifyChannelItem [ ] > ( [ ] )
125+ const [ selectedChannelIds , setSelectedChannelIds ] = useState < Set < number > > ( new Set ( ) )
126+ const [ notifySaving , setNotifySaving ] = useState ( false )
127+ const [ notifyTesting , setNotifyTesting ] = useState ( false )
128+
115129 const loadData = useCallback ( async ( ) => {
116130 setLoading ( true )
117131 try {
@@ -181,6 +195,69 @@ export default function PaperTradingPage() {
181195 }
182196 }
183197
198+ const loadNotifySettings = async ( ) => {
199+ try {
200+ const data = await paperTradingApi . getNotifySettings ( )
201+ const s = data . settings
202+ setNotifyEnabled ( s . pt_notify_enabled === 'true' )
203+ setNotifyRealtime ( s . pt_notify_realtime === 'true' )
204+ setNotifyPremarket ( s . pt_notify_premarket === 'true' )
205+ setNotifySummary ( s . pt_notify_summary === 'true' )
206+ setNotifyChannels ( data . channels )
207+ const ids = s . pt_notify_channel_ids
208+ ? new Set ( s . pt_notify_channel_ids . split ( ',' ) . map ( Number ) . filter ( Boolean ) )
209+ : new Set < number > ( )
210+ setSelectedChannelIds ( ids )
211+ } catch {
212+ toast ( '加载通知配置失败' , 'error' )
213+ }
214+ }
215+
216+ const handleOpenNotify = async ( ) => {
217+ setNotifyOpen ( true )
218+ await loadNotifySettings ( )
219+ }
220+
221+ const handleSaveNotify = async ( ) => {
222+ setNotifySaving ( true )
223+ try {
224+ await paperTradingApi . updateNotifySettings ( {
225+ pt_notify_enabled : notifyEnabled ? 'true' : 'false' ,
226+ pt_notify_channel_ids : Array . from ( selectedChannelIds ) . join ( ',' ) ,
227+ pt_notify_realtime : notifyRealtime ? 'true' : 'false' ,
228+ pt_notify_premarket : notifyPremarket ? 'true' : 'false' ,
229+ pt_notify_summary : notifySummary ? 'true' : 'false' ,
230+ } )
231+ toast ( '通知配置已保存' , 'success' )
232+ setNotifyOpen ( false )
233+ } catch {
234+ toast ( '保存失败' , 'error' )
235+ } finally {
236+ setNotifySaving ( false )
237+ }
238+ }
239+
240+ const handleTestNotify = async ( ) => {
241+ setNotifyTesting ( true )
242+ try {
243+ await paperTradingApi . testNotify ( )
244+ toast ( '测试通知已发送' , 'success' )
245+ } catch {
246+ toast ( '测试通知发送失败' , 'error' )
247+ } finally {
248+ setNotifyTesting ( false )
249+ }
250+ }
251+
252+ const toggleChannel = ( id : number ) => {
253+ setSelectedChannelIds ( prev => {
254+ const next = new Set ( prev )
255+ if ( next . has ( id ) ) next . delete ( id )
256+ else next . add ( id )
257+ return next
258+ } )
259+ }
260+
184261 const totalPages = Math . ceil ( tradesTotal / tradesPageSize )
185262
186263 return (
@@ -199,6 +276,10 @@ export default function PaperTradingPage() {
199276 ) }
200277 </ div >
201278 < div className = "flex items-center gap-2" >
279+ < Button variant = "outline" size = "sm" className = "h-8" onClick = { handleOpenNotify } >
280+ < Bell className = "w-3.5 h-3.5" />
281+ < span className = "hidden sm:inline ml-1" > 通知</ span >
282+ </ Button >
202283 < Button variant = "outline" size = "sm" className = "h-8" onClick = { handleScan } disabled = { scanning } >
203284 < Play className = "w-3.5 h-3.5 mr-1" />
204285 < span className = "hidden sm:inline" > { scanning ? '扫描中...' : '立即扫描' } </ span >
@@ -471,6 +552,96 @@ export default function PaperTradingPage() {
471552 </ >
472553 ) }
473554 </ div >
555+
556+ { /* 跟单通知设置对话框 */ }
557+ < Dialog open = { notifyOpen } onOpenChange = { setNotifyOpen } >
558+ < DialogContent >
559+ < DialogHeader >
560+ < DialogTitle > 跟单通知设置</ DialogTitle >
561+ < DialogDescription > 配置模拟盘交易通知,实时跟踪建仓/平仓动作</ DialogDescription >
562+ </ DialogHeader >
563+
564+ < div className = "space-y-5" >
565+ { /* 总开关 */ }
566+ < div className = "flex items-center justify-between" >
567+ < div >
568+ < div className = "text-sm font-medium" > 启用通知</ div >
569+ < div className = "text-xs text-muted-foreground" > 开启后将通过所选渠道推送交易通知</ div >
570+ </ div >
571+ < Switch checked = { notifyEnabled } onCheckedChange = { setNotifyEnabled } />
572+ </ div >
573+
574+ { notifyEnabled && (
575+ < >
576+ { /* 通知渠道选择 */ }
577+ < div >
578+ < div className = "text-sm font-medium mb-2" > 通知渠道</ div >
579+ { notifyChannels . length === 0 ? (
580+ < div className = "text-xs text-muted-foreground" > 暂无可用渠道,请先在设置中配置通知渠道</ div >
581+ ) : (
582+ < div className = "flex flex-wrap gap-2" >
583+ { notifyChannels . map ( ch => (
584+ < button
585+ key = { ch . id }
586+ onClick = { ( ) => toggleChannel ( ch . id ) }
587+ className = { `px-2.5 py-1 rounded-lg text-xs font-medium transition-all ${
588+ selectedChannelIds . has ( ch . id )
589+ ? 'bg-primary/10 text-primary ring-1 ring-primary/20'
590+ : 'bg-muted/50 text-muted-foreground'
591+ } `}
592+ >
593+ { ch . name }
594+ </ button >
595+ ) ) }
596+ </ div >
597+ ) }
598+ { selectedChannelIds . size === 0 && notifyChannels . length > 0 && (
599+ < div className = "text-xs text-muted-foreground mt-1" > 未选择渠道时将使用默认渠道</ div >
600+ ) }
601+ </ div >
602+
603+ { /* 通知模式 */ }
604+ < div className = "space-y-3" >
605+ < div className = "text-sm font-medium" > 通知模式</ div >
606+ < div className = "flex items-center justify-between" >
607+ < div >
608+ < div className = "text-sm" > 实时交易信号</ div >
609+ < div className = "text-xs text-muted-foreground" > 建仓/平仓时立即推送</ div >
610+ </ div >
611+ < Switch checked = { notifyRealtime } onCheckedChange = { setNotifyRealtime } />
612+ </ div >
613+ < div className = "flex items-center justify-between" >
614+ < div >
615+ < div className = "text-sm" > 盘前计划</ div >
616+ < div className = "text-xs text-muted-foreground" > 每天 09:00 推送当日候选列表</ div >
617+ </ div >
618+ < Switch checked = { notifyPremarket } onCheckedChange = { setNotifyPremarket } />
619+ </ div >
620+ < div className = "flex items-center justify-between" >
621+ < div >
622+ < div className = "text-sm" > 日终摘要</ div >
623+ < div className = "text-xs text-muted-foreground" > 每天 15:30 推送当日操作汇总</ div >
624+ </ div >
625+ < Switch checked = { notifySummary } onCheckedChange = { setNotifySummary } />
626+ </ div >
627+ </ div >
628+ </ >
629+ ) }
630+
631+ { /* 操作按钮 */ }
632+ < div className = "flex items-center gap-2 pt-2" >
633+ < Button size = "sm" onClick = { handleSaveNotify } disabled = { notifySaving } >
634+ { notifySaving ? '保存中...' : '保存' }
635+ </ Button >
636+ { notifyEnabled && (
637+ < Button variant = "outline" size = "sm" onClick = { handleTestNotify } disabled = { notifyTesting } >
638+ { notifyTesting ? '发送中...' : '测试通知' }
639+ </ Button >
640+ ) }
641+ </ div >
642+ </ div >
643+ </ DialogContent >
644+ </ Dialog >
474645 </ div >
475646 )
476647}
0 commit comments