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:
TIANHE
2026-01-24 23:26:26 +08:00
parent f4e5a9f8e0
commit 35d7ac5e1b
15 changed files with 4744 additions and 75 deletions
@@ -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
+1 -1
View File
@@ -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',
+3 -2
View File
@@ -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
+177 -12
View File
@@ -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
+79
View File
@@ -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'
})
}
+14 -7
View File
@@ -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
+62 -2
View File
@@ -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 {
+62 -2
View File
@@ -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
+67 -31
View File
@@ -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 {
+21 -2
View File
@@ -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 {