diff --git a/api/strategy.go b/api/strategy.go index 8939c8d8..64dd4e8e 100644 --- a/api/strategy.go +++ b/api/strategy.go @@ -344,7 +344,7 @@ func (s *Server) handleDeleteStrategy(c *gin.Context) { } if err := s.store.Strategy().Delete(userID, strategyID); err != nil { - SafeInternalError(c, "Failed to delete strategy", err) + c.JSON(http.StatusBadRequest, gin.H{"error": SanitizeError(err, "Failed to delete strategy")}) return } diff --git a/store/strategy.go b/store/strategy.go index 80459269..ebc2f06c 100644 --- a/store/strategy.go +++ b/store/strategy.go @@ -440,8 +440,18 @@ func (s *StrategyStore) Update(strategy *Strategy) error { func (s *StrategyStore) Delete(userID, id string) error { // do not allow deleting system default strategy var st Strategy - if err := s.db.Where("id = ?", id).First(&st).Error; err == nil && st.IsDefault { - return fmt.Errorf("cannot delete system default strategy") + if err := s.db.Where("id = ?", id).First(&st).Error; err == nil { + if st.IsDefault { + return fmt.Errorf("cannot delete system default strategy") + } + } + + // Check if any trader references this strategy + var count int64 + if err := s.db.Model(&Trader{}). + Where("user_id = ? AND strategy_id = ?", userID, id). + Count(&count).Error; err == nil && count > 0 { + return fmt.Errorf("cannot delete strategy in use by %d trader(s) - reassign those traders first", count) } return s.db.Where("id = ? AND user_id = ?", id, userID).Delete(&Strategy{}).Error diff --git a/web/src/App.tsx b/web/src/App.tsx index 6c0b04be..fbdcb5ff 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -137,6 +137,18 @@ function App() { const hasPersistedAuth = !!localStorage.getItem('auth_token') && !!localStorage.getItem('auth_user') + // Poll-off states: stop polling after 3 consecutive failures + const [accountPollOff, setAccountPollOff] = useState(false) + const [positionsPollOff, setPositionsPollOff] = useState(false) + const [decisionsPollOff, setDecisionsPollOff] = useState(false) + + // Reset poll-off states when trader changes + useEffect(() => { + setAccountPollOff(false) + setPositionsPollOff(false) + setDecisionsPollOff(false) + }, [selectedTraderId]) + // 监听URL变化,同步页面状态 useEffect(() => { const handleRouteChange = () => { @@ -242,11 +254,16 @@ function App() { currentPage === 'trader' && selectedTraderId ? `account-${selectedTraderId}` : null, - () => api.getAccount(selectedTraderId), + () => api.getAccount(selectedTraderId, true), { - refreshInterval: 15000, // 15秒刷新(配合后端15秒缓存) - revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求 - dedupingInterval: 10000, // 10秒去重,防止短时间内重复请求 + refreshInterval: accountPollOff ? 0 : 15000, + revalidateOnFocus: false, + dedupingInterval: 10000, + onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => { + if (retryCount >= 2) { setAccountPollOff(true); return } + setTimeout(() => revalidate({ retryCount }), 500) + }, + onSuccess: () => { if (accountPollOff) setAccountPollOff(false) }, } ) @@ -254,11 +271,16 @@ function App() { currentPage === 'trader' && selectedTraderId ? `positions-${selectedTraderId}` : null, - () => api.getPositions(selectedTraderId), + () => api.getPositions(selectedTraderId, true), { - refreshInterval: 15000, // 15秒刷新(配合后端15秒缓存) - revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求 - dedupingInterval: 10000, // 10秒去重,防止短时间内重复请求 + refreshInterval: positionsPollOff ? 0 : 15000, + revalidateOnFocus: false, + dedupingInterval: 10000, + onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => { + if (retryCount >= 2) { setPositionsPollOff(true); return } + setTimeout(() => revalidate({ retryCount }), 500) + }, + onSuccess: () => { if (positionsPollOff) setPositionsPollOff(false) }, } ) @@ -266,11 +288,16 @@ function App() { currentPage === 'trader' && selectedTraderId ? `decisions/latest-${selectedTraderId}-${decisionsLimit}` : null, - () => api.getLatestDecisions(selectedTraderId, decisionsLimit), + () => api.getLatestDecisions(selectedTraderId, decisionsLimit, true), { - refreshInterval: 30000, // 30秒刷新(决策更新频率较低) + refreshInterval: decisionsPollOff ? 0 : 30000, revalidateOnFocus: false, dedupingInterval: 20000, + onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => { + if (retryCount >= 2) { setDecisionsPollOff(true); return } + setTimeout(() => revalidate({ retryCount }), 500) + }, + onSuccess: () => { if (decisionsPollOff) setDecisionsPollOff(false) }, } ) @@ -295,6 +322,13 @@ function App() { const selectedTrader = traders?.find((t) => t.trader_id === selectedTraderId) + // When polling has permanently failed, provide zero-value data instead of keeping skeleton + const effectiveAccount = (accountPollOff && !account) + ? { total_equity: 0, available_balance: 0, total_pnl: 0, total_pnl_pct: 0, position_count: 0, margin_used: 0, margin_used_pct: 0 } as AccountInfo + : account + const effectivePositions = (positionsPollOff && !positions) ? [] as Position[] : positions + const effectiveDecisions = (decisionsPollOff && !decisions) ? [] as DecisionRecord[] : decisions + // Handle routing useEffect(() => { const handlePopState = () => { @@ -510,9 +544,9 @@ function App() { { + async getAccount(traderId?: string, silent?: boolean): Promise { const url = traderId ? `${API_BASE}/account?trader_id=${traderId}` : `${API_BASE}/account` - const result = await httpClient.get(url) + const result = await httpClient.request(url, { silent }) if (!result.success) throw new Error('Failed to fetch account info') return result.data! }, - async getPositions(traderId?: string): Promise { + async getPositions(traderId?: string, silent?: boolean): Promise { const url = traderId ? `${API_BASE}/positions?trader_id=${traderId}` : `${API_BASE}/positions` - const result = await httpClient.get(url) + const result = await httpClient.request(url, { silent }) if (!result.success) throw new Error('Failed to fetch positions') return result.data! }, @@ -48,7 +48,8 @@ export const dataApi = { async getLatestDecisions( traderId?: string, - limit: number = 5 + limit: number = 5, + silent?: boolean ): Promise { const params = new URLSearchParams() if (traderId) { @@ -56,8 +57,9 @@ export const dataApi = { } params.append('limit', limit.toString()) - const result = await httpClient.get( - `${API_BASE}/decisions/latest?${params}` + const result = await httpClient.request( + `${API_BASE}/decisions/latest?${params}`, + { silent } ) if (!result.success) throw new Error('Failed to fetch latest decisions') return result.data! diff --git a/web/src/lib/httpClient.ts b/web/src/lib/httpClient.ts index 3c97cac7..783d6479 100644 --- a/web/src/lib/httpClient.ts +++ b/web/src/lib/httpClient.ts @@ -85,11 +85,16 @@ export class HttpClient { * Only business errors are returned to caller */ private async handleError(error: AxiosError): Promise { + const isSilent = (error.config as any)?.silentError === true + // Network error (no response from server) if (!error.response) { - toast.error('Network error - Please check your connection', { - description: 'Unable to reach the server', - }) + if (!isSilent) { + toast.error('Network error - Please check your connection', { + id: 'network-error', + description: 'Unable to reach the server', + }) + } throw new Error('Network error') } @@ -132,25 +137,34 @@ export class HttpClient { // Handle 403 Forbidden - system error if (status === 403) { - toast.error('Permission Denied', { - description: 'You do not have permission to access this resource', - }) + if (!isSilent) { + toast.error('Permission Denied', { + id: 'permission-denied', + description: 'You do not have permission to access this resource', + }) + } throw new Error('Permission denied') } // Handle 404 Not Found - system error if (status === 404) { - toast.error('API Not Found', { - description: 'The requested endpoint does not exist (404)', - }) + if (!isSilent) { + toast.error('API Not Found', { + id: `404-${(error.config as any)?.url || 'unknown'}`, + description: 'The requested endpoint does not exist (404)', + }) + } throw new Error('API not found') } // Handle 500+ Server Error - system error if (status >= 500) { - toast.error('Server Error', { - description: 'Please try again later or contact support', - }) + if (!isSilent) { + toast.error('Server Error', { + id: 'server-error', + description: 'Please try again later or contact support', + }) + } throw new Error('Server error') } @@ -171,6 +185,7 @@ export class HttpClient { data?: any params?: any headers?: Record + silent?: boolean } = {} ): Promise> { try { @@ -180,6 +195,7 @@ export class HttpClient { data: options.data, params: options.params, headers: options.headers, + ...(options.silent && { silentError: true }), }) // Success diff --git a/web/src/pages/StrategyStudioPage.tsx b/web/src/pages/StrategyStudioPage.tsx index fbc90f36..a46c2714 100644 --- a/web/src/pages/StrategyStudioPage.tsx +++ b/web/src/pages/StrategyStudioPage.tsx @@ -247,6 +247,24 @@ export function StrategyStudioPage() { const handleDeleteStrategy = async (id: string) => { if (!token) return + // Check if strategy is in use by any trader before showing dialog + try { + const tradersResp = await fetch(`${API_BASE}/api/my-traders`, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (tradersResp.ok) { + const traderList = await tradersResp.json() + const using = traderList.filter((t: any) => t.strategy_id === id) + if (using.length > 0) { + const names = using.map((t: any) => t.trader_name).join(', ') + notify.error(`Strategy is in use by: ${names}`) + return + } + } + } catch { + // fetch failed — proceed, backend will guard + } + const confirmed = await confirmToast( tr('confirmDeleteStrategy'), { @@ -262,9 +280,12 @@ export function StrategyStudioPage() { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }) - if (!response.ok) throw new Error('Failed to delete strategy') + if (!response.ok) { + const data = await response.json().catch(() => ({})) + notify.error(data.error || 'Failed to delete strategy') + return + } notify.success(tr('strategyDeleted')) - // Clear selection if deleted strategy was selected if (selectedStrategy?.id === id) { setSelectedStrategy(null) setEditingConfig(null) @@ -272,9 +293,7 @@ export function StrategyStudioPage() { } await fetchStrategies() } catch (err) { - const errorMsg = err instanceof Error ? err.message : 'Unknown error' - setError(errorMsg) - notify.error(errorMsg) + notify.error(err instanceof Error ? err.message : 'Unknown error') } }