Improve global market dashboard UX and settings
- Add global market dashboard APIs/assets and improve data robustness (incl. crypto heatmap by market cap) - Enhance global market UI (map+heatmap layout, loading behavior, formatting, theme tweaks) - Fix Settings LLM Provider select to render label/value options correctly - Rename Indicator Community to Official Community and move it to the bottom - Add search fallback when Google quota is exhausted
This commit is contained in:
@@ -22,6 +22,7 @@ def register_routes(app: Flask):
|
||||
from app.routes.ibkr import ibkr_bp
|
||||
from app.routes.mt5 import mt5_bp
|
||||
from app.routes.user import user_bp
|
||||
from app.routes.global_market import global_market_bp
|
||||
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(auth_bp, url_prefix='/api/auth') # Auth routes
|
||||
@@ -39,3 +40,4 @@ def register_routes(app: Flask):
|
||||
app.register_blueprint(portfolio_bp, url_prefix='/api/portfolio')
|
||||
app.register_blueprint(ibkr_bp, url_prefix='/api/ibkr')
|
||||
app.register_blueprint(mt5_bp, url_prefix='/api/mt5')
|
||||
app.register_blueprint(global_market_bp, url_prefix='/api/global-market')
|
||||
File diff suppressed because it is too large
Load Diff
@@ -66,7 +66,7 @@ def get_public_config():
|
||||
'google/gemini-2.5-pro': 'Google: Gemini 2.5 Pro',
|
||||
'openai/gpt-4o-mini': 'OpenAI: GPT-4o-mini',
|
||||
'openai/gpt-5-mini': 'OpenAI: GPT-5 Mini',
|
||||
'openai/gpt-oss-120b': 'OpenAI: gpt-oss-120b',
|
||||
'openai/gpt-4.1-mini': 'OpenAI: GPT-4.1 Mini',
|
||||
'deepseek/deepseek-v3.2': 'DeepSeek: DeepSeek V3.2',
|
||||
'minimax/minimax-m2': 'MiniMax: MiniMax M2',
|
||||
'anthropic/claude-sonnet-4': 'Anthropic: Claude Sonnet 4',
|
||||
|
||||
@@ -387,8 +387,9 @@ class LLMService:
|
||||
logger.error(f"{p.value} API HTTP error ({current_model}): {error_detail}")
|
||||
last_error = str(e)
|
||||
|
||||
# Check for payment/quota errors
|
||||
if e.response and e.response.status_code in (402, 429):
|
||||
# Check for recoverable errors - try fallback model
|
||||
# 402: Payment required, 403: Forbidden (invalid key), 404: Model not found, 429: Rate limit
|
||||
if e.response and e.response.status_code in (402, 403, 404, 429):
|
||||
logger.warning(f"{p.value} returned {e.response.status_code} for model {current_model}; trying fallback...")
|
||||
continue
|
||||
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
"""
|
||||
Search service.
|
||||
Integrates Google Custom Search (CSE) and Bing Search API.
|
||||
Integrates Google Custom Search (CSE), Bing Search API, and DuckDuckGo (free fallback).
|
||||
Configuration is provided via environment variables (see env.example) through config_loader.
|
||||
"""
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
from typing import List, Dict, Any, Optional
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.config_loader import load_addon_config
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Track Google API quota status
|
||||
_google_quota_exhausted = False
|
||||
_google_quota_reset_time = 0
|
||||
|
||||
|
||||
class SearchService:
|
||||
"""Search service."""
|
||||
"""Search service with automatic fallback."""
|
||||
|
||||
def __init__(self):
|
||||
self._config = {}
|
||||
@@ -28,7 +33,7 @@ class SearchService:
|
||||
|
||||
def search(self, query: str, num_results: int = None, date_restrict: str = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Execute a web search.
|
||||
Execute a web search with automatic fallback.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
@@ -38,18 +43,45 @@ class SearchService:
|
||||
Returns:
|
||||
List of search results
|
||||
"""
|
||||
global _google_quota_exhausted, _google_quota_reset_time
|
||||
|
||||
# 重新加载配置以支持热更新
|
||||
self._load_config()
|
||||
|
||||
limit = num_results if num_results else self.max_results
|
||||
|
||||
# Check if Google quota has reset (after midnight UTC typically)
|
||||
if _google_quota_exhausted and time.time() > _google_quota_reset_time:
|
||||
_google_quota_exhausted = False
|
||||
logger.info("Google API quota reset, re-enabling Google search")
|
||||
|
||||
results = []
|
||||
|
||||
if self.provider == 'bing':
|
||||
return self._search_bing(query, limit)
|
||||
results = self._search_bing(query, limit)
|
||||
elif self.provider == 'duckduckgo':
|
||||
results = self._search_duckduckgo(query, limit)
|
||||
else:
|
||||
return self._search_google(query, limit, date_restrict)
|
||||
# Google with fallback
|
||||
if not _google_quota_exhausted:
|
||||
results = self._search_google(query, limit, date_restrict)
|
||||
|
||||
# If Google failed or returned empty, try fallbacks
|
||||
if not results:
|
||||
logger.info("Google search failed or empty, trying fallback search engines...")
|
||||
# Try Bing first if configured
|
||||
results = self._search_bing(query, limit)
|
||||
|
||||
# If Bing also failed, try DuckDuckGo (free, no API key needed)
|
||||
if not results:
|
||||
results = self._search_duckduckgo(query, limit)
|
||||
|
||||
return results
|
||||
|
||||
def _search_google(self, query: str, num_results: int, date_restrict: str = None) -> List[Dict[str, Any]]:
|
||||
"""Google Custom Search (CSE)."""
|
||||
global _google_quota_exhausted, _google_quota_reset_time
|
||||
|
||||
api_key = self._config.get('google', {}).get('api_key')
|
||||
cx = self._config.get('google', {}).get('cx')
|
||||
|
||||
@@ -71,17 +103,23 @@ class SearchService:
|
||||
params['dateRestrict'] = date_restrict
|
||||
|
||||
try:
|
||||
# logger.info(f"正在调用 Google Search API: q={query}, params={params}")
|
||||
response = requests.get(url, params=params, timeout=10)
|
||||
|
||||
# Check for quota exceeded (429)
|
||||
if response.status_code == 429:
|
||||
logger.warning("Google Search API quota exceeded (429). Switching to fallback search engines.")
|
||||
_google_quota_exhausted = True
|
||||
# Set reset time to next day midnight UTC
|
||||
import datetime
|
||||
tomorrow = datetime.datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1)
|
||||
_google_quota_reset_time = tomorrow.timestamp()
|
||||
return []
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# logger.info(f"Google Search 原始响应: {json.dumps(data, ensure_ascii=False)}") # 打印全部字符
|
||||
# logger.info(f"Google Search 原始响应: {json.dumps(data, ensure_ascii=False)[:500]}...") # 打印前500字符避免日志过大
|
||||
|
||||
results = []
|
||||
if 'items' in data:
|
||||
# logger.info(f"Google Search 返回了 {len(data['items'])} 条结果")
|
||||
for item in data['items']:
|
||||
logger.debug(f"Search Item: {item.get('title')} - {item.get('link')}")
|
||||
results.append({
|
||||
@@ -96,10 +134,20 @@ class SearchService:
|
||||
|
||||
return results
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if hasattr(e, 'response') and e.response is not None and e.response.status_code == 429:
|
||||
logger.warning("Google Search API quota exceeded. Switching to fallback.")
|
||||
_google_quota_exhausted = True
|
||||
import datetime
|
||||
tomorrow = datetime.datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1)
|
||||
_google_quota_reset_time = tomorrow.timestamp()
|
||||
else:
|
||||
logger.error(f"Google search failed: {e}")
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
logger.error(f"Response: {e.response.text}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Google search failed: {e}")
|
||||
if 'response' in locals():
|
||||
logger.error(f"Response: {response.text}")
|
||||
return []
|
||||
|
||||
def _search_bing(self, query: str, num_results: int) -> List[Dict[str, Any]]:
|
||||
@@ -140,3 +188,120 @@ class SearchService:
|
||||
logger.error(f"Bing search failed: {e}")
|
||||
return []
|
||||
|
||||
def _search_duckduckgo(self, query: str, num_results: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
DuckDuckGo search (free, no API key required).
|
||||
Uses the DuckDuckGo HTML search endpoint.
|
||||
"""
|
||||
try:
|
||||
# Use DuckDuckGo Instant Answer API
|
||||
url = "https://api.duckduckgo.com/"
|
||||
params = {
|
||||
'q': query,
|
||||
'format': 'json',
|
||||
'no_html': 1,
|
||||
'skip_disambig': 1
|
||||
}
|
||||
|
||||
response = requests.get(url, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
results = []
|
||||
|
||||
# Get results from RelatedTopics
|
||||
related_topics = data.get('RelatedTopics', [])
|
||||
for topic in related_topics[:num_results]:
|
||||
if isinstance(topic, dict):
|
||||
if 'FirstURL' in topic:
|
||||
results.append({
|
||||
'title': topic.get('Text', '')[:100],
|
||||
'link': topic.get('FirstURL', ''),
|
||||
'snippet': topic.get('Text', ''),
|
||||
'source': 'DuckDuckGo',
|
||||
'published': ''
|
||||
})
|
||||
# Handle nested topics
|
||||
elif 'Topics' in topic:
|
||||
for sub_topic in topic['Topics']:
|
||||
if len(results) >= num_results:
|
||||
break
|
||||
if 'FirstURL' in sub_topic:
|
||||
results.append({
|
||||
'title': sub_topic.get('Text', '')[:100],
|
||||
'link': sub_topic.get('FirstURL', ''),
|
||||
'snippet': sub_topic.get('Text', ''),
|
||||
'source': 'DuckDuckGo',
|
||||
'published': ''
|
||||
})
|
||||
|
||||
# Also check AbstractURL and AbstractText
|
||||
if data.get('AbstractURL') and len(results) < num_results:
|
||||
results.insert(0, {
|
||||
'title': data.get('Heading', query),
|
||||
'link': data.get('AbstractURL', ''),
|
||||
'snippet': data.get('AbstractText', ''),
|
||||
'source': 'DuckDuckGo',
|
||||
'published': ''
|
||||
})
|
||||
|
||||
# If no results from Instant Answer, try HTML scraping as fallback
|
||||
if not results:
|
||||
results = self._search_duckduckgo_html(query, num_results)
|
||||
|
||||
if results:
|
||||
logger.info(f"DuckDuckGo search returned {len(results)} results")
|
||||
|
||||
return results[:num_results]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"DuckDuckGo search failed: {e}")
|
||||
# Try HTML fallback
|
||||
return self._search_duckduckgo_html(query, num_results)
|
||||
|
||||
def _search_duckduckgo_html(self, query: str, num_results: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
DuckDuckGo HTML search fallback.
|
||||
Scrapes the lite HTML version for better results.
|
||||
"""
|
||||
try:
|
||||
url = "https://lite.duckduckgo.com/lite/"
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
data = {'q': query}
|
||||
|
||||
response = requests.post(url, headers=headers, data=data, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
results = []
|
||||
|
||||
# Simple HTML parsing without BeautifulSoup
|
||||
html = response.text
|
||||
|
||||
# Find all result links (they have class="result-link")
|
||||
import re
|
||||
|
||||
# Pattern to find result entries
|
||||
link_pattern = r'<a[^>]*class="result-link"[^>]*href="([^"]*)"[^>]*>([^<]*)</a>'
|
||||
snippet_pattern = r'<td[^>]*class="result-snippet"[^>]*>([^<]*)</td>'
|
||||
|
||||
links = re.findall(link_pattern, html)
|
||||
snippets = re.findall(snippet_pattern, html)
|
||||
|
||||
for i, (link, title) in enumerate(links[:num_results]):
|
||||
snippet = snippets[i] if i < len(snippets) else ''
|
||||
if link and title:
|
||||
results.append({
|
||||
'title': title.strip(),
|
||||
'link': link,
|
||||
'snippet': snippet.strip(),
|
||||
'source': 'DuckDuckGo',
|
||||
'published': ''
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"DuckDuckGo HTML search failed: {e}")
|
||||
return []
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
-- Migration: Add notification_settings column to qd_users table
|
||||
-- Run this if you see error: column "notification_settings" does not exist
|
||||
|
||||
-- Add notification_settings column (if not exists)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'qd_users' AND column_name = 'notification_settings'
|
||||
) THEN
|
||||
ALTER TABLE qd_users ADD COLUMN notification_settings TEXT DEFAULT '';
|
||||
RAISE NOTICE 'Column notification_settings added to qd_users';
|
||||
ELSE
|
||||
RAISE NOTICE 'Column notification_settings already exists';
|
||||
END IF;
|
||||
END $$;
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Global Market Dashboard API
|
||||
*/
|
||||
import request from '@/utils/request'
|
||||
|
||||
const BASE_URL = '/api/global-market'
|
||||
|
||||
/**
|
||||
* Get global market overview (indices, forex, crypto, commodities)
|
||||
* Includes geo coordinates for world map display
|
||||
*/
|
||||
export function getMarketOverview () {
|
||||
return request({
|
||||
url: `${BASE_URL}/overview`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get market heatmap data (crypto, stock sectors, forex)
|
||||
*/
|
||||
export function getMarketHeatmap () {
|
||||
return request({
|
||||
url: `${BASE_URL}/heatmap`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get financial news - separated by language (cn/en)
|
||||
* @param {string} lang - Language filter: 'cn', 'en', or 'all' (default)
|
||||
*/
|
||||
export function getMarketNews (lang = 'all') {
|
||||
return request({
|
||||
url: `${BASE_URL}/news`,
|
||||
method: 'get',
|
||||
params: { lang }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get economic calendar with impact indicators
|
||||
*/
|
||||
export function getEconomicCalendar () {
|
||||
return request({
|
||||
url: `${BASE_URL}/calendar`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get market sentiment (Fear & Greed Index, VIX)
|
||||
*/
|
||||
export function getMarketSentiment () {
|
||||
return request({
|
||||
url: `${BASE_URL}/sentiment`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trading opportunities based on technical analysis
|
||||
*/
|
||||
export function getTradingOpportunities () {
|
||||
return request({
|
||||
url: `${BASE_URL}/opportunities`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh all market data (clears cache)
|
||||
*/
|
||||
export function refreshMarketData () {
|
||||
return request({
|
||||
url: `${BASE_URL}/refresh`,
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
@@ -16,6 +16,13 @@ export const asyncRouterMap = [
|
||||
component: () => import('@/views/dashboard'),
|
||||
meta: { title: 'menu.dashboard', keepAlive: true, icon: 'dashboard', permission: ['dashboard'] }
|
||||
},
|
||||
// 全球金融面板
|
||||
{
|
||||
path: '/global-market',
|
||||
name: 'GlobalMarket',
|
||||
component: () => import('@/views/global-market'),
|
||||
meta: { title: 'menu.dashboard.globalMarket', keepAlive: true, icon: 'global', permission: ['dashboard'] }
|
||||
},
|
||||
// AI 分析
|
||||
{
|
||||
path: '/ai-analysis/:pageNo([1-9]\\d*)?',
|
||||
@@ -44,13 +51,6 @@ export const asyncRouterMap = [
|
||||
component: () => import('@/views/portfolio'),
|
||||
meta: { title: 'menu.dashboard.portfolio', keepAlive: true, icon: 'fund', permission: ['dashboard'] }
|
||||
},
|
||||
// 指标社区(keepAlive disabled intentionally for iframe page)
|
||||
{
|
||||
path: '/indicator-community',
|
||||
name: 'IndicatorCommunity',
|
||||
component: () => import('@/views/indicator-community'),
|
||||
meta: { title: 'menu.dashboard.community', keepAlive: false, icon: 'shop', permission: ['dashboard'] }
|
||||
},
|
||||
// 系统设置 (admin only)
|
||||
{
|
||||
path: '/settings',
|
||||
@@ -71,6 +71,13 @@ export const asyncRouterMap = [
|
||||
name: 'Profile',
|
||||
component: () => import('@/views/profile'),
|
||||
meta: { title: 'menu.myProfile', keepAlive: false, icon: 'user', permission: ['dashboard'] }
|
||||
},
|
||||
// 官方社区(keepAlive disabled intentionally for iframe page)
|
||||
{
|
||||
path: '/indicator-community',
|
||||
name: 'IndicatorCommunity',
|
||||
component: () => import('@/views/indicator-community'),
|
||||
meta: { title: 'menu.dashboard.community', keepAlive: false, icon: 'shop', permission: ['dashboard'] }
|
||||
}
|
||||
|
||||
// other
|
||||
|
||||
@@ -26,9 +26,10 @@ const locale = {
|
||||
'menu.dashboard': 'Dashboard',
|
||||
'menu.dashboard.analysis': 'AI Analysis',
|
||||
'menu.dashboard.indicator': 'Indicator Analysis',
|
||||
'menu.dashboard.community': 'Indicator Community',
|
||||
'menu.dashboard.community': 'Official Community',
|
||||
'menu.dashboard.tradingAssistant': 'Trading Assistant',
|
||||
'menu.dashboard.portfolio': 'Portfolio',
|
||||
'menu.dashboard.globalMarket': 'Global Market',
|
||||
'menu.settings': 'Settings',
|
||||
'menu.dashboard.aiTradingAssistant': 'AI Trading Assistant',
|
||||
'menu.dashboard.signalRobot': 'Signal Robot',
|
||||
@@ -2353,6 +2354,8 @@ const locale = {
|
||||
'portfolio.form.deselectAll': 'Deselect All',
|
||||
'portfolio.form.selectedCount': '{count} of {total} selected',
|
||||
'portfolio.form.pleaseSelectPositions': 'Please select at least one position to monitor',
|
||||
'portfolio.form.notificationFromProfile': 'Notifications will be sent to addresses configured in your profile',
|
||||
'portfolio.form.goToProfile': 'Go to settings',
|
||||
'portfolio.message.loadFailed': 'Failed to load data',
|
||||
'portfolio.message.saveSuccess': 'Saved successfully',
|
||||
'portfolio.message.saveFailed': 'Failed to save',
|
||||
@@ -2581,7 +2584,64 @@ const locale = {
|
||||
'settings.desc.CREDITS_REFERRAL_BONUS': 'Credits awarded to referrer when someone signs up with their referral code',
|
||||
'settings.desc.VERIFICATION_CODE_MAX_ATTEMPTS': 'Maximum attempts to verify a code before lockout',
|
||||
'settings.desc.VERIFICATION_CODE_LOCK_MINUTES': 'Lockout duration after exceeding max attempts',
|
||||
'settings.desc.RECHARGE_TELEGRAM_URL': 'Telegram customer service URL for recharge inquiries'
|
||||
'settings.desc.RECHARGE_TELEGRAM_URL': 'Telegram customer service URL for recharge inquiries',
|
||||
|
||||
// Global Market
|
||||
'globalMarket.title': 'Global Market Dashboard',
|
||||
'globalMarket.lastUpdate': 'Last Update',
|
||||
'globalMarket.refresh': 'Refresh',
|
||||
'globalMarket.name': 'Name',
|
||||
'globalMarket.price': 'Price',
|
||||
'globalMarket.change': 'Change',
|
||||
'globalMarket.trend': 'Trend',
|
||||
'globalMarket.pair': 'Pair',
|
||||
'globalMarket.unit': 'Unit',
|
||||
'globalMarket.refreshSuccess': 'Data refreshed successfully',
|
||||
'globalMarket.refreshError': 'Failed to refresh data',
|
||||
'globalMarket.fetchError': 'Failed to fetch data',
|
||||
'globalMarket.loading': 'Loading...',
|
||||
'globalMarket.fearGreedIndex': 'Fear & Greed Index',
|
||||
'globalMarket.fearGreedTip': 'Fear & Greed Index measures market sentiment, 0 = Extreme Fear, 100 = Extreme Greed',
|
||||
'globalMarket.volatilityIndex': 'Volatility Index',
|
||||
'globalMarket.vixTitle': 'VIX Volatility Index',
|
||||
'globalMarket.vixTip': 'VIX measures expected market volatility, higher values indicate more market fear',
|
||||
'globalMarket.extremeFear': 'Extreme Fear',
|
||||
'globalMarket.fear': 'Fear',
|
||||
'globalMarket.neutral': 'Neutral',
|
||||
'globalMarket.greed': 'Greed',
|
||||
'globalMarket.extremeGreed': 'Extreme Greed',
|
||||
'globalMarket.marketOverview': 'Market Overview',
|
||||
'globalMarket.indices': 'Indices',
|
||||
'globalMarket.forex': 'Forex',
|
||||
'globalMarket.crypto': 'Crypto',
|
||||
'globalMarket.commodities': 'Commodities',
|
||||
'globalMarket.heatmap': 'Market Heatmap',
|
||||
'globalMarket.cryptoHeatmap': 'Crypto Heatmap',
|
||||
'globalMarket.sectorHeatmap': 'Sector Heatmap',
|
||||
'globalMarket.forexHeatmap': 'Forex Heatmap',
|
||||
'globalMarket.opportunities': 'Trading Opportunities',
|
||||
'globalMarket.noOpportunities': 'No significant opportunities detected',
|
||||
'globalMarket.financialNews': 'Financial News',
|
||||
'globalMarket.noNews': 'No news available',
|
||||
'globalMarket.economicCalendar': 'Economic Calendar',
|
||||
'globalMarket.noEvents': 'No events scheduled',
|
||||
'globalMarket.actual': 'Actual',
|
||||
'globalMarket.forecast': 'Forecast',
|
||||
'globalMarket.previous': 'Previous',
|
||||
'globalMarket.signal.bullish': 'Bullish Momentum',
|
||||
'globalMarket.signal.bearish': 'Bearish Momentum',
|
||||
'globalMarket.signal.overbought': 'Overbought Warning',
|
||||
'globalMarket.signal.oversold': 'Oversold Opportunity',
|
||||
'globalMarket.worldMap': 'Global Markets Map',
|
||||
'globalMarket.rising': 'Rising',
|
||||
'globalMarket.falling': 'Falling',
|
||||
'globalMarket.bullish': 'Bullish',
|
||||
'globalMarket.bearish': 'Bearish',
|
||||
'globalMarket.expectedImpact': 'Expected Impact',
|
||||
'globalMarket.aboveForecast': 'Above Forecast',
|
||||
'globalMarket.belowForecast': 'Below Forecast',
|
||||
'globalMarket.upcomingEvents': 'Upcoming Events',
|
||||
'globalMarket.releasedEvents': 'Released Data'
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -25,10 +25,11 @@ const locale = {
|
||||
'menu.home': '主页',
|
||||
'menu.dashboard': '仪表盘',
|
||||
'menu.dashboard.indicator': '指标分析',
|
||||
'menu.dashboard.community': '指标社区',
|
||||
'menu.dashboard.community': '官方社区',
|
||||
'menu.dashboard.analysis': 'AI 分析',
|
||||
'menu.dashboard.tradingAssistant': '交易助手',
|
||||
'menu.dashboard.portfolio': '资产监测',
|
||||
'menu.dashboard.globalMarket': '全球金融',
|
||||
'menu.settings': '系统设置',
|
||||
'menu.dashboard.aiTradingAssistant': 'AI交易助手',
|
||||
'menu.dashboard.signalRobot': '信号机器人',
|
||||
@@ -2163,6 +2164,8 @@ const locale = {
|
||||
'portfolio.form.deselectAll': '全不选',
|
||||
'portfolio.form.selectedCount': '已选 {count}/{total}',
|
||||
'portfolio.form.pleaseSelectPositions': '请至少选择一个持仓进行监控',
|
||||
'portfolio.form.notificationFromProfile': '通知将发送到您在个人中心配置的地址',
|
||||
'portfolio.form.goToProfile': '前往配置',
|
||||
'portfolio.message.loadFailed': '加载数据失败',
|
||||
'portfolio.message.saveSuccess': '保存成功',
|
||||
'portfolio.message.saveFailed': '保存失败',
|
||||
@@ -2391,7 +2394,64 @@ const locale = {
|
||||
'settings.desc.CREDITS_REFERRAL_BONUS': '用户通过邀请链接成功邀请新用户时,邀请人获得的积分奖励',
|
||||
'settings.desc.VERIFICATION_CODE_MAX_ATTEMPTS': '验证码验证失败的最大尝试次数,超过后将锁定',
|
||||
'settings.desc.VERIFICATION_CODE_LOCK_MINUTES': '验证码验证失败次数超过限制后的锁定时长(分钟)',
|
||||
'settings.desc.RECHARGE_TELEGRAM_URL': '用户点击充值时跳转的Telegram客服链接'
|
||||
'settings.desc.RECHARGE_TELEGRAM_URL': '用户点击充值时跳转的Telegram客服链接',
|
||||
|
||||
// Global Market
|
||||
'globalMarket.title': '全球金融面板',
|
||||
'globalMarket.lastUpdate': '最后更新',
|
||||
'globalMarket.refresh': '刷新数据',
|
||||
'globalMarket.name': '名称',
|
||||
'globalMarket.price': '价格',
|
||||
'globalMarket.change': '涨跌',
|
||||
'globalMarket.trend': '走势',
|
||||
'globalMarket.pair': '货币对',
|
||||
'globalMarket.unit': '单位',
|
||||
'globalMarket.refreshSuccess': '数据刷新成功',
|
||||
'globalMarket.refreshError': '数据刷新失败',
|
||||
'globalMarket.fetchError': '获取数据失败',
|
||||
'globalMarket.loading': '加载中...',
|
||||
'globalMarket.fearGreedIndex': '恐惧贪婪指数',
|
||||
'globalMarket.fearGreedTip': '恐惧贪婪指数衡量市场情绪,0表示极度恐惧,100表示极度贪婪',
|
||||
'globalMarket.volatilityIndex': '波动率指数',
|
||||
'globalMarket.vixTitle': 'VIX 波动率指数',
|
||||
'globalMarket.vixTip': 'VIX指数衡量市场预期波动率,数值越高表示市场越恐慌',
|
||||
'globalMarket.extremeFear': '极度恐惧',
|
||||
'globalMarket.fear': '恐惧',
|
||||
'globalMarket.neutral': '中性',
|
||||
'globalMarket.greed': '贪婪',
|
||||
'globalMarket.extremeGreed': '极度贪婪',
|
||||
'globalMarket.marketOverview': '全球市场概览',
|
||||
'globalMarket.indices': '主要指数',
|
||||
'globalMarket.forex': '外汇市场',
|
||||
'globalMarket.crypto': '加密货币',
|
||||
'globalMarket.commodities': '大宗商品',
|
||||
'globalMarket.heatmap': '市场热力图',
|
||||
'globalMarket.cryptoHeatmap': '加密货币热力图',
|
||||
'globalMarket.sectorHeatmap': '板块热力图',
|
||||
'globalMarket.forexHeatmap': '外汇热力图',
|
||||
'globalMarket.opportunities': '交易机会',
|
||||
'globalMarket.noOpportunities': '暂无明显交易机会',
|
||||
'globalMarket.financialNews': '财经资讯',
|
||||
'globalMarket.noNews': '暂无新闻',
|
||||
'globalMarket.economicCalendar': '财经日历',
|
||||
'globalMarket.noEvents': '暂无事件',
|
||||
'globalMarket.actual': '实际',
|
||||
'globalMarket.forecast': '预期',
|
||||
'globalMarket.previous': '前值',
|
||||
'globalMarket.signal.bullish': '看涨趋势',
|
||||
'globalMarket.signal.bearish': '看跌趋势',
|
||||
'globalMarket.signal.overbought': '超买警告',
|
||||
'globalMarket.signal.oversold': '超卖机会',
|
||||
'globalMarket.worldMap': '全球市场地图',
|
||||
'globalMarket.rising': '上涨',
|
||||
'globalMarket.falling': '下跌',
|
||||
'globalMarket.bullish': '利多',
|
||||
'globalMarket.bearish': '利空',
|
||||
'globalMarket.expectedImpact': '预期影响',
|
||||
'globalMarket.aboveForecast': '高于预期',
|
||||
'globalMarket.belowForecast': '低于预期',
|
||||
'globalMarket.upcomingEvents': '即将公布',
|
||||
'globalMarket.releasedEvents': '已公布数据'
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -727,9 +727,9 @@
|
||||
>
|
||||
<template #message>
|
||||
<span>
|
||||
{{ $t('portfolio.form.notificationFromProfile') || '通知将发送到您在个人中心配置的地址' }}
|
||||
{{ $t('portfolio.form.notificationFromProfile') }}
|
||||
<router-link to="/profile" style="margin-left: 8px">
|
||||
<a-icon type="setting" /> {{ $t('portfolio.form.goToProfile') || '前往配置' }}
|
||||
<a-icon type="setting" /> {{ $t('portfolio.form.goToProfile') }}
|
||||
</router-link>
|
||||
</span>
|
||||
</template>
|
||||
@@ -806,9 +806,9 @@
|
||||
>
|
||||
<template #message>
|
||||
<span>
|
||||
{{ $t('portfolio.form.notificationFromProfile') || '通知将发送到您在个人中心配置的地址' }}
|
||||
{{ $t('portfolio.form.notificationFromProfile') }}
|
||||
<router-link to="/profile" style="margin-left: 8px">
|
||||
<a-icon type="setting" /> {{ $t('portfolio.form.goToProfile') || '前往配置' }}
|
||||
<a-icon type="setting" /> {{ $t('portfolio.form.goToProfile') }}
|
||||
</router-link>
|
||||
</span>
|
||||
</template>
|
||||
@@ -836,15 +836,21 @@
|
||||
:key="pos.id"
|
||||
class="position-checkbox-item"
|
||||
>
|
||||
<a-checkbox :value="pos.id">
|
||||
<span class="position-checkbox-label">
|
||||
<a-tag :color="getMarketColor(pos.market)" size="small">{{ pos.market }}</a-tag>
|
||||
<span class="symbol">{{ pos.symbol }}</span>
|
||||
<span class="name">{{ pos.name }}</span>
|
||||
<span :class="['pnl', pos.pnl >= 0 ? 'positive' : 'negative']">
|
||||
{{ pos.pnl >= 0 ? '+' : '' }}{{ formatNumber(pos.pnl_percent) }}%
|
||||
</span>
|
||||
</span>
|
||||
<a-checkbox :value="pos.id" class="position-checkbox">
|
||||
<div class="position-checkbox-label">
|
||||
<div class="position-left">
|
||||
<a-tag :color="getMarketColor(pos.market)" size="small">{{ pos.market }}</a-tag>
|
||||
<span class="symbol">{{ pos.symbol }}</span>
|
||||
</div>
|
||||
<div class="position-middle">
|
||||
<span class="name" :title="pos.name">{{ pos.name }}</span>
|
||||
</div>
|
||||
<div class="position-right">
|
||||
<span :class="['pnl', pos.pnl >= 0 ? 'positive' : 'negative']">
|
||||
{{ pos.pnl >= 0 ? '+' : '' }}{{ formatNumber(pos.pnl_percent) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-checkbox>
|
||||
</div>
|
||||
</a-checkbox-group>
|
||||
@@ -2615,34 +2621,64 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
.position-checkbox {
|
||||
width: 100%;
|
||||
|
||||
// Override Ant Design checkbox label width
|
||||
::v-deep .ant-checkbox + span {
|
||||
width: calc(100% - 24px);
|
||||
padding-left: 8px;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.position-checkbox-label {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
|
||||
.symbol {
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
min-width: 60px;
|
||||
.position-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.symbol {
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
min-width: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
.position-middle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 120px;
|
||||
|
||||
.name {
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.pnl {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
.position-right {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
|
||||
&.positive { color: @green; }
|
||||
&.negative { color: @red; }
|
||||
.pnl {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
|
||||
&.positive { color: @green; }
|
||||
&.negative { color: @red; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2676,8 +2712,8 @@ export default {
|
||||
}
|
||||
|
||||
.position-checkbox-label {
|
||||
.symbol { color: #d1d4dc; }
|
||||
.name { color: #868993; }
|
||||
.position-left .symbol { color: #d1d4dc; }
|
||||
.position-middle .name { color: #868993; }
|
||||
}
|
||||
|
||||
.position-select-actions {
|
||||
|
||||
@@ -171,8 +171,12 @@
|
||||
v-decorator="[item.key, { initialValue: getFieldValue(groupKey, item.key) || item.default }]"
|
||||
:placeholder="item.default ? `${$t('settings.default')}: ${item.default}` : $t('settings.pleaseSelect')"
|
||||
>
|
||||
<a-select-option v-for="opt in item.options" :key="opt" :value="opt">
|
||||
{{ opt }}
|
||||
<a-select-option
|
||||
v-for="opt in getSelectOptions(item.options)"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
@@ -249,6 +253,21 @@ export default {
|
||||
this.loadSettings()
|
||||
},
|
||||
methods: {
|
||||
// 兼容后端 schema options 两种格式:
|
||||
// - string[]: ['openrouter','openai', ...]
|
||||
// - {value,label}[]: [{value:'openrouter',label:'OpenRouter'}, ...]
|
||||
getSelectOptions (options) {
|
||||
const arr = Array.isArray(options) ? options : []
|
||||
return arr.map(opt => {
|
||||
if (opt && typeof opt === 'object') {
|
||||
return {
|
||||
value: opt.value != null ? String(opt.value) : '',
|
||||
label: opt.label != null ? String(opt.label) : String(opt.value || '')
|
||||
}
|
||||
}
|
||||
return { value: String(opt), label: String(opt) }
|
||||
}).filter(o => o.value !== '')
|
||||
},
|
||||
async loadSettings () {
|
||||
this.loading = true
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user