feat(trading-assistant): refactor strategy creation and enhance script mode functionality

- Removed conditional rendering for the assistant guide bar.
- Simplified strategy overview and strategy list item components.
- Introduced a new modal for selecting strategy mode.
- Enhanced strategy creation modal to support script strategies with a dedicated editor.
- Updated form handling for script strategies, including validation and submission logic.
- Improved user experience with better messaging and streamlined UI components.
- Updated translations for better clarity in Chinese.
This commit is contained in:
dienakdz
2026-04-08 07:27:26 +07:00
parent 2dc29a215e
commit c3cf230104
89 changed files with 4653 additions and 1735 deletions
+2 -2
View File
@@ -19,14 +19,14 @@ ENV/
logs/
*.log
# Database ( docker-compose 中挂载)
# Database (mounted in docker-compose)
*.db
# Environment
.env
.env.local
# Data ( docker-compose 中挂载)
# Data (mounted in docker-compose)
data/memory/*.db
# Git
+2 -2
View File
@@ -30,10 +30,10 @@ wheels/
*.swo
*~
# 日志
# Logs
*.log
# 环境变量
# Environment variables
.env
.env.local
@@ -86,7 +86,7 @@ class DataCache:
if entry.is_expired():
del self._cache[key]
self._misses += 1
logger.debug(f"[缓存] {self.name}:{key} 已过期,删除")
logger.debug(f"[cache] {self.name}:{key} expired and was removed")
return None
# Update access order (LRU)
@@ -94,7 +94,7 @@ class DataCache:
entry.hit_count += 1
self._hits += 1
logger.debug(f"[缓存命中] {self.name}:{key} (年龄: {entry.age():.0f}s/{entry.ttl:.0f}s)")
logger.debug(f"[cache hit] {self.name}:{key} (age: {entry.age():.0f}s/{entry.ttl:.0f}s)")
return entry.data
def set(
@@ -115,7 +115,7 @@ class DataCache:
# Check capacity, perform LRU elimination
while len(self._cache) >= self.max_size:
oldest_key, _ = self._cache.popitem(last=False)
logger.debug(f"[缓存] {self.name} 容量已满,淘汰: {oldest_key}")
logger.debug(f"[cache] {self.name} reached capacity, evicted: {oldest_key}")
actual_ttl = ttl if ttl is not None else self.default_ttl
self._cache[key] = CacheEntry(
@@ -124,14 +124,14 @@ class DataCache:
ttl=actual_ttl
)
logger.debug(f"[缓存更新] {self.name}:{key} TTL={actual_ttl}s")
logger.debug(f"[cache update] {self.name}:{key} TTL={actual_ttl}s")
def delete(self, key: str) -> bool:
"""Delete cache entry"""
with self._lock:
if key in self._cache:
del self._cache[key]
logger.debug(f"[缓存] {self.name}:{key} 已删除")
logger.debug(f"[cache] {self.name}:{key} deleted")
return True
return False
@@ -140,7 +140,7 @@ class DataCache:
with self._lock:
count = len(self._cache)
self._cache.clear()
logger.info(f"[缓存] {self.name} 已清空 {count} 条记录")
logger.info(f"[cache] {self.name} cleared {count} records")
return count
def cleanup_expired(self) -> int:
@@ -154,7 +154,7 @@ class DataCache:
del self._cache[key]
if expired_keys:
logger.debug(f"[缓存] {self.name} 清理 {len(expired_keys)} 条过期记录")
logger.debug(f"[cache] {self.name} cleaned {len(expired_keys)} expired records")
return len(expired_keys)
def stats(self) -> Dict[str, Any]:
@@ -84,11 +84,11 @@ class CircuitBreaker:
# Cooling is completed and enters the half-open state
state['state'] = CircuitState.HALF_OPEN
state['half_open_calls'] = 0
logger.info(f"[熔断器] {source} 冷却完成,进入半开状态")
logger.info(f"[circuit breaker] {source} cooldown finished, entering half-open state")
return True
else:
remaining = self.cooldown_seconds - time_since_failure
logger.debug(f"[熔断器] {source} 处于熔断状态,剩余冷却时间: {remaining:.0f}s")
logger.debug(f"[circuit breaker] {source} is open, remaining cooldown: {remaining:.0f}s")
return False
if state['state'] == CircuitState.HALF_OPEN:
@@ -105,7 +105,7 @@ class CircuitBreaker:
if state['state'] == CircuitState.HALF_OPEN:
# Successful in half-open state, full recovery
logger.info(f"[熔断器] {source} 半开状态请求成功,恢复正常")
logger.info(f"[circuit breaker] {source} request succeeded in half-open state, fully recovered")
# reset state
state['state'] = CircuitState.CLOSED
@@ -126,14 +126,14 @@ class CircuitBreaker:
# Fails in half-open state and continues to fuse
state['state'] = CircuitState.OPEN
state['half_open_calls'] = 0
logger.warning(f"[熔断器] {source} 半开状态请求失败,继续熔断 {self.cooldown_seconds}s")
logger.warning(f"[circuit breaker] {source} request failed in half-open state, staying open for {self.cooldown_seconds}s")
elif state['failures'] >= self.failure_threshold:
# reaches the threshold and enters the circuit breaker
state['state'] = CircuitState.OPEN
logger.warning(f"[熔断器] {source} 连续失败 {state['failures']} 次,进入熔断状态 "
f"(冷却 {self.cooldown_seconds}s)")
logger.warning(f"[circuit breaker] {source} failed {state['failures']} times consecutively and is now open "
f"(cooldown {self.cooldown_seconds}s)")
if error:
logger.warning(f"[熔断器] 最后错误: {error}")
logger.warning(f"[circuit breaker] last error: {error}")
def get_status(self) -> Dict[str, Dict[str, Any]]:
"""Get all data source status"""
@@ -151,10 +151,10 @@ class CircuitBreaker:
if source:
if source in self._states:
del self._states[source]
logger.info(f"[熔断器] 已重置 {source} 的熔断状态")
logger.info(f"[circuit breaker] reset breaker state for {source}")
else:
self._states.clear()
logger.info("[熔断器] 已重置所有数据源的熔断状态")
logger.info("[circuit breaker] reset breaker state for all data sources")
# ============================================
@@ -62,7 +62,7 @@ class DataSourceFactory:
from app.data_sources.futures import FuturesDataSource
return FuturesDataSource()
else:
raise ValueError(f"不支持的市场类型: {market}")
raise ValueError(f"Unsupported market type: {market}")
@classmethod
def get_kline(
@@ -160,7 +160,7 @@ class PolymarketDataSource:
return None
def get_market_history(self, market_id: str, days: int = 30) -> List[Dict]:
"""获取市场历史价格数据"""
"""Get historical market price data."""
# Here you need to implement historical data acquisition logic
# Temporarily returns an empty list
return []
@@ -374,7 +374,7 @@ class PolymarketDataSource:
return []
def _get_cached_markets(self, category: str = None, limit: int = 50) -> Optional[List[Dict]]:
"""从数据库缓存读取市场数据"""
"""Read market data from the database cache."""
try:
with get_db_connection() as db:
cur = db.cursor()
+1 -1
View File
@@ -709,7 +709,7 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio
reference_id=f"ai_code_gen_{user_id}_{int(time.time())}"
)
if not ok:
yield "data: " + json.dumps({"error": f"积分不足: {msg}"}, ensure_ascii=False) + "\n\n"
yield "data: " + json.dumps({"error": f"Insufficient credits: {msg}"}, ensure_ascii=False) + "\n\n"
yield "data: [DONE]\n\n"
return
+5 -5
View File
@@ -1010,7 +1010,7 @@ def get_openrouter_balance():
if not api_key:
return jsonify({
'code': 0,
'msg': 'OpenRouter API Key 未配置',
'msg': 'OpenRouter API key is not configured',
'data': None
})
@@ -1050,27 +1050,27 @@ def get_openrouter_balance():
elif resp.status_code == 401:
return jsonify({
'code': 0,
'msg': 'API Key 无效或已过期',
'msg': 'API key is invalid or expired',
'data': None
})
else:
return jsonify({
'code': 0,
'msg': f'查询失败: HTTP {resp.status_code}',
'msg': f'Query failed: HTTP {resp.status_code}',
'data': None
})
except requests.exceptions.Timeout:
return jsonify({
'code': 0,
'msg': '请求超时,请检查网络连接',
'msg': 'Request timed out. Please check the network connection.',
'data': None
})
except Exception as e:
logger.error(f"Get OpenRouter balance failed: {e}")
return jsonify({
'code': 0,
'msg': f'查询失败: {str(e)}',
'msg': f'Query failed: {str(e)}',
'data': None
})
@@ -84,7 +84,7 @@ class AnalysisMemory:
cur.execute("""
DO $$
BEGIN
-- 添加 user_id 如果不存在
-- Add the user_id column if it does not exist
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_analysis_memory' AND column_name = 'user_id'
@@ -92,7 +92,7 @@ class AnalysisMemory:
ALTER TABLE qd_analysis_memory ADD COLUMN user_id INT;
END IF;
-- 添加 raw_result 如果不存在
-- Add the raw_result column if it does not exist
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_analysis_memory' AND column_name = 'raw_result'
+3 -3
View File
@@ -3378,7 +3378,7 @@ import pandas as pd
else:
# SL not strict enough, liquidation triggered
logger.warning(f"Long liquidation! entry={entry_price:.2f}, low={low:.2f}, "
f"爆仓线={liquidation_price:.2f}, 止损价={stop_loss_price:.2f}")
f"liq_price={liquidation_price:.2f}, stop_loss_price={stop_loss_price:.2f}")
is_liquidated = True
liquidation_loss = self._liquidation_loss(capital)
capital = 0
@@ -3402,7 +3402,7 @@ import pandas as pd
stop_loss_price = close_short_price_arr[i] if has_stop_loss else 0
logger.warning(f"[candle {i}] Short hit liquidation! entry={entry_price:.2f}, high={high:.2f}, liq_price={liquidation_price:.2f}, "
f"止损信号={close_short_arr[i]}, 止损价={stop_loss_price:.4f}, 时间={timestamp}")
f"stop_loss_signal={close_short_arr[i]}, stop_loss_price={stop_loss_price:.4f}, time={timestamp}")
# Determine SL or liquidation first
if has_stop_loss and stop_loss_price < liquidation_price:
@@ -3425,7 +3425,7 @@ import pandas as pd
else:
# SL not strict enough, liquidation triggered
logger.warning(f"Short liquidation! entry={entry_price:.2f}, high={high:.2f}, "
f"爆仓线={liquidation_price:.2f}, 止损价={stop_loss_price:.2f}")
f"liq_price={liquidation_price:.2f}, stop_loss_price={stop_loss_price:.2f}")
is_liquidated = True
liquidation_loss = self._liquidation_loss(capital)
capital = 0
@@ -155,7 +155,7 @@ class CommunityService:
return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0}
def get_indicator_detail(self, indicator_id: int, user_id: int = None) -> Optional[Dict[str, Any]]:
"""获取指标详情"""
"""Get indicator details."""
try:
with get_db_connection() as db:
cur = db.cursor()
@@ -235,8 +235,8 @@ class CommunityService:
def purchase_indicator(self, buyer_id: int, indicator_id: int) -> Tuple[bool, str, Dict[str, Any]]:
"""
购买指标
Purchase an indicator.
Returns:
(success, message, data)
"""
@@ -367,7 +367,7 @@ class CommunityService:
return False, f'error: {str(e)}', {}
def get_my_purchases(self, user_id: int, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
"""获取用户购买的指标列表"""
"""Get the list of indicators purchased by the user."""
offset = (page - 1) * page_size
try:
@@ -433,7 +433,7 @@ class CommunityService:
# ==========================================
def get_comments(self, indicator_id: int, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
"""获取指标评论列表"""
"""Get the list of indicator comments."""
offset = (page - 1) * page_size
try:
@@ -495,7 +495,9 @@ class CommunityService:
content: str
) -> Tuple[bool, str, Dict[str, Any]]:
"""
添加评论只有购买过的用户可以评论且只能评论一次
Add a comment.
Only users who purchased the indicator can comment, and only once.
"""
try:
# Verify score range
@@ -577,7 +579,9 @@ class CommunityService:
content: str
) -> Tuple[bool, str, Dict[str, Any]]:
"""
更新评论只能修改自己的评论
Update a comment.
Users can only edit their own comments.
"""
try:
rating = max(1, min(5, int(rating)))
@@ -628,7 +632,7 @@ class CommunityService:
return False, f'error: {str(e)}', {}
def get_user_comment(self, user_id: int, indicator_id: int) -> Optional[Dict[str, Any]]:
"""获取用户对某个指标的评论"""
"""Get the user's comment for a specific indicator."""
try:
with get_db_connection() as db:
cur = db.cursor()
@@ -665,7 +669,7 @@ class CommunityService:
page_size: int = 20,
review_status: str = 'pending' # 'pending' / 'approved' / 'rejected' / 'all'
) -> Dict[str, Any]:
"""获取待审核的指标列表(管理员用)"""
"""Get the list of indicators pending review for admins."""
offset = (page - 1) * page_size
try:
@@ -753,7 +757,7 @@ class CommunityService:
action: str, # 'approve' / 'reject'
note: str = ''
) -> Tuple[bool, str]:
"""审核指标"""
"""Review an indicator."""
try:
new_status = 'approved' if action == 'approve' else 'rejected'
note = (note or '').strip()[:500]
@@ -790,7 +794,7 @@ class CommunityService:
return False, f'error: {str(e)}'
def unpublish_indicator(self, admin_id: int, indicator_id: int, note: str = '') -> Tuple[bool, str]:
"""下架指标(取消发布)"""
"""Unpublish an indicator."""
try:
note = (note or '').strip()[:500]
@@ -826,7 +830,7 @@ class CommunityService:
return False, f'error: {str(e)}'
def admin_delete_indicator(self, admin_id: int, indicator_id: int) -> Tuple[bool, str]:
"""管理员删除指标"""
"""Delete an indicator as an admin."""
try:
with get_db_connection() as db:
cur = db.cursor()
@@ -889,11 +893,11 @@ class CommunityService:
def get_indicator_performance(self, indicator_id: int) -> Dict[str, Any]:
"""
获取指标的实盘表现统计
Get live performance statistics for an indicator.
数据来源
1. qd_backtest_runs - 回测记录result_json 内含 totalReturn / winRate
2. qd_strategy_trades + qd_strategies_trading - 真实实盘交易记录
Data sources:
1. qd_backtest_runs - Backtest records (result_json contains totalReturn, winRate, etc.)
2. qd_strategy_trades + qd_strategies_trading - Real live trading records
"""
default_result = {
'strategy_count': 0,
@@ -1032,7 +1036,7 @@ _community_service = None
def get_community_service() -> CommunityService:
"""获取社区服务单例"""
"""Get the community service singleton."""
global _community_service
if _community_service is None:
_community_service = CommunityService()
@@ -272,13 +272,13 @@ class IndicatorCaller:
def get_indicator_params(indicator_id: int) -> List[Dict[str, Any]]:
"""
获取指标的参数声明供API调用
Get the indicator parameter declarations for API usage
Args:
indicator_id: 指标ID
indicator_id: Indicator ID
Returns:
参数声明列表
A list of parameter declarations
"""
try:
with get_db_connection() as db:
@@ -722,8 +722,8 @@ class OkxClient(BaseRestClient):
"""
mt = (market_type or "swap").strip().lower()
inst_id = to_okx_spot_inst_id(symbol) if mt == "spot" else to_okx_swap_inst_id(symbol)
# IMPORTANT: For OKX SWAP, fillSz/accFillSz are in "contracts" (张数), not base-asset quantity.
# Our system standardizes on base-asset quantity everywhere ("币数"), so we convert using ctVal.
# IMPORTANT: For OKX SWAP, fillSz/accFillSz are in contract counts, not base-asset quantity.
# Our system standardizes on base-asset quantity everywhere, so we convert using ctVal.
ct_val = Decimal("0")
if mt != "spot":
try:
+3 -3
View File
@@ -212,11 +212,11 @@ class LLMService:
if "openrouter" in (base_url or "").lower():
from app.config.api_keys import APIKeys
if not APIKeys.OPENROUTER_API_KEY:
error_msg += ". OPENROUTER_API_KEY 未配置,请在 backend_api_python/.env 中设置"
error_msg += ". OPENROUTER_API_KEY is not configured. Set it in backend_api_python/.env"
elif response.status_code == 403:
error_msg += ". 可能原因:API 密钥无效/过期、余额不足、或无模型权限。请检查 https://openrouter.ai/keys"
error_msg += ". Possible causes: API key is invalid or expired, account balance is insufficient, or the model is not permitted. Check https://openrouter.ai/keys"
elif response.status_code == 404:
error_msg += ". 可能原因:模型不可用或账户隐私/数据策略限制。请检查 https://openrouter.ai/settings/privacy"
error_msg += ". Possible causes: the model is unavailable or blocked by account privacy/data-policy settings. Check https://openrouter.ai/settings/privacy"
raise ValueError(error_msg)
@@ -534,7 +534,7 @@ class MarketDataCollector:
def _calc_macd(self, closes: List[float]) -> Dict[str, float]:
"""
MACD(12,26,9)DIF = EMA12(close) EMA26(close)DEA = EMA9(DIF) = DIF DEA
MACD(12,26,9): DIF = EMA12(close) - EMA26(close), DEA = EMA9(DIF), histogram = DIF - DEA.
Each EMA uses SMA seeds; DIF is defined from the 26th K onwards, and EMA9 is calculated for the DIF subsequence of the signal line pair.
"""
n = len(closes)
@@ -866,32 +866,32 @@ class MarketDataCollector:
crypto_info = {
'BTC': {
'name': 'Bitcoin',
'description': '比特币,数字黄金,市值第一的加密货币,作为价值存储和避险资产',
'description': 'Bitcoin, the leading cryptocurrency by market cap, often viewed as digital gold and a store-of-value asset.',
'category': 'Store of Value',
},
'ETH': {
'name': 'Ethereum',
'description': '以太坊,智能合约平台,DeFi和NFT生态的基础设施',
'description': 'Ethereum, a smart-contract platform and core infrastructure for the DeFi and NFT ecosystem.',
'category': 'Smart Contract Platform',
},
'BNB': {
'name': 'Binance Coin',
'description': '币安币,全球最大交易所的平台代币',
'description': 'Binance Coin, the platform token of one of the world\'s largest exchanges.',
'category': 'Exchange Token',
},
'SOL': {
'name': 'Solana',
'description': '高性能公链,主打高TPS和低Gas费',
'description': 'A high-performance public chain focused on high throughput and low transaction fees.',
'category': 'Smart Contract Platform',
},
'XRP': {
'name': 'Ripple',
'description': '瑞波币,专注跨境支付解决方案',
'description': 'Ripple, focused on cross-border payment solutions.',
'category': 'Payment',
},
'DOGE': {
'name': 'Dogecoin',
'description': '狗狗币,Meme币代表,社区驱动',
'description': 'Dogecoin, a community-driven meme coin.',
'category': 'Meme',
},
}
@@ -905,7 +905,7 @@ class MarketDataCollector:
return {
'name': base,
'description': f'{base} 是一种加密货币',
'description': f'{base} is a cryptocurrency.',
'category': 'Unknown',
}
@@ -960,7 +960,7 @@ class MarketDataCollector:
if cached_sentiment.get('vix'):
vix = cached_sentiment['vix']
result['VIX'] = {
'name': 'VIX恐慌指数',
'name': 'VIX Fear Index',
'description': vix.get('interpretation', ''),
'price': vix.get('value', 0),
'change': vix.get('change', 0),
@@ -971,7 +971,7 @@ class MarketDataCollector:
if cached_sentiment.get('dxy'):
dxy = cached_sentiment['dxy']
result['DXY'] = {
'name': '美元指数',
'name': 'US Dollar Index',
'description': dxy.get('interpretation', ''),
'price': dxy.get('value', 0),
'change': dxy.get('change', 0),
@@ -982,7 +982,7 @@ class MarketDataCollector:
if cached_sentiment.get('yield_curve'):
yc = cached_sentiment['yield_curve']
result['TNX'] = {
'name': '美债10年收益率',
'name': 'US 10Y Treasury Yield',
'description': yc.get('interpretation', ''),
'price': yc.get('yield_10y', 0),
'change': yc.get('change', 0),
@@ -994,7 +994,7 @@ class MarketDataCollector:
if cached_sentiment.get('fear_greed'):
fg = cached_sentiment['fear_greed']
result['FEAR_GREED'] = {
'name': '恐惧贪婪指数',
'name': 'Fear & Greed Index',
'description': fg.get('classification', 'Neutral'),
'price': fg.get('value', 50),
'change': 0,
@@ -1024,7 +1024,7 @@ class MarketDataCollector:
# Convert to unified format
if key == 'VIX':
result[key] = {
'name': 'VIX恐慌指数',
'name': 'VIX Fear Index',
'description': data.get('interpretation', ''),
'price': data.get('value', 0),
'change': data.get('change', 0),
@@ -1033,7 +1033,7 @@ class MarketDataCollector:
}
elif key == 'DXY':
result[key] = {
'name': '美元指数',
'name': 'US Dollar Index',
'description': data.get('interpretation', ''),
'price': data.get('value', 0),
'change': data.get('change', 0),
@@ -1042,7 +1042,7 @@ class MarketDataCollector:
}
elif key == 'TNX':
result[key] = {
'name': '美债10年收益率',
'name': 'US 10Y Treasury Yield',
'description': data.get('interpretation', ''),
'price': data.get('yield_10y', 0),
'change': data.get('change', 0),
@@ -1052,7 +1052,7 @@ class MarketDataCollector:
}
elif key == 'FEAR_GREED':
result[key] = {
'name': '恐惧贪婪指数',
'name': 'Fear & Greed Index',
'description': data.get('classification', 'Neutral'),
'price': data.get('value', 50),
'change': 0,
@@ -1122,7 +1122,7 @@ class MarketDataCollector:
"url": item.get('url', ''),
"sentiment": item.get('sentiment', 'neutral'),
})
logger.info(f"Finnhub 新闻获取成功: {len(news_list)} ")
logger.info(f"Finnhub news fetched successfully: {len(news_list)} items")
except Exception as e:
logger.debug(f"Finnhub news fetch failed: {e}")
@@ -1199,13 +1199,13 @@ class MarketDataCollector:
"datetime": result.published_date or datetime.now().strftime('%Y-%m-%d'),
"headline": result.title,
"summary": result.snippet[:200] if result.snippet else '',
"source": f"搜索:{result.source}",
"source": f"Search:{result.source}",
"url": result.url,
"sentiment": result.sentiment,
})
logger.info(f"搜索引擎新闻补充: {len(news_list)} 条 (来源: {response.provider})")
logger.info(f"Search-engine news supplement: {len(news_list)} items (provider: {response.provider})")
except Exception as e:
logger.debug(f"搜索引擎新闻获取失败: {e}")
logger.debug(f"Search-engine news fetch failed: {e}")
return news_list
@@ -1260,7 +1260,7 @@ class MarketDataCollector:
"datetime": result.published_date or datetime.now().strftime('%Y-%m-%d %H:%M'),
"headline": result.title,
"summary": result.snippet[:300] if result.snippet else '',
"source": f"全球事件:{result.source}",
"source": f"Global event:{result.source}",
"url": result.url,
"sentiment": "negative" if any(kw in text for kw in ["war", "conflict", "attack", "战争", "冲突", "袭击"]) else "neutral",
"is_global_event": True # Flag as global event
+22 -22
View File
@@ -9,7 +9,7 @@ Supported search engines (in order of priority):
4. Bing Search API
5. DuckDuckGo Get the scoop for free
参考daily_stock_analysis-main/src/search_service.py
Reference: daily_stock_analysis-main/src/search_service.py
"""
import requests
import json
@@ -72,9 +72,9 @@ class SearchResponse:
def to_context(self, max_results: int = 5) -> str:
"""Transform search results into context that can be used for AI analysis"""
if not self.success or not self.results:
return f"搜索 '{self.query}' 未找到相关结果。"
return f"No relevant results were found for '{self.query}'."
lines = [f"{self.query} 搜索结果】(来源:{self.provider}"]
lines = [f"[Search results for {self.query}] (source: {self.provider})"]
for i, result in enumerate(self.results[:max_results], 1):
lines.append(f"\n{i}. {result.to_text()}")
@@ -128,7 +128,7 @@ class BaseSearchProvider(ABC):
return key
# There is a problem with all keys, reset the error count and return the first one
logger.warning(f"[{self._name}] 所有 API Key 都有错误记录,重置错误计数")
logger.warning(f"[{self._name}] all API keys have recorded errors, resetting error counters")
self._key_errors = {key: 0 for key in self._api_keys}
return self._api_keys[0] if self._api_keys else None
@@ -142,7 +142,7 @@ class BaseSearchProvider(ABC):
def _record_error(self, key: str) -> None:
"""Log errors"""
self._key_errors[key] = self._key_errors.get(key, 0) + 1
logger.warning(f"[{self._name}] API Key {key[:8]}... 错误计数: {self._key_errors[key]}")
logger.warning(f"[{self._name}] API key {key[:8]}... error count: {self._key_errors[key]}")
@abstractmethod
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
@@ -168,7 +168,7 @@ class BaseSearchProvider(ABC):
results=[],
provider=self._name,
success=False,
error_message=f"{self._name} 未配置 API Key"
error_message=f"{self._name} API key is not configured"
)
start_time = time.time()
@@ -178,7 +178,7 @@ class BaseSearchProvider(ABC):
if response.success:
self._record_success(api_key)
logger.info(f"[{self._name}] 搜索 '{query}' 成功,返回 {len(response.results)} 条结果,耗时 {response.search_time:.2f}s")
logger.info(f"[{self._name}] search '{query}' succeeded, returned {len(response.results)} results in {response.search_time:.2f}s")
else:
self._record_error(api_key)
@@ -187,7 +187,7 @@ class BaseSearchProvider(ABC):
except Exception as e:
self._record_error(api_key)
elapsed = time.time() - start_time
logger.error(f"[{self._name}] 搜索 '{query}' 失败: {e}")
logger.error(f"[{self._name}] search '{query}' failed: {e}")
return SearchResponse(
query=query,
results=[],
@@ -203,9 +203,9 @@ class BaseSearchProvider(ABC):
try:
parsed = urlparse(url)
domain = parsed.netloc.replace('www.', '')
return domain or '未知来源'
return domain or 'Unknown source'
except:
return '未知来源'
return 'Unknown source'
class TavilySearchProvider(BaseSearchProvider):
@@ -265,7 +265,7 @@ class TavilySearchProvider(BaseSearchProvider):
except Exception as e:
error_msg = str(e)
if 'rate limit' in error_msg.lower() or 'quota' in error_msg.lower():
error_msg = f"API 配额已用尽: {error_msg}"
error_msg = f"API quota exhausted: {error_msg}"
return SearchResponse(
query=query,
@@ -483,7 +483,7 @@ class GoogleSearchProvider(BaseSearchProvider):
results=[],
provider=self.name,
success=False,
error_message="Google Search 未配置 CX"
error_message="Google Search CX is not configured"
)
try:
@@ -515,7 +515,7 @@ class GoogleSearchProvider(BaseSearchProvider):
results=[],
provider=self.name,
success=False,
error_message="Google API 配额已用尽"
error_message="Google API quota exhausted"
)
response.raise_for_status()
@@ -744,33 +744,33 @@ class SearchService:
tavily_keys = APIKeys.TAVILY_API_KEYS
if tavily_keys:
self._providers.append(TavilySearchProvider(tavily_keys))
logger.info(f"已配置 Tavily 搜索,共 {len(tavily_keys)} API Key")
logger.info(f"Configured Tavily search with {len(tavily_keys)} API keys")
# 2. SerpAPI
serpapi_keys = APIKeys.SERPAPI_KEYS
if serpapi_keys:
self._providers.append(SerpAPISearchProvider(serpapi_keys))
logger.info(f"已配置 SerpAPI 搜索,共 {len(serpapi_keys)} API Key")
logger.info(f"Configured SerpAPI search with {len(serpapi_keys)} API keys")
# 3. Google CSE
google_api_key = self._config.get('google', {}).get('api_key')
google_cx = self._config.get('google', {}).get('cx')
if google_api_key and google_cx:
self._providers.append(GoogleSearchProvider(google_api_key, google_cx))
logger.info("已配置 Google CSE 搜索")
logger.info("Configured Google CSE search")
# 4. Bing
bing_api_key = self._config.get('bing', {}).get('api_key')
if bing_api_key:
self._providers.append(BingSearchProvider(bing_api_key))
logger.info("已配置 Bing 搜索")
logger.info("Configured Bing search")
# 5. DuckDuckGo (Free Tips)
self._providers.append(DuckDuckGoSearchProvider())
logger.info("已配置 DuckDuckGo 搜索(免费兜底)")
logger.info("Configured DuckDuckGo search as the free fallback")
if len(self._providers) == 1:
logger.warning("仅有 DuckDuckGo 可用,建议配置更多搜索引擎 API Key")
logger.warning("Only DuckDuckGo is available. Configure more search-engine API keys for better coverage.")
@property
def is_available(self) -> bool:
@@ -826,7 +826,7 @@ class SearchService:
if response.success and response.results:
return response
else:
logger.warning(f"{provider.name} 搜索失败: {response.error_message},尝试下一个引擎")
logger.warning(f"{provider.name} search failed: {response.error_message}. Trying the next engine.")
# all engines fail
return SearchResponse(
@@ -834,7 +834,7 @@ class SearchService:
results=[],
provider="None",
success=False,
error_message="所有搜索引擎都不可用或搜索失败"
error_message="All search engines are unavailable or failed"
)
def search_stock_news(
@@ -875,7 +875,7 @@ class SearchService:
else:
query = f"{stock_name} {stock_code} latest news"
logger.info(f"搜索股票新闻: {stock_name}({stock_code}), market={market}, days={search_days}")
logger.info(f"Searching stock news: {stock_name}({stock_code}), market={market}, days={search_days}")
return self.search_with_fallback(query, max_results, search_days)
@@ -501,15 +501,15 @@ class SignalNotifier:
"""
Generic webhook delivery.
用户在个人中心配置
- webhook_url: Webhook 地址
- webhook_token: Bearer Token可选
User configuration in the profile center:
- webhook_url: webhook URL
- webhook_token: optional Bearer token
支持功能
- 自定义 headers: notification_config.targets.webhook_headers
Supported features:
- Custom headers: notification_config.targets.webhook_headers
- Bearer Token: notification_config.targets.webhook_token
- 签名验证: notification_config.targets.webhook_signing_secret
- 自动重试: 429/5xx 时重试一次
- Signature verification: notification_config.targets.webhook_signing_secret
- Automatic retry: retry once on 429/5xx
"""
if not url:
return False, "missing_webhook_url"
@@ -933,6 +933,8 @@ class StrategyService:
trading_config = payload.get('trading_config') if payload.get('trading_config') is not None else (existing.get('trading_config') or {})
exchange_config = payload.get('exchange_config') if payload.get('exchange_config') is not None else (existing.get('exchange_config') or {})
ai_model_config = payload.get('ai_model_config') if payload.get('ai_model_config') is not None else (existing.get('ai_model_config') or {})
strategy_mode = payload.get('strategy_mode') if payload.get('strategy_mode') is not None else (existing.get('strategy_mode') or 'signal')
strategy_code = payload.get('strategy_code') if payload.get('strategy_code') is not None else (existing.get('strategy_code') or '')
# When credential_id is present, strip raw API keys to avoid
# storing secrets in the strategy record — they live in qd_exchange_credentials.
@@ -964,6 +966,8 @@ class StrategyService:
"""
UPDATE qd_strategies_trading
SET strategy_name = ?,
strategy_mode = ?,
strategy_code = ?,
market_category = ?,
execution_mode = ?,
notification_config = ?,
@@ -981,6 +985,8 @@ class StrategyService:
""",
(
name,
strategy_mode,
strategy_code,
market_category,
execution_mode,
self._dump_json_or_encrypt(notification_config, encrypt=False),
@@ -88,9 +88,10 @@ class TradingExecutor:
def _normalize_trade_symbol(self, exchange: Any, symbol: str, market_type: str, exchange_id: str) -> str:
"""
将数据库/配置里的 symbol 规范化为交易所合约可用的 CCXT symbol
Normalize symbols from the database/config into the CCXT symbol format accepted by the exchange.
典型场景OKX 永续统一符号通常是 `BNB/USDT:USDT`但前端/数据库可能传 `BNB/USDT`
Typical case: the normalized OKX perpetual symbol is usually `BNB/USDT:USDT`,
while the frontend or database may pass `BNB/USDT`.
"""
try:
# New system: only supports swap (perpetual contract) / spot (spot)
@@ -131,7 +132,7 @@ class TradingExecutor:
return symbol
def _log_resource_status(self, prefix: str = ""):
"""调试:记录线程/内存使用,便于定位 can't start new thread 根因"""
"""Debug helper: record thread and memory usage to diagnose the root cause of 'can't start new thread'."""
try:
import psutil # Use more precise metrics if installed
p = psutil.Process()
@@ -415,7 +416,7 @@ class TradingExecutor:
thread.start()
except Exception as e:
# Capture exceptions such as can't start new thread and record resource status
self._log_resource_status(prefix="启动异常")
self._log_resource_status(prefix="startup exception")
raise e
self.running_strategies[strategy_id] = thread
@@ -631,15 +632,15 @@ class TradingExecutor:
# Even in signal mode, it is necessary to check and clean up the situation when the user manually closes the position on the exchange but the database record is still there.
# This can prevent the strategy from thinking that there are still positions and being unable to execute a new opening signal.
try:
logger.info(f"策略 {strategy_id} 启动时检查持仓同步...")
logger.info(f"Checking position synchronization during startup for strategy {strategy_id}...")
# Call position synchronization logic (check even in signal mode)
from app import get_pending_order_worker
worker = get_pending_order_worker()
if worker and hasattr(worker, '_sync_positions_best_effort'):
worker._sync_positions_best_effort(target_strategy_id=strategy_id)
logger.info(f"策略 {strategy_id} 启动时持仓同步完成")
logger.info(f"Position synchronization completed during startup for strategy {strategy_id}")
except Exception as e:
logger.warning(f"策略 {strategy_id} 启动时持仓同步失败(不影响启动): {e}")
logger.warning(f"Position synchronization failed during startup for strategy {strategy_id} (startup continues): {e}")
# Get the current highest position price (read from local database)
current_pos_list = self._get_current_positions(strategy_id, symbol)
@@ -660,7 +661,7 @@ class TradingExecutor:
# Key diagnostic log: Confirm whether the indicator has obtained the position status
logger.info(
f"策略 {strategy_id} 指标注入持仓状态: count={len(current_pos_list)}, "
f"Injected position state for strategy {strategy_id}: count={len(current_pos_list)}, "
f"position={initial_position}, entry_price={initial_avg_entry_price}, highest={initial_highest}"
)
@@ -1005,7 +1006,7 @@ class TradingExecutor:
market=market_type or 'Crypto',
symbol=symbol,
signal_type=signal_type,
signal_detail=f"策略: {strategy_name}\n信号: {signal_type}\n价格: {execute_price:.4f}"
signal_detail=f"Strategy: {strategy_name}\nSignal: {signal_type}\nPrice: {execute_price:.4f}"
)
except Exception as link_e:
logger.warning(f"Strategy signal linkage notification failed: {link_e}")
@@ -1095,8 +1096,8 @@ class TradingExecutor:
def _is_strategy_running(self, strategy_id: int) -> bool:
"""
检查策略是否在运行
同时检查数据库状态和线程状态避免重启后状态不一致
Check whether the strategy is running.
This checks both the database state and the thread state to avoid mismatches after restart.
"""
try:
# 1. Check database status
@@ -1145,7 +1146,7 @@ class TradingExecutor:
leverage: float = None,
strategy_id: int = None
) -> Optional[ccxt.Exchange]:
"""(Mock) 信号模式不需要真实交易所连接"""
"""(Mock) Signal mode does not require a real exchange connection."""
return None
def _fetch_latest_kline(self, symbol: str, timeframe: str, limit: int = 500, market_category: str = 'Crypto') -> List[Dict[str, Any]]:
@@ -1872,7 +1873,7 @@ class TradingExecutor:
allowed_modules = ['numpy', 'pandas', 'math', 'json', 'time']
if name in allowed_modules or name.split('.')[0] in allowed_modules:
return builtins.__import__(name, *args, **kwargs)
raise ImportError(f"不允许导入模块: {name}")
raise ImportError(f"Importing modules is not allowed: {name}")
safe_builtins = {k: getattr(builtins, k) for k in dir(builtins)
if not k.startswith('_') and k not in [
@@ -1961,7 +1962,7 @@ class TradingExecutor:
return []
def _execute_trading_logic(self, *args, **kwargs):
"""已废弃"""
"""Deprecated."""
pass
def _execute_signal(
@@ -2014,8 +2015,8 @@ class TradingExecutor:
# Best-effort persist a browser notification so UI can show "HOLD due to AI filter".
reason = (ai_info or {}).get("reason") or "ai_filter_rejected"
ai_decision = (ai_info or {}).get("ai_decision") or ""
title = f"AI过滤拦截开仓 | {symbol}"
msg = f"策略信号={sig}AI决策={ai_decision or 'UNKNOWN'},原因={reason};已HOLD(不下单)"
title = f"AI filter blocked opening a position | {symbol}"
msg = f"Strategy signal={sig}, AI decision={ai_decision or 'UNKNOWN'}, reason={reason}; action forced to HOLD (no order placed)"
self._persist_browser_notification(
strategy_id=strategy_id,
symbol=symbol,
@@ -2369,7 +2370,7 @@ class TradingExecutor:
payload: Optional[Dict[str, Any]] = None,
user_id: int = None,
) -> None:
"""Best-effort persist notification row for the frontend '通知' panel (browser channel)."""
"""Best-effort persistence of a notification row for the frontend notifications panel (browser channel)."""
try:
now = int(time.time())
# Get user_id from strategy if not provided
@@ -2776,7 +2777,7 @@ class TradingExecutor:
highest_price: float = 0.0,
lowest_price: float = 0.0,
):
"""更新持仓状态"""
"""Update position state."""
try:
# Get user_id from strategy
user_id = 1
@@ -2811,7 +2812,7 @@ class TradingExecutor:
logger.error(f"Failed to update position: {e}")
def _close_position(self, strategy_id: int, symbol: str, side: str):
"""平仓:删除持仓记录"""
"""Close a position by deleting the position record."""
try:
with get_db_connection() as db:
cursor = db.cursor()
@@ -2861,7 +2862,7 @@ class TradingExecutor:
return []
def _should_rebalance(self, strategy_id: int, rebalance_frequency: str) -> bool:
"""检查是否应该调仓"""
"""Check whether rebalancing should run."""
try:
with get_db_connection() as db:
cursor = db.cursor()
@@ -2892,7 +2893,7 @@ class TradingExecutor:
return True
def _update_last_rebalance(self, strategy_id: int):
"""更新上次调仓时间"""
"""Update the last rebalance timestamp."""
try:
with get_db_connection() as db:
cursor = db.cursor()
@@ -2920,7 +2921,7 @@ class TradingExecutor:
timeframe: str
) -> Optional[Dict[str, Any]]:
"""
执行截面策略指标返回所有标的的评分和排序
Execute cross-sectional strategy indicators and return scores and ranking for all instruments.
"""
try:
# Get K-line data of all targets
@@ -2989,7 +2990,7 @@ class TradingExecutor:
trading_config: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""
根据排序结果生成截面策略信号
Generate cross-sectional strategy signals from the ranking results.
"""
portfolio_size = trading_config.get('portfolio_size', 10)
long_ratio = float(trading_config.get('long_ratio', 0.5))
@@ -3080,7 +3081,7 @@ class TradingExecutor:
indicator_id: Optional[int]
):
"""
截面策略执行循环
Cross-sectional strategy execution loop.
"""
logger.info(f"Starting cross-sectional strategy loop for strategy {strategy_id}")
@@ -3188,4 +3189,4 @@ class TradingExecutor:
except Exception as e:
logger.error(f"Cross-sectional strategy loop error: {e}")
logger.error(traceback.format_exc())
time.sleep(5) # Wait before retrying
time.sleep(5) # Wait before retrying
+11 -11
View File
@@ -50,7 +50,7 @@ def timeout_context(seconds: int):
return
def timeout_handler(signum, frame):
raise TimeoutError(f"代码执行超时(超过{seconds}秒)")
raise TimeoutError(f"Code execution timed out after {seconds} seconds")
try:
# Set up signal handler
@@ -129,7 +129,7 @@ def safe_exec_code(
}
except MemoryError as e:
error_msg = f"代码执行内存不足(超过{max_memory_mb}MB限制)"
error_msg = f"Code execution exceeded the memory limit of {max_memory_mb}MB"
logger.error(f"Code execution out of memory (limit={max_memory_mb}MB)")
return {
'success': False,
@@ -145,7 +145,7 @@ def safe_exec_code(
'result': None
}
except Exception as e:
error_msg = f"代码执行错误: {str(e)}\n{traceback.format_exc()}"
error_msg = f"Code execution error: {str(e)}\n{traceback.format_exc()}"
logger.error(f"Code execution error: {str(e)}")
logger.error(traceback.format_exc())
return {
@@ -237,7 +237,7 @@ def validate_code_safety(code: str) -> Tuple[bool, Optional[str]]:
# Check your code for dangerous patterns
for pattern in dangerous_patterns:
if re.search(pattern, code):
return False, f"检测到危险代码模式: {pattern}"
return False, f"Dangerous code pattern detected: {pattern}"
# Try parsing the AST, checking for dangerous nodes
try:
@@ -267,13 +267,13 @@ def validate_code_safety(code: str) -> Tuple[bool, Optional[str]]:
if isinstance(node.func, ast.Name):
func_name = node.func.id
if func_name in dangerous_functions:
return False, f"检测到危险函数调用: {func_name}()"
return False, f"Dangerous function call detected: {func_name}()"
# Check if there are calls to os.system etc.
if isinstance(node.func, ast.Attribute):
if isinstance(node.func.value, ast.Name):
if node.func.value.id in dangerous_modules:
return False, f"检测到危险模块调用: {node.func.value.id}.{node.func.attr}"
return False, f"Dangerous module call detected: {node.func.value.id}.{node.func.attr}"
# Check if there are bypass methods such as getattr(builtins, '__import__')
if isinstance(node.func, ast.Name) and node.func.id == 'getattr':
@@ -281,15 +281,15 @@ def validate_code_safety(code: str) -> Tuple[bool, Optional[str]]:
if len(node.args) >= 2:
if isinstance(node.args[0], ast.Name) and node.args[0].id in ['builtins', '__builtins__']:
if isinstance(node.args[1], ast.Constant) and node.args[1].value in dangerous_functions:
return False, f"检测到通过 getattr 绕过限制: getattr({node.args[0].id}, '{node.args[1].value}')"
return False, f"Detected an attempt to bypass restrictions via getattr: getattr({node.args[0].id}, '{node.args[1].value}')"
# Check the import statement: the use of import is prohibited in user scripts (security dependencies are uniformly injected by the platform)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
return False, "不允许在脚本中使用 import 语句,请直接使用平台提供的 pd/np 等对象"
return False, "Import statements are not allowed in scripts. Use the platform-provided objects such as pd and np directly."
if isinstance(node, ast.ImportFrom):
return False, "不允许在脚本中使用 import 语句,请直接使用平台提供的 pd/np 等对象"
return False, "Import statements are not allowed in scripts. Use the platform-provided objects such as pd and np directly."
# Check if there is an attempt to access __builtins__
for node in ast.walk(tree):
@@ -298,10 +298,10 @@ def validate_code_safety(code: str) -> Tuple[bool, Optional[str]]:
if node.attr in ['__builtins__', '__import__', '__class__', '__bases__', '__subclasses__', '__mro__']:
# Check if used in dangerous context
if isinstance(node.value, ast.Name) and node.value.id in ['builtins', '__builtins__']:
return False, f"检测到访问危险属性: {node.value.id}.{node.attr}"
return False, f"Dangerous attribute access detected: {node.value.id}.{node.attr}"
except SyntaxError as e:
return False, f"代码语法错误: {str(e)}"
return False, f"Code syntax error: {str(e)}"
except Exception as e:
# If AST parsing fails, log a warning but allow to continue (possibly incomplete code)
logger.warning(f"AST parse failed; skipping safety checks: {str(e)}")
+1 -1
View File
@@ -88,7 +88,7 @@ GITHUB_REDIRECT_URI=http://localhost:5000/api/auth/oauth/github/callback
# =========================
BILLING_ENABLED=false
# 积分单价
# Credit unit cost
BILLING_COST_AI_ANALYSIS=10
BILLING_COST_AI_CODE_GEN=30
@@ -1,19 +1,19 @@
"""
回填历史 qd_strategy_trades price/amount/value 0 的记录
Backfill historical qd_strategy_trades rows whose price/amount/value are 0.
背景
- 某些交易所/订单类型下执行器回报里 filled_price/filled_amount 可能为 0
但交易所实际已成交导致交易纪律/交易记录显示为 0
- 我们现在在 OrderProcessor 中增加了fetch_order/fetch_my_trades 回补逻辑避免新数据再出现该问题
- 对历史脏数据可用 qd_pending_orders 中的 executed_at/filled_price/filled_amount/fee 做近似匹配回填
Background:
- For some exchanges and order types, the executor may report filled_price/filled_amount as 0
even though the exchange has actually filled the order, causing trade records to show 0.
- We have now added fetch_order/fetch_my_trades backfill logic in OrderProcessor to avoid this for new data.
- For historical dirty data, use executed_at/filled_price/filled_amount/fee in qd_pending_orders for approximate backfilling.
使用
Usage:
python backend_api_python/scripts/backfill_zero_trades.py --strategy-id 43 --since 2025-12-24 --until 2025-12-25
python backend_api_python/scripts/backfill_zero_trades.py --strategy-id 43 --since 2025-12-24 --until 2025-12-25 --apply
注意
- 该脚本按 (strategy_id, symbol, type) + 时间窗口(默认 ±600s) 匹配 qd_pending_orders
- 若同一条 trade 匹配到多个候选订单将选择 executed_at 最接近的那条若仍不唯一会跳过
Notes:
- The script matches qd_pending_orders by (strategy_id, symbol, type) plus a time window of ±600s by default.
- If one trade matches multiple candidate orders, it chooses the one with the closest executed_at; if still ambiguous, it skips the row.
"""
from __future__ import annotations
@@ -28,15 +28,15 @@ from app.utils.db import get_db_connection
def _parse_date_to_ts(s: str) -> int:
s = (s or "").strip()
# 支持 YYYY-MM-DD YYYY/MM/DD
# Support YYYY-MM-DD or YYYY/MM/DD
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S"):
try:
dt = datetime.strptime(s, fmt)
# 服务器通常用本地时间写入 int(time.time());这里按本地时间解析
# The server usually writes int(time.time()) in local time; parse it as local time here
return int(dt.replace(tzinfo=None).timestamp())
except Exception:
pass
raise ValueError(f"无法解析日期: {s}")
raise ValueError(f"Unable to parse date: {s}")
def _fetch_bad_trades(strategy_id: int, since_ts: int, until_ts: int, limit: int) -> List[Dict[str, Any]]:
@@ -95,7 +95,7 @@ def _find_best_order_match(
cursor.close()
if not cand:
return None
# 若最接近的有并列(比如 executed_at 相同),认为不唯一,跳过以免误回填
# If the closest candidates are tied, such as identical executed_at, treat it as ambiguous and skip it
if len(cand) >= 2 and abs(int(cand[0]["executed_at"]) - trade_ts) == abs(int(cand[1]["executed_at"]) - trade_ts):
return None
return cand[0]
@@ -128,11 +128,11 @@ def _update_trade(
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--strategy-id", type=int, required=True)
ap.add_argument("--since", type=str, required=True, help="YYYY-MM-DD YYYY/MM/DD")
ap.add_argument("--until", type=str, required=True, help="YYYY-MM-DD YYYY/MM/DD")
ap.add_argument("--window-sec", type=int, default=600, help="匹配窗口,默认±600秒")
ap.add_argument("--limit", type=int, default=500, help="最多处理多少条 trade")
ap.add_argument("--apply", action="store_true", help="真正写库;默认 dry-run 仅打印")
ap.add_argument("--since", type=str, required=True, help="YYYY-MM-DD or YYYY/MM/DD")
ap.add_argument("--until", type=str, required=True, help="YYYY-MM-DD or YYYY/MM/DD")
ap.add_argument("--window-sec", type=int, default=600, help="Match window, default ±600 seconds")
ap.add_argument("--limit", type=int, default=500, help="Maximum number of trades to process")
ap.add_argument("--apply", action="store_true", help="Actually write to the database; default is dry-run output only")
args = ap.parse_args()
since_ts = _parse_date_to_ts(args.since)
@@ -2,8 +2,8 @@ import sys
import os
from dotenv import load_dotenv
# 添加后端目录到 Python 路径(使得可以 import app.*
# 由于 app 包位于 backend_api_python/app 下,而脚本位于 backend_api_python/scripts
# Add the backend directory to the Python path so app.* can be imported
# The app package lives under backend_api_python/app while this script is under backend_api_python/scripts
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from app.services.reflection import ReflectionService
@@ -19,8 +19,8 @@ def main():
load_dotenv(backend_env_path, override=False)
"""
运行自动反思验证任务
建议通过 cron 定时任务调度器 每天运行一次
Run the automated reflection verification task.
It is intended to run once per day via cron or another task scheduler.
"""
print("Running Automated Reflection Verification Task...")
service = ReflectionService()
+10 -11
View File
@@ -1,26 +1,25 @@
#!/bin/bash
# QuantDinger Python API 启动脚本
# QuantDinger Python API startup script
# 激活虚拟环境(如果使用虚拟环境)
# Activate the virtual environment if one is used
# source venv/bin/activate
# 检查依赖是否安装
# Check whether dependencies are installed
if ! python -c "import flask" 2>/dev/null; then
echo "正在安装依赖..."
echo "Installing dependencies..."
pip install -r requirements.txt
fi
# 启动服务
echo "启动 QuantDinger Python API 服务..."
echo "服务地址: http://0.0.0.0:5000"
# Start the service
echo "Starting QuantDinger Python API service..."
echo "Service address: http://0.0.0.0:5000"
# 创建日志目录
# Create the log directory
mkdir -p logs
# 开发环境(使用新的入口文件)
# Development environment (uses the new entry file)
python run.py
# 生产环境(使用 gunicorn
# Production environment (uses gunicorn)
# gunicorn -w 4 -b 0.0.0.0:5000 --timeout 120 --access-logfile logs/access.log --error-logfile logs/error.log "run:create_app()"
+2 -2
View File
@@ -3,7 +3,7 @@ const IS_PREVIEW = process.env.VUE_APP_PREVIEW === 'true'
const plugins = []
if (IS_PROD && !IS_PREVIEW) {
// 去除日志的插件,
// Plugin used to strip logs
plugins.push('transform-remove-console')
}
@@ -12,7 +12,7 @@ if (IS_PROD && !IS_PREVIEW) {
plugins.push(['import', {
'libraryName': 'ant-design-vue',
'libraryDirectory': 'es',
'style': true // `style: true` 会加载 less 文件
'style': true // `style: true` loads the LESS files
}])
module.exports = {
+11 -11
View File
@@ -1,15 +1,15 @@
/**
* feat新增功能
* fixbug 修复
* docs文档更新
* style不影响程序逻辑的代码修改(修改空白字符格式缩进补全缺失的分号等没有改变代码逻辑)
* refactor重构代码(既没有新增功能也没有修复 bug)
* perf性能, 体验优化
* test新增测试用例或是更新现有测试
* build主要目的是修改项目构建系统(例如 glupwebpackrollup 的配置等)的提交
* ci主要目的是修改项目继续集成流程(例如 TravisJenkinsGitLab CICircle等)的提交
* chore不属于以上类型的其他类型比如构建流程, 依赖管理
* revert回滚某个更早之前的提交
* feat: add a new feature
* fix: bug fix
* docs: documentation update
* style: code changes that do not affect program logic, such as whitespace, formatting, or missing semicolons
* refactor: code refactoring that neither adds a feature nor fixes a bug
* perf: performance or experience optimization
* test: add new test cases or update existing ones
* build: changes mainly related to the project build system, such as gulp, webpack, or rollup config
* ci: changes mainly related to the continuous integration flow, such as Travis, Jenkins, GitLab CI, or Circle
* chore: other changes that do not fit the categories above, such as build flow or dependency management
* revert: revert an earlier commit
*/
module.exports = {
+3 -3
View File
@@ -2,7 +2,7 @@ const ThemeColorReplacer = require('webpack-theme-color-replacer')
const generate = require('@ant-design/colors/lib/generate').default
const getAntdSerials = (color) => {
// 淡化(即less的tint
// Lighten (the LESS tint equivalent)
const lightens = new Array(9).fill().map((t, i) => {
return ThemeColorReplacer.varyColor.lighten(color, i / 10)
})
@@ -13,8 +13,8 @@ const getAntdSerials = (color) => {
const themePluginOption = {
fileName: 'css/theme-colors-[contenthash:8].css',
matchColors: getAntdSerials('#1890ff'), // 主色系列
// 改变样式选择器,解决样式覆盖问题
matchColors: getAntdSerials('#1890ff'), // Primary color palette
// Change the style selector to avoid style override issues
changeSelector (selector) {
switch (selector) {
case '.ant-calendar-today .ant-calendar-date':
+37 -37
View File
@@ -37,7 +37,7 @@
max-width: 600px;
}
/* 像素风格小猫奔跑动画容器 */
/* Pixel-style running cat animation container */
.pixel-cat-container {
position: relative;
width: 100%;
@@ -46,7 +46,7 @@
overflow: hidden;
}
/* 像素小猫主体 */
/* Pixel cat body */
.pixel-cat {
position: absolute;
left: 0;
@@ -59,14 +59,14 @@
image-rendering: crisp-edges;
}
/* 所有像素元素都使用锐利边缘 */
/* All pixel elements use sharp edges */
.pixel-cat * {
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
}
/* 猫头 - 像素方块组成 */
/* Cat head - built from pixel blocks */
.cat-head {
position: absolute;
left: 2px;
@@ -74,16 +74,16 @@
width: 16px;
height: 14px;
background:
/* 头部主体白色 */
/* White base of the head */
linear-gradient(#fff, #fff) 2px 4px / 12px 10px no-repeat,
/* 左半脸黑色斑块 */
/* Black patch on the left side of the face */
linear-gradient(#000, #000) 2px 4px / 6px 10px no-repeat,
/* 头顶 */
/* Top of the head */
linear-gradient(#fff, #fff) 4px 2px / 8px 2px no-repeat;
animation: headBob 0.4s steps(2) infinite;
}
/* 左耳 - 黑色尖耳朵 */
/* Left ear - black pointed ear */
.cat-ear-left {
position: absolute;
left: 0px;
@@ -96,7 +96,7 @@
linear-gradient(#000, #000) 1px 0px / 2px 2px no-repeat;
}
/* 右耳 - 白色尖耳朵 */
/* Right ear - white pointed ear */
.cat-ear-right {
position: absolute;
right: 0px;
@@ -120,7 +120,7 @@
box-sizing: border-box;
}
/* 左眼 - 黑底白眼(在黑色区域) */
/* Left eye - white eye on black background */
.cat-eye-left {
position: absolute;
left: 4px;
@@ -140,7 +140,7 @@
background: #000;
}
/* 右眼 - 白底黑眼(在白色区域) */
/* Right eye - black eye on white background */
.cat-eye-right {
position: absolute;
right: 2px;
@@ -162,7 +162,7 @@
background: #000;
}
/* 鼻子 - 小粉色方块 */
/* Nose - small pink square */
.cat-nose {
position: absolute;
left: 7px;
@@ -172,7 +172,7 @@
background: #000;
}
/* 胡须 - 像素线条 */
/* Whiskers - pixel lines */
.cat-whiskers {
position: absolute;
left: 0;
@@ -203,7 +203,7 @@
box-shadow: 0 2px 0 #000;
}
/* 身体 - 黑白相间像素块 */
/* Body - black and white pixel blocks */
.cat-body {
position: absolute;
left: 4px;
@@ -211,15 +211,15 @@
width: 14px;
height: 10px;
background:
/* 白色部分 */
/* White section */
linear-gradient(#fff, #fff) 6px 0 / 8px 10px no-repeat,
/* 黑色部分 */
/* Black section */
linear-gradient(#000, #000) 0 0 / 8px 10px no-repeat;
border: 1px solid #000;
box-sizing: border-box;
}
/* 前腿 - 左 (黑色) */
/* Front leg - left, black */
.cat-leg-front-left {
position: absolute;
left: 6px;
@@ -230,7 +230,7 @@
animation: legFront 0.2s steps(2) infinite;
}
/* 前腿 - 右 (白色带边框) */
/* Front leg - right, white with border */
.cat-leg-front-right {
position: absolute;
left: 12px;
@@ -243,7 +243,7 @@
animation: legFront 0.2s steps(2) infinite 0.1s;
}
/* 后腿 - 左 (白色带边框) */
/* Back leg - left, white with border */
.cat-leg-back-left {
position: absolute;
left: 3px;
@@ -256,7 +256,7 @@
animation: legBack 0.2s steps(2) infinite 0.1s;
}
/* 后腿 - 右 (黑色) */
/* Back leg - right, black */
.cat-leg-back-right {
position: absolute;
left: 15px;
@@ -267,7 +267,7 @@
animation: legBack 0.2s steps(2) infinite;
}
/* 尾巴 - 长且弯曲的像素尾巴 */
/* Tail - a long curved pixel tail */
.cat-tail {
position: absolute;
right: -10px;
@@ -275,55 +275,55 @@
width: 12px;
height: 10px;
background:
/* 尾巴根部 */
/* Tail base */
linear-gradient(#000, #000) 0 6px / 3px 3px no-repeat,
/* 尾巴中部 */
/* Middle of the tail */
linear-gradient(#000, #000) 3px 4px / 3px 3px no-repeat,
/* 尾巴弯曲 */
/* Tail curve */
linear-gradient(#000, #000) 6px 2px / 3px 3px no-repeat,
/* 尾巴尖端 */
/* Tail tip */
linear-gradient(#000, #000) 9px 0 / 3px 3px no-repeat;
animation: tailWag 0.3s steps(2) infinite alternate;
transform-origin: left bottom;
}
/* 奔跑动画 - 轻微上下跳动 */
/* Running animation - slight vertical bounce */
@keyframes catRun {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
/* 移动动画 - 左右移动 */
/* Movement animation - move left and right */
@keyframes catMove {
0% { left: -40px; }
100% { left: calc(100% + 40px); }
}
/* 前腿动画 */
/* Front leg animation */
@keyframes legFront {
0%, 100% { transform: rotate(-15deg); }
50% { transform: rotate(15deg); }
}
/* 后腿动画 */
/* Back leg animation */
@keyframes legBack {
0%, 100% { transform: rotate(15deg); }
50% { transform: rotate(-15deg); }
}
/* 尾巴摆动 */
/* Tail swing */
@keyframes tailWag {
0% { transform: rotate(-10deg); }
100% { transform: rotate(10deg); }
}
/* 头部轻微摆动 */
/* Subtle head sway */
@keyframes headBob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-1px); }
}
/* 地面效果 - 像素风格 */
/* Ground effect - pixel style */
.ground {
position: absolute;
bottom: 0;
@@ -346,7 +346,7 @@
100% { background-position: 12px 0; }
}
/* 品牌文字 */
/* Brand text */
.brand-text {
display: flex;
justify-content: center;
@@ -358,7 +358,7 @@
letter-spacing: 2px;
}
/* 暗色主题适配 */
/* Dark theme adaptation */
@media (prefers-color-scheme: dark) {
.first-loading-wrp {
background: #141414;
@@ -382,7 +382,7 @@
);
}
/* 暗色模式下白色部分改成浅灰 */
/* Use light gray instead of pure white in dark mode */
.cat-head {
background:
linear-gradient(#ddd, #ddd) 2px 4px / 12px 10px no-repeat,
@@ -407,7 +407,7 @@
}
}
/* 确保像素风格在所有浏览器中正确显示 */
/* Ensure the pixel style renders correctly in all browsers */
.pixel-cat,
.pixel-cat * {
image-rendering: -moz-crisp-edges;
@@ -416,7 +416,7 @@
image-rendering: crisp-edges;
}
/* 手机端适配 */
/* Mobile adaptation */
@media (max-width: 768px) {
.pixel-cat-container {
transform: scale(1.5);
+9 -9
View File
@@ -13,7 +13,7 @@ const api = {
}
/**
* 获取AI交易策略列表
* Get AI trading strategy list
*/
export function getStrategies () {
return request({
@@ -23,7 +23,7 @@ export function getStrategies () {
}
/**
* 创建AI交易策略
* Create AI trading strategy
*/
export function createAIStrategy (data) {
return request({
@@ -34,7 +34,7 @@ export function createAIStrategy (data) {
}
/**
* 更新AI交易策略
* Update AI trading strategy
*/
export function updateAIStrategy (data) {
return request({
@@ -45,7 +45,7 @@ export function updateAIStrategy (data) {
}
/**
* 删除策略
* Delete strategy
*/
export function deleteStrategy (strategyId) {
return request({
@@ -56,7 +56,7 @@ export function deleteStrategy (strategyId) {
}
/**
* 启动策略
* Start strategy
*/
export function startStrategy (strategyId) {
return request({
@@ -67,7 +67,7 @@ export function startStrategy (strategyId) {
}
/**
* 停止策略
* Stop strategy
*/
export function stopStrategy (strategyId) {
return request({
@@ -78,7 +78,7 @@ export function stopStrategy (strategyId) {
}
/**
* 测试交易所连接
* Test exchange connection
*/
export function testConnection (data) {
return request({
@@ -89,7 +89,7 @@ export function testConnection (data) {
}
/**
* 获取AI决策记录
* Get AI decision records
*/
export function getAIDecisions (strategyId, params) {
return request({
@@ -103,7 +103,7 @@ export function getAIDecisions (strategyId, params) {
}
/**
* 获取系统支持的交易对列表
* Get supported trading pairs
*/
export function getCryptoSymbols () {
return request({
+18 -18
View File
@@ -28,7 +28,7 @@ const marketApi = {
}
/**
* 获取自选股列表
* Get watchlist
* @param parameter { userid: number }
* @returns {*}
*/
@@ -41,7 +41,7 @@ export function getWatchlist (parameter) {
}
/**
* 添加自选股
* Add watchlist item
* @param parameter { userid: number, market: string, symbol: string }
* @returns {*}
*/
@@ -54,7 +54,7 @@ export function addWatchlist (parameter) {
}
/**
* 删除自选股
* Remove watchlist item
* @param parameter { userid: number, symbol: string }
* @returns {*}
*/
@@ -67,8 +67,8 @@ export function removeWatchlist (parameter) {
}
/**
* 获取自选股价格
* @param parameter { watchlist: array } watchlist格式[{market: 'USStock', symbol: 'AAPL'}, ...]
* Get watchlist prices
* @param parameter { watchlist: array } watchlist format: [{market: 'USStock', symbol: 'AAPL'}, ...]
* @returns {*}
*/
export function getWatchlistPrices (parameter) {
@@ -82,7 +82,7 @@ export function getWatchlistPrices (parameter) {
}
/**
* 发送 AI 聊天消息
* Send AI chat message
* @param parameter { userid: number, message: string, chatId?: string }
* @returns {*}
*/
@@ -95,7 +95,7 @@ export function chatMessage (parameter) {
}
/**
* 获取聊天历史
* Get chat history
* @param parameter { userid: number }
* @returns {*}
*/
@@ -108,7 +108,7 @@ export function getChatHistory (parameter) {
}
/**
* 保存聊天历史
* Save chat history
* @param parameter { userid: number, chatHistory: array }
* @returns {*}
*/
@@ -121,7 +121,7 @@ export function saveChatHistory (parameter) {
}
/**
* 执行多维度分析
* Run multi-dimensional analysis
* @param parameter { userid: number, market: string, symbol: string }
* @returns {*}
*/
@@ -135,7 +135,7 @@ export function multiAnalysis (parameter) {
}
/**
* 创建分析任务
* Create analysis task
* @param parameter { userid: number, market: string, symbol: string }
* @returns {*}
*/
@@ -148,7 +148,7 @@ export function createAnalysisTask (parameter) {
}
/**
* 获取分析任务状态
* Get analysis task status
* @param parameter { task_id: number }
* @returns {*}
*/
@@ -161,7 +161,7 @@ export function getAnalysisTaskStatus (parameter) {
}
/**
* 获取历史分析列表
* Get analysis history list
* @param parameter { userid: number, page?: number, pagesize?: number }
* @returns {*}
*/
@@ -187,7 +187,7 @@ export function deleteAnalysisTask (parameter) {
}
/**
* 反思学习
* Reflection learning
* @param parameter { market: string, symbol: string, decision: string, returns?: number, result?: string }
* @returns {*}
*/
@@ -200,7 +200,7 @@ export function reflectAnalysis (parameter) {
}
/**
* 获取插件配置
* Get plugin configuration
* @returns {*}
*/
export function getConfig () {
@@ -211,7 +211,7 @@ export function getConfig () {
}
/**
* 获取菜单底部配置
* Get footer menu configuration
* @returns {*}
*/
export function getMenuFooterConfig () {
@@ -222,7 +222,7 @@ export function getMenuFooterConfig () {
}
/**
* 获取股票类型列表
* Get stock type list
* @returns {*}
*/
export function getMarketTypes () {
@@ -233,7 +233,7 @@ export function getMarketTypes () {
}
/**
* 搜索金融产品
* Search financial instruments
* @param parameter { market: string, keyword: string, limit?: number }
* @returns {*}
*/
@@ -246,7 +246,7 @@ export function searchSymbols (parameter) {
}
/**
* 获取热门标的
* Get popular instruments
* @param parameter { market: string, limit?: number }
* @returns {*}
*/
+8 -8
View File
@@ -1,7 +1,7 @@
import request from '@/utils/request'
/**
* 获取配置项定义
* Get setting schema definitions
*/
export function getSettingsSchema () {
return request({
@@ -11,7 +11,7 @@ export function getSettingsSchema () {
}
/**
* 获取当前配置值
* Get current setting values
*/
export function getSettingsValues () {
return request({
@@ -21,8 +21,8 @@ export function getSettingsValues () {
}
/**
* 保存配置
* @param {Object} data - 配置数据
* Save settings
* @param {Object} data - settings data
*/
export function saveSettings (data) {
return request({
@@ -33,9 +33,9 @@ export function saveSettings (data) {
}
/**
* 测试API连接
* @param {string} service - 服务名称 (openrouter, finnhub, etc.)
* @param {Object} params - 额外参数
* Test API connection
* @param {string} service - service name (openrouter, finnhub, etc.)
* @param {Object} params - extra parameters
*/
export function testConnection (service, params = {}) {
return request({
@@ -46,7 +46,7 @@ export function testConnection (service, params = {}) {
}
/**
* 查询 OpenRouter 账户余额
* Query OpenRouter account balance
*/
export function getOpenRouterBalance () {
return request({
+26
View File
@@ -17,6 +17,8 @@ const api = {
batchStopStrategies: '/api/strategies/batch-stop',
batchDeleteStrategies: '/api/strategies/batch-delete',
testConnection: '/api/strategies/test-connection',
verifyCode: '/api/strategies/verify-code',
aiGenerate: '/api/strategies/ai-generate',
trades: '/api/strategies/trades',
positions: '/api/strategies/positions',
equityCurve: '/api/strategies/equityCurve',
@@ -235,6 +237,30 @@ export function testExchangeConnection (exchangeConfig) {
})
}
/**
* Verify script strategy code.
* @param {string} code - Python strategy source.
*/
export function verifyStrategyCode (code) {
return request({
url: api.verifyCode,
method: 'post',
data: { code }
})
}
/**
* Ask the backend LLM worker to generate script strategy code.
* @param {string} prompt - Natural language strategy description.
*/
export function aiGenerateStrategyCode (prompt) {
return request({
url: api.aiGenerate,
method: 'post',
data: { prompt }
})
}
/**
* Get strategy trade records.
* @param {number} id - Strategy ID.
+2 -2
View File
@@ -5,7 +5,7 @@
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Ant-Design-Pro-3.0" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="账户密码登录-校验" transform="translate(-79.000000, -82.000000)">
<g id="account-password-login-validation" transform="translate(-79.000000, -82.000000)">
<g id="Group-21" transform="translate(77.000000, 73.000000)">
<g id="Group-18" opacity="0.8" transform="translate(74.901416, 569.699158) rotate(-7.000000) translate(-74.901416, -569.699158) translate(4.901416, 525.199158)">
<ellipse id="Oval-11" fill="#CFDAE6" opacity="0.25" cx="63.5748792" cy="32.468367" rx="21.7830479" ry="21.766008"></ellipse>
@@ -66,4 +66,4 @@
</g>
</g>
</g>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

@@ -7,7 +7,7 @@
</div>
<div class="extra">
<a-avatar :src="avatar" size="small" />
<a :href="href">{{ owner }}</a> 发布在 <a :href="href">{{ href }}</a>
<a :href="href">{{ owner }}</a> published on <a :href="href">{{ href }}</a>
<em>{{ updateAt | moment }}</em>
</div>
</div>
@@ -6,7 +6,7 @@ import Item from './Item.jsx'
import { filterEmpty } from '@/components/_util/util'
/**
* size: `number` `large``small``default` 默认值: default
* size: `number`, `large`, `small`, `default`; default: default
* maxLength: number
* excessItemsStyle: CSSProperties
*/
+1 -1
View File
@@ -35,7 +35,7 @@ export default {
min: 2
}, {
dataKey: 'y',
title: '时间',
title: 'Time',
min: 1,
max: 22
}]
@@ -33,7 +33,7 @@ const scale = [{
min: 2
}, {
dataKey: 'y',
title: '时间',
title: 'Time',
min: 1,
max: 22
}]
@@ -34,7 +34,7 @@ const scale = [{
min: 2
}, {
dataKey: 'y',
title: '时间',
title: 'Time',
min: 1,
max: 30
}]
+1 -1
View File
@@ -41,7 +41,7 @@ const scale = [
max: 80
}, {
dataKey: 'user',
alias: '类型'
alias: 'Type'
}
]
@@ -24,13 +24,13 @@ const tooltip = [
]
const scale = [{
dataKey: 'x',
title: '日期(天)',
alias: '日期(天)',
title: 'Date (day)',
alias: 'Date (day)',
min: 2
}, {
dataKey: 'y',
title: '流量(Gb)',
alias: '流量(Gb)',
title: 'Traffic (Gb)',
alias: 'Traffic (Gb)',
min: 1
}]
@@ -30,7 +30,7 @@ export default {
type: String,
default: 'ant-editor-quill'
},
//
// Field used for form validation
// eslint-disable-next-line
value: {
type: String
@@ -66,7 +66,7 @@ export default {
<style lang="less" scoped>
@import url('../index.less');
/* 覆盖 quill 默认边框圆角为 ant 默认圆角,用于统一 ant 组件风格 */
/* Override Quill border radius with the Ant default radius to keep styling consistent */
.ant-editor-quill {
line-height: initial;
:deep(.ql-toolbar.ql-snow) {
@@ -33,11 +33,11 @@ export default {
}
},
computed: {
//
// Check whether the dark theme is active
isDarkTheme () {
return this.navTheme === 'dark' || this.navTheme === 'realdark'
},
// Footer
// Footer container class names
footerCls () {
return {
'footer-wrapper': true,
@@ -49,9 +49,9 @@ export default {
</script>
<style lang="less">
/* 不使用 scoped,直接覆盖全局样式 */
/* Do not use scoped so the global styles can be overridden directly */
.footer-wrapper {
/* 调整内间距 */
/* Adjust inner spacing */
.ant-pro-global-footer {
padding: 4px 16px 8px;
margin: 0;
@@ -66,14 +66,14 @@ export default {
}
}
/* 浅色主题(默认)- 确保文字是深色的 */
/* Light theme (default) - keep text dark */
.footer-wrapper {
.ant-pro-global-footer {
background: transparent !important;
color: rgba(0, 0, 0, 0.65) !important;
}
/* 链接颜色 */
/* Link color */
.ant-pro-global-footer-links {
a {
color: rgba(0, 0, 0, 0.65) !important;
@@ -84,20 +84,20 @@ export default {
}
}
/* 版权文字颜色 */
/* Copyright text color */
.ant-pro-global-footer-copyright {
color: rgba(0, 0, 0, 0.65) !important;
}
}
/* 浅色主题(默认) */
/* Light theme (default) */
.legal-content {
white-space: pre-wrap;
line-height: 1.7;
color: rgba(0, 0, 0, 0.85);
}
/* 暗黑主题 - 通过组件外层类名控制 */
/* Dark theme - controlled by the outer component class */
.footer-wrapper-dark {
.ant-pro-global-footer {
background: transparent !important;
@@ -105,7 +105,7 @@ export default {
border-top: none !important;
}
/* 链接颜色 */
/* Link color */
.ant-pro-global-footer-links {
a {
color: rgba(255, 255, 255, 0.65) !important;
@@ -116,12 +116,12 @@ export default {
}
}
/* 版权文字颜色 */
/* Copyright text color */
.ant-pro-global-footer-copyright {
color: rgba(255, 255, 255, 0.65) !important;
}
/* 弹窗内容颜色 */
/* Modal content color */
.legal-content {
color: rgba(255, 255, 255, 0.85) !important;
}
@@ -71,7 +71,7 @@ export default {
}
}
/* 暗黑主题 - 下拉菜单样式 */
/* Dark theme - dropdown menu styles */
body.dark .ant-dropdown-menu,
body.realdark .ant-dropdown-menu,
.ant-layout.dark .ant-dropdown-menu,
@@ -49,7 +49,7 @@ export default {
},
methods: {
handleSettingClick () {
//
// Emit the event that shows the settings drawer
this.$root.$emit('show-setting-drawer')
}
},
@@ -74,7 +74,7 @@ export default {
<style lang="less">
@import '~ant-design-vue/es/style/themes/default.less';
/* 浅色主题(默认) */
/* Light theme (default) */
.ant-pro-global-header-index-right {
display: flex;
align-items: center;
@@ -98,7 +98,7 @@ export default {
}
}
/* 手机端适配 */
/* Mobile adaptation */
@media (max-width: 768px) {
.ant-pro-global-header-index-right {
.ant-pro-global-header-index-action {
@@ -112,15 +112,15 @@ export default {
}
}
/* 暗黑主题 - 强制覆盖 */
/* 只要 body layout dark/realdark 类,就应用这些样式 */
/* Dark theme - force override */
/* Apply these styles whenever body or layout has the dark/realdark class */
body.dark,
body.realdark,
.ant-layout.dark,
.ant-layout.realdark,
.ant-pro-layout.dark,
.ant-pro-layout.realdark {
/* 覆盖 Header 右侧容器内所有文本颜色 */
/* Override all text colors in the right side of the header */
.ant-pro-global-header-index-right {
color: rgba(255, 255, 255, 0.85) !important;
@@ -128,7 +128,7 @@ body.realdark,
color: rgba(255, 255, 255, 0.85) !important;
}
/* 操作按钮 */
/* Action buttons */
.ant-pro-global-header-index-action {
color: rgba(255, 255, 255, 0.85) !important;
@@ -138,14 +138,14 @@ body.realdark,
}
}
/* 头像 */
/* Avatar */
.ant-pro-account-avatar {
.antd-pro-global-header-index-avatar {
background: rgba(255, 255, 255, 0.25) !important;
}
}
/* 下拉菜单触发器(包含图标) */
/* Dropdown trigger (including icon) */
.ant-pro-drop-down,
.ant-dropdown-trigger {
color: rgba(255, 255, 255, 0.85) !important;
@@ -1,36 +1,36 @@
/**
* 增加新的图标时请遵循以下数据结构
* When adding new icons, follow the data structure below
* Adding new icon please follow the data structure below
*/
export default [
{
key: 'directional',
title: '方向性图标',
title: 'Directional icons',
icons: ['step-backward', 'step-forward', 'fast-backward', 'fast-forward', 'shrink', 'arrows-alt', 'down', 'up', 'left', 'right', 'caret-up', 'caret-down', 'caret-left', 'caret-right', 'up-circle', 'down-circle', 'left-circle', 'right-circle', 'double-right', 'double-left', 'vertical-left', 'vertical-right', 'forward', 'backward', 'rollback', 'enter', 'retweet', 'swap', 'swap-left', 'swap-right', 'arrow-up', 'arrow-down', 'arrow-left', 'arrow-right', 'play-circle', 'up-square', 'down-square', 'left-square', 'right-square', 'login', 'logout', 'menu-fold', 'menu-unfold', 'border-bottom', 'border-horizontal', 'border-inner', 'border-left', 'border-right', 'border-top', 'border-verticle', 'pic-center', 'pic-left', 'pic-right', 'radius-bottomleft', 'radius-bottomright', 'radius-upleft', 'fullscreen', 'fullscreen-exit']
},
{
key: 'suggested',
title: '提示建议性图标',
title: 'Advisory icons',
icons: ['question', 'question-circle', 'plus', 'plus-circle', 'pause', 'pause-circle', 'minus', 'minus-circle', 'plus-square', 'minus-square', 'info', 'info-circle', 'exclamation', 'exclamation-circle', 'close', 'close-circle', 'close-square', 'check', 'check-circle', 'check-square', 'clock-circle', 'warning', 'issues-close', 'stop']
},
{
key: 'editor',
title: '编辑类图标',
title: 'Editing icons',
icons: ['edit', 'form', 'copy', 'scissor', 'delete', 'snippets', 'diff', 'highlight', 'align-center', 'align-left', 'align-right', 'bg-colors', 'bold', 'italic', 'underline', 'strikethrough', 'redo', 'undo', 'zoom-in', 'zoom-out', 'font-colors', 'font-size', 'line-height', 'colum-height', 'dash', 'small-dash', 'sort-ascending', 'sort-descending', 'drag', 'ordered-list', 'radius-setting']
},
{
key: 'data',
title: '数据类图标',
title: 'Data icons',
icons: ['area-chart', 'pie-chart', 'bar-chart', 'dot-chart', 'line-chart', 'radar-chart', 'heat-map', 'fall', 'rise', 'stock', 'box-plot', 'fund', 'sliders']
},
{
key: 'brand_logo',
title: '网站通用图标',
title: 'Common website icons',
icons: ['lock', 'unlock', 'bars', 'book', 'calendar', 'cloud', 'cloud-download', 'code', 'copy', 'credit-card', 'delete', 'desktop', 'download', 'ellipsis', 'file', 'file-text', 'file-unknown', 'file-pdf', 'file-word', 'file-excel', 'file-jpg', 'file-ppt', 'file-markdown', 'file-add', 'folder', 'folder-open', 'folder-add', 'hdd', 'frown', 'meh', 'smile', 'inbox', 'laptop', 'appstore', 'link', 'mail', 'mobile', 'notification', 'paper-clip', 'picture', 'poweroff', 'reload', 'search', 'setting', 'share-alt', 'shopping-cart', 'tablet', 'tag', 'tags', 'to-top', 'upload', 'user', 'video-camera', 'home', 'loading', 'loading-3-quarters', 'cloud-upload', 'star', 'heart', 'environment', 'eye', 'camera', 'save', 'team', 'solution', 'phone', 'filter', 'exception', 'export', 'customer-service', 'qrcode', 'scan', 'like', 'dislike', 'message', 'pay-circle', 'calculator', 'pushpin', 'bulb', 'select', 'switcher', 'rocket', 'bell', 'disconnect', 'database', 'compass', 'barcode', 'hourglass', 'key', 'flag', 'layout', 'printer', 'sound', 'usb', 'skin', 'tool', 'sync', 'wifi', 'car', 'schedule', 'user-add', 'user-delete', 'usergroup-add', 'usergroup-delete', 'man', 'woman', 'shop', 'gift', 'idcard', 'medicine-box', 'red-envelope', 'coffee', 'copyright', 'trademark', 'safety', 'wallet', 'bank', 'trophy', 'contacts', 'global', 'shake', 'api', 'fork', 'dashboard', 'table', 'profile', 'alert', 'audit', 'branches', 'build', 'border', 'crown', 'experiment', 'fire', 'money-collect', 'property-safety', 'read', 'reconciliation', 'rest', 'security-scan', 'insurance', 'interation', 'safety-certificate', 'project', 'thunderbolt', 'block', 'cluster', 'deployment-unit', 'dollar', 'euro', 'pound', 'file-done', 'file-exclamation', 'file-protect', 'file-search', 'file-sync', 'gateway', 'gold', 'robot', 'shopping']
},
{
key: 'application',
title: '品牌和标识',
title: 'Brands and logos',
icons: ['android', 'apple', 'windows', 'ie', 'chrome', 'github', 'aliwangwang', 'dingding', 'weibo-square', 'weibo-circle', 'taobao-circle', 'html5', 'weibo', 'twitter', 'wechat', 'youtube', 'alipay-circle', 'taobao', 'skype', 'qq', 'medium-workmark', 'gitlab', 'medium', 'linkedin', 'google-plus', 'dropbox', 'facebook', 'codepen', 'code-sandbox', 'amazon', 'google', 'codepen-circle', 'alipay', 'ant-design', 'aliyun', 'zhihu', 'slack', 'slack-square', 'behance', 'behance-square', 'dribbble', 'dribbble-square', 'instagram', 'yuque', 'alibaba', 'yahoo']
}
]
@@ -51,7 +51,7 @@
</span>
</a-popover>
<!-- 通知详情弹窗 -->
<!-- Notification details modal -->
<a-modal
v-model="detailVisible"
:title="detailNotice ? detailNotice.title : ''"
@@ -74,18 +74,18 @@
<a-divider />
<!-- 消息内容 - 支持 HTML 报告或 Markdown 格式 -->
<!-- Message content - supports HTML reports or Markdown -->
<div class="notice-detail-content" :class="{ 'html-report': isHtmlReport }">
<div v-html="formatMessageHtml(detailNotice.message)" class="message-body"></div>
</div>
<!-- 如果有额外的 payload 信息 HTML 报告时显示 -->
<!-- Show extra payload information when available for non-HTML reports -->
<template v-if="!isHtmlReport && detailNotice.payload && Object.keys(detailNotice.payload).length > 0">
<a-divider />
<div class="notice-detail-extra">
<div class="extra-title">{{ $t('notice.detailInfo') }}</div>
<!-- AI分析结果 -->
<!-- AI analysis result -->
<template v-if="detailNotice.signal_type === 'ai_monitor'">
<div v-if="detailNotice.payload.final_decision" class="extra-item decision">
<span class="label">{{ $t('notice.aiDecision') }}:</span>
@@ -102,7 +102,7 @@
</div>
</template>
<!-- 价格提醒 -->
<!-- Price alert -->
<template v-if="detailNotice.signal_type === 'price_alert'">
<div v-if="detailNotice.payload.symbol" class="extra-item">
<span class="label">{{ $t('notice.symbol') }}:</span>
@@ -118,7 +118,7 @@
</div>
</template>
<!-- 交易信号 -->
<!-- Trading signal -->
<template v-if="detailNotice.signal_type === 'signal' || detailNotice.signal_type === 'trade'">
<div v-if="detailNotice.payload.symbol" class="extra-item">
<span class="label">{{ $t('notice.symbol') }}:</span>
@@ -138,7 +138,7 @@
</div>
</template>
<!-- 操作按钮 -->
<!-- Action buttons -->
<div class="notice-detail-actions">
<a-button v-if="detailNotice.payload && detailNotice.payload.monitor_id" type="primary" @click="goToPortfolio">
<a-icon type="fund" />
@@ -190,7 +190,7 @@ export default {
methods: {
startPolling () {
this.stopPolling()
// 30
// Poll every 30 seconds
this.pollingTimer = setInterval(() => {
this.fetchNotifications(true)
}, 30000)
@@ -208,7 +208,7 @@ export default {
try {
const res = await getStrategyNotifications({ limit: 50 })
if (res.code === 1 && res.data?.items) {
// payload_json
// Parse payload_json if it is a string
this.notifications = res.data.items.map(item => {
let payload = item.payload_json
if (typeof payload === 'string') {
@@ -290,26 +290,26 @@ export default {
},
formatTime (timestamp) {
if (!timestamp) return ''
// ISO
// Support multiple time formats: ISO strings, second timestamps, and millisecond timestamps
let date
if (typeof timestamp === 'number') {
//
// For numeric values, determine whether the timestamp is in seconds or milliseconds
date = new Date(timestamp < 1e12 ? timestamp * 1000 : timestamp)
} else if (typeof timestamp === 'string') {
//
// String values
if (/^\d+$/.test(timestamp)) {
//
// Pure numeric string (timestamp)
const ts = parseInt(timestamp, 10)
date = new Date(ts < 1e12 ? ts * 1000 : ts)
} else {
// ISO
// ISO date string or other format
date = new Date(timestamp)
}
} else {
return ''
}
//
// Check whether the date is valid
if (isNaN(date.getTime())) {
return ''
}
@@ -334,7 +334,7 @@ export default {
},
formatFullTime (timestamp) {
if (!timestamp) return ''
// ISO
// Support multiple time formats: ISO strings, second timestamps, and millisecond timestamps
let date
if (typeof timestamp === 'number') {
date = new Date(timestamp < 1e12 ? timestamp * 1000 : timestamp)
@@ -357,36 +357,36 @@ export default {
formatMessageHtml (message) {
if (!message) return ''
// HTML AI Monitor
// Check whether the content is already HTML (AI Monitor report)
if (message.includes('<div class="qd-report">') || message.includes('<style>')) {
// HTML
// Already HTML, return it directly
return message
}
// Markdown
// Simple Markdown conversion
const html = message
// HTML
// Escape HTML
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
//
// Headings
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
//
// Bold
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
//
// Italic
.replace(/\*(.+?)\*/g, '<em>$1</em>')
//
// List items
.replace(/^- (.+)$/gm, '<li>$1</li>')
//
// Line breaks
.replace(/\n/g, '<br>')
return html
},
handleNoticeClick (item) {
//
// Mark as read
this.markAsRead(item.id)
//
// Open the details modal
this.detailNotice = item
this.detailVisible = true
this.visible = false
@@ -400,7 +400,7 @@ export default {
if (item) {
item.is_read = true
}
// API
// Call the backend API to mark it as read
try {
await request({
url: '/api/strategies/notifications/read',
@@ -408,7 +408,7 @@ export default {
data: { id }
})
} catch (e) {
//
// Ignore errors because the frontend is already updated
}
},
async markAllRead () {
@@ -419,7 +419,7 @@ export default {
method: 'post'
})
} catch (e) {
//
// Ignore errors
}
},
async clearNotifications () {
@@ -430,7 +430,7 @@ export default {
method: 'delete'
})
} catch (e) {
//
// Ignore errors
}
this.visible = false
}
@@ -466,7 +466,7 @@ export default {
}
}
/* 手机端适配 */
/* Mobile adaptation */
@media (max-width: 768px) {
.header-notice {
padding: 0 8px;
@@ -579,7 +579,7 @@ export default {
}
}
/* 详情弹窗内容 */
/* Details modal content */
.notice-detail {
.notice-detail-meta {
display: flex;
@@ -634,7 +634,7 @@ export default {
}
}
// HTML
// HTML report styles
&.html-report {
.message-body {
max-height: 70vh;
@@ -700,7 +700,7 @@ export default {
}
}
/* 详情弹窗样式 */
/* Details modal styles */
.notice-detail-modal {
.ant-modal-header {
border-bottom: 1px solid #f0f0f0;
@@ -710,7 +710,7 @@ export default {
padding: 16px 24px;
}
// HTML
// HTML report mode
&.html-report-modal {
.ant-modal-body {
padding: 0;
@@ -718,7 +718,7 @@ export default {
}
}
/* 暗黑主题支持 */
/* Dark theme support */
body.dark,
body.realdark,
.ant-layout.dark,
@@ -781,7 +781,7 @@ body.realdark,
}
}
/* 详情弹窗暗黑主题 */
/* Dark theme for the details modal */
.notice-detail-modal {
.ant-modal-content {
background: #1f1f1f;
@@ -43,7 +43,7 @@ export default {
},
render () {
// return <ins class="adsbygoogle" style="display:inline-block;width:728px;height:90px" data-ad-client="ca-pub-4801326429087140" data-ad-slot="6929057621" />
return <div class="business-pro-ad"><a href="https://store.antdv.com/pro/" target="_blank">(推荐) 企业级商用版 Admin Pro 现已发售采用 Vue3 + TS 欢迎购买</a></div>;
return <div class="business-pro-ad"><a href="https://store.antdv.com/pro/" target="_blank">(Recommended) The enterprise commercial edition of Admin Pro is now available, built with Vue3 + TS.</a></div>;
}
}
</script>
@@ -284,7 +284,7 @@ export default {
try {
const res = await analyzePolymarket({
input: this.inputText.trim(),
language: this.$i18n.locale || 'zh-CN'
language: this.$i18n.locale || 'en-US'
})
if (res && res.code === 1) {
this.analysisResult = res.data
@@ -6,10 +6,10 @@ import i18nMixin from '@/store/i18n-mixin'
const locales = ['en-US', 'ja-JP', 'ko-KR', 'vi-VN', 'th-TH', 'ar-SA', 'fr-FR', 'de-DE', 'zh-TW', 'zh-CN']
const languageLabels = {
'zh-CN': '简体中文',
'zh-TW': '繁體中文',
'zh-CN': 'Simplified Chinese',
'zh-TW': 'Traditional Chinese',
'en-US': 'English',
'ja-JP': '日本語',
'ja-JP': 'Japanese',
'ko-KR': '한국어',
'vi-VN': 'Tiếng Việt',
'th-TH': 'ไทย',
@@ -39,7 +39,7 @@
}
}
/* 手机端适配 */
/* Mobile adaptation */
@media (max-width: 768px) {
.@{header-drop-down-prefix-cls} {
padding: 0 8px;
@@ -153,7 +153,7 @@
@click="doCopy"
icon="copy"
block
>拷贝设置</a-button>
>Copy Settings</a-button>
</div> -->
</div>
</a-drawer>
@@ -218,7 +218,7 @@ export default {
},
mounted () {
//
// Silently update the theme color during initialization without showing a message
updateTheme(this.currentPrimaryColor, true)
if (this.currentColorWeak !== config.colorWeak) {
updateColorWeak(this.currentColorWeak)
@@ -266,7 +266,7 @@ export default {
},
handleLayout (mode) {
this.$emit('change', { type: 'layout', value: mode })
//
// The top menu layout cannot fix the left sidebar, so disable it automatically
if (mode === 'topmenu') {
this.$emit('change', { type: 'fixSiderbar', value: false })
}
@@ -298,7 +298,7 @@ export default {
</script>
<style lang="less" scoped>
/* 隐藏所有可能的悬浮按钮 */
/* Hide any possible floating buttons */
:deep(.ant-drawer-handle),
:deep(.setting-drawer-index-handle) {
display: none !important;
@@ -3,11 +3,11 @@ import generate from '@ant-design/colors/lib/generate'
export default {
getAntdSerials (color) {
// 淡化(即less的tint
// Lighten (the LESS tint equivalent)
const lightens = new Array(9).fill().map((t, i) => {
return client.varyColor.lighten(color, i / 10)
})
// colorPalette变换得到颜色值
// Generate color values from colorPalette
const colorPalettes = generate(color)
const rgb = client.varyColor.toNum3(color.replace('#', '')).join(',')
return lightens.concat(colorPalettes).concat(rgb)
+26 -25
View File
@@ -13,7 +13,7 @@ export default {
localDataSource: [],
localPagination: Object.assign({}, this.pagination),
// 存储表格onchange时的filters sorter对象
// Store filters and sorter objects from the table onchange event
filters: {},
sorter: {}
}
@@ -123,8 +123,8 @@ export default {
},
methods: {
/**
* 表格重新加载方法
* 如果参数为 true, 则强制刷新到第一页
* Table reload method
* If the argument is true, force refresh from the first page
* @param Boolean bool
*/
refresh (bool = false) {
@@ -134,10 +134,10 @@ export default {
this.loadData()
},
/**
* 加载数据方法
* @param {Object} pagination 分页选项器
* @param {Object} filters 过滤条件
* @param {Object} sorter 排序条件
* Data loading method
* @param {Object} pagination pagination options
* @param {Object} filters filters
* @param {Object} sorter sorter
*/
loadData (pagination, filters = this.filters, sorter = this.sorter) {
this.filters = filters
@@ -160,26 +160,27 @@ export default {
}
)
const result = this.data(parameter)
// 对接自己的通用数据接口需要修改下方代码中的 r.pageNo, r.totalCount, r.data
// Adjust r.pageNo, r.totalCount, and r.data below to match your generic data API
// eslint-disable-next-line
if ((typeof result === 'object' || typeof result === 'function') && typeof result.then === 'function') {
result.then(r => {
this.localPagination = this.showPagination && Object.assign({}, this.localPagination, {
current: r.pageNo, // 返回结果中的当前分页数
total: r.totalCount, // 返回结果中的总记录数
current: r.pageNo, // current page number from the response
total: r.totalCount, // total record count from the response
showSizeChanger: this.showSizeChanger,
pageSize: (pagination && pagination.pageSize) ||
this.localPagination.pageSize
}) || false
// 为防止删除数据后导致页面当前页面数据长度为 0 ,自动翻页到上一页
// Automatically move to the previous page if deleting data leaves the current page empty
if (r.data.length === 0 && this.showPagination && this.localPagination.current > 1) {
this.localPagination.current--
this.loadData()
return
}
// 这里用于判断接口是否有返回 r.totalCount 且 this.showPagination = true 且 pageNo pageSize 存在 且 totalCount 小于等于 pageNo * pageSize 的大小
// 当情况满足时,表示数据不满足分页大小,关闭 table 分页功能
// This checks whether the API returned r.totalCount, pagination is enabled, pageNo and pageSize exist,
// and totalCount is less than or equal to pageNo * pageSize.
// When this condition is met, disable table pagination because the data no longer requires it.
try {
if ((['auto', true].includes(this.showPagination) && r.totalCount <= (r.pageNo * this.localPagination.pageSize))) {
this.localPagination.hideOnSinglePage = true
@@ -187,7 +188,7 @@ export default {
} catch (e) {
this.localPagination = false
}
this.localDataSource = r.data // 返回结果中的数组数据
this.localDataSource = r.data // array data from the response
})
.finally(() => {
this.localLoading = false
@@ -207,7 +208,7 @@ export default {
return totalList
},
/**
* 用于更新已选中的列表数据 total 统计
* Used to update total statistics for selected rows
* @param selectedRowKeys
* @param selectedRows
*/
@@ -226,7 +227,7 @@ export default {
})
},
/**
* 清空 table 已选中项
* Clear selected table rows
*/
clearSelected () {
if (this.rowSelection) {
@@ -235,7 +236,7 @@ export default {
}
},
/**
* 处理交给 table 使用者去处理 clear 事件时内部选中统计同时调用
* When clear is handled externally by the table consumer, also update the internal selection statistics
* @param callback
* @returns {*}
*/
@@ -245,29 +246,29 @@ export default {
<a style="margin-left: 24px" onClick={() => {
callback()
this.clearSelected()
}}>清空</a>
}}>Clear</a>
)
},
renderAlert () {
// 绘制统计列数据
// Render summary column data
const needTotalItems = this.needTotalList.map((item) => {
return (<span style="margin-right: 12px">
{item.title}总计 <a style="font-weight: 600">{!item.customRender ? item.total : item.customRender(item.total)}</a>
{item.title} Total <a style="font-weight: 600">{!item.customRender ? item.total : item.customRender(item.total)}</a>
</span>)
})
// 绘制 清空 按钮
// Render the clear button
const clearItem = (typeof this.alert.clear === 'boolean' && this.alert.clear) ? (
this.renderClear(this.clearSelected)
) : (this.alert !== null && typeof this.alert.clear === 'function') ? (
this.renderClear(this.alert.clear)
) : null
// 绘制 alert 组件
// Render the alert component
return (
<a-alert showIcon={true} style="margin-bottom: 16px">
<template slot="message">
<span style="margin-right: 12px">已选择: <a style="font-weight: 600">{this.selectedRows.length}</a></span>
<span style="margin-right: 12px">Selected: <a style="font-weight: 600">{this.selectedRows.length}</a></span>
{needTotalItems}
{clearItem}
</template>
@@ -289,7 +290,7 @@ export default {
}
if (k === 'rowSelection') {
if (showAlert && this.rowSelection) {
// 如果需要使用alert,则重新绑定 rowSelection 事件
// Rebind the rowSelection event when alert support is enabled
props[k] = {
...this.rowSelection,
selectedRows: this.selectedRows,
@@ -301,7 +302,7 @@ export default {
}
return props[k]
} else if (!this.rowSelection) {
// 如果没打算开启 rowSelection 则清空默认的选择项
// Clear the default selection if rowSelection is not enabled
props[k] = null
return props[k]
}
+3 -3
View File
@@ -70,9 +70,9 @@ export default {
<a-dropdown>
<a class="btn"><a-icon type="ellipsis" /></a>
<a-menu slot="overlay">
<a-menu-item key="1">新增</a-menu-item>
<a-menu-item key="2">合并</a-menu-item>
<a-menu-item key="3">移除</a-menu-item>
<a-menu-item key="1">Add</a-menu-item>
<a-menu-item key="2">Merge</a-menu-item>
<a-menu-item key="3">Remove</a-menu-item>
</a-menu>
</a-dropdown>
</template>
+3 -3
View File
@@ -3,7 +3,7 @@
*/
/**
* 清理空值对象
* Clean empty values from objects
* @param children
* @returns {*[]}
*/
@@ -12,7 +12,7 @@ export function filterEmpty (children = []) {
}
/**
* 获取字符串长度英文字符 长度1中文字符长度2
* Get string length. English characters count as 1, Chinese characters count as 2
* @param {*} str
*/
export const getStrFullLength = (str = '') =>
@@ -25,7 +25,7 @@ export const getStrFullLength = (str = '') =>
}, 0)
/**
* 截取字符串根据 maxLength 截取后返回
* Truncate a string based on maxLength
* @param {*} str
* @param {*} maxLength
*/
+11 -11
View File
@@ -1,14 +1,14 @@
/**
* 项目默认配置项
* primaryColor - 默认主题色, 如果修改颜色不生效请清理 localStorage
* navTheme - sidebar theme ['dark', 'light'] 两种主题
* colorWeak - 色盲模式
* layout - 整体布局方式 ['sidemenu', 'topmenu'] 两种布局
* fixedHeader - 固定 Header : boolean
* fixSiderbar - 固定左侧菜单栏 boolean
* contentWidth - 内容区布局 流式 | 固定
* Default project settings
* primaryColor - default theme color. If changes do not apply, clear localStorage
* navTheme - sidebar theme ['dark', 'light'] with two theme options
* colorWeak - color weak mode
* layout - overall layout mode ['sidemenu', 'topmenu'] with two layout options
* fixedHeader - fixed Header : boolean
* fixSiderbar - fixed left sidebar : boolean
* contentWidth - content layout: fluid | fixed
*
* storageOptions: {} - Vue-ls 插件配置项 (localStorage/sessionStorage)
* storageOptions: {} - Vue-ls plugin options (localStorage/sessionStorage)
*
*/
@@ -19,8 +19,8 @@ export default {
primaryColor: '#13C2C2', // '#F5222D', // primary color of ant design
layout: 'sidemenu', // nav menu position: `sidemenu` or `topmenu`
contentWidth: 'Fluid', // layout of content: `Fluid` or `Fixed`, only works when layout is topmenu
fixedHeader: true, // sticky header - 固定顶部导航栏
fixSiderbar: true, // sticky siderbar - 固定左侧边栏
fixedHeader: true, // sticky header - pin the top navigation
fixSiderbar: true, // sticky siderbar - pin the left sidebar
colorWeak: false,
menu: {
locale: true
+2 -2
View File
@@ -13,7 +13,7 @@ import { printANSI } from '@/utils/screenLog'
import defaultSettings from '@/config/defaultSettings'
export default function Initializer () {
printANSI() // 请自行移除该行. please remove this line
printANSI() // remove this line if it is not needed
store.commit(TOGGLE_LAYOUT, storage.get(TOGGLE_LAYOUT, defaultSettings.layout))
store.commit(TOGGLE_FIXED_HEADER, storage.get(TOGGLE_FIXED_HEADER, defaultSettings.fixedHeader))
@@ -24,7 +24,7 @@ export default function Initializer () {
store.commit(TOGGLE_WEAK, storage.get(TOGGLE_WEAK, defaultSettings.colorWeak))
store.commit(TOGGLE_COLOR, storage.get(TOGGLE_COLOR, defaultSettings.primaryColor))
store.commit(TOGGLE_MULTI_TAB, storage.get(TOGGLE_MULTI_TAB, defaultSettings.multiTab))
// 处理 token 可能是字符串或对象的情况
// Handle tokens stored as either strings or objects
let token = storage.get(ACCESS_TOKEN)
if (token && typeof token !== 'string') {
token = token.token || token.value || (typeof token === 'object' ? null : token)
+8 -8
View File
@@ -2,15 +2,15 @@ import Vue from 'vue'
import store from '@/store'
/**
* Action 权限指令
* 指令用法
* - 在需要控制 action 级别权限的组件上使用 v-action:[method] , 如下
* <i-button v-action:add >添加用户</a-button>
* <a-button v-action:delete>删除用户</a-button>
* <a v-action:edit @click="edit(record)">修改</a>
* Action permission directive
* Directive usage:
* - Use v-action:[method] on components that need action-level permission control, for example:
* <i-button v-action:add >Add user</a-button>
* <a-button v-action:delete>Delete user</a-button>
* <a v-action:edit @click="edit(record)">Edit</a>
*
* - 当前用户没有权限时组件上使用了该指令则会被隐藏
* - 当后台权限跟 pro 提供的模式不同时只需要针对这里的权限过滤进行修改即可
* - If the current user lacks permission, components using this directive will be hidden
* - If backend permissions differ from the mode provided by pro, only adjust the permission filtering here
*
* @see https://github.com/vueComponent/ant-design-vue-pro/pull/53
*/
+2 -2
View File
@@ -3,8 +3,8 @@
* All icons are loaded here for easy management
* @see https://vue.ant.design/components/icon/#Custom-Font-Icon
*
* 自定义图标加载表
* 所有图标均从这里加载方便管理
* Custom icon registry
* All icons are loaded here for easier management
*/
import bxAnaalyse from '@/assets/icons/bx-analyse.svg?inline' // path to your '*.svg?inline' file.
+1 -1
View File
@@ -109,7 +109,7 @@ Vue.use(Space)
Vue.use(Empty)
Vue.use(Rate)
Vue.use(AutoComplete)
// Textarea 是 Input 组件的一部分,通过 Vue.use(Input) 已自动注册
// Textarea is part of the Input component and is auto-registered via Vue.use(Input)
Vue.prototype.$confirm = Modal.confirm
Vue.prototype.$message = message
@@ -1,13 +1,13 @@
export const PERMISSION_ENUM = {
'add': { key: 'add', label: '新增' },
'delete': { key: 'delete', label: '删除' },
'edit': { key: 'edit', label: '修改' },
'query': { key: 'query', label: '查询' },
'get': { key: 'get', label: '详情' },
'enable': { key: 'enable', label: '启用' },
'disable': { key: 'disable', label: '禁用' },
'import': { key: 'import', label: '导入' },
'export': { key: 'export', label: '导出' }
'add': { key: 'add', label: 'Add' },
'delete': { key: 'delete', label: 'Delete' },
'edit': { key: 'edit', label: 'Edit' },
'query': { key: 'query', label: 'Query' },
'get': { key: 'get', label: 'Details' },
'enable': { key: 'enable', label: 'Enable' },
'disable': { key: 'disable', label: 'Disable' },
'import': { key: 'import', label: 'Import' },
'export': { key: 'export', label: 'Export' }
}
/**
+5 -5
View File
@@ -31,11 +31,11 @@ ol {
list-style: none;
}
// 数据列表 样式
// Data list styles
.table-alert {
margin-bottom: 16px;
}
// 数据列表 操作
// Data list actions
.table-operator {
margin-bottom: 18px;
@@ -43,7 +43,7 @@ ol {
margin-right: 8px;
}
}
// 数据列表 搜索条件
// Data list search filters
.table-page-search-wrapper {
.ant-form-inline {
.ant-form-item {
@@ -105,7 +105,7 @@ ol {
visibility: hidden !important;
}
// 手机端强制覆盖 logo padding
// Force logo padding override on mobile
@media (max-width: 768px) {
.ant-pro-global-header-logo {
padding: 0 !important;
@@ -114,4 +114,4 @@ ol {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
}
}
+43 -44
View File
@@ -1,8 +1,8 @@
@import '~ant-design-vue/es/style/themes/default.less';
/* ========== 完全隐藏 ant-layout-footer ========== */
/* ========== Fully hide ant-layout-footer ========== */
:global {
/* 全局隐藏所有 ant-layout-footer */
/* Hide all ant-layout-footer elements globally */
.ant-layout-footer,
footer.ant-layout-footer,
.ant-pro-layout .ant-layout-footer,
@@ -18,10 +18,10 @@
}
}
/* ========== ant-layout-header 主题适配 ========== */
/* 全局样式:根据 body layout 的主题类自动适配 */
/* ========== ant-layout-header theme adaptation ========== */
/* Global styles: adapt automatically based on the theme class on body or layout */
:global {
/* 暗黑主题下的 ant-layout-header */
/* ant-layout-header under the dark theme */
body.dark .ant-layout-header,
body.realdark .ant-layout-header,
.ant-layout.dark .ant-layout-header,
@@ -33,12 +33,12 @@
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08) !important;
color: rgba(255, 255, 255, 0.85) !important;
/* Header 内的所有文字和图标 */
/* All text and icons inside the header */
* {
color: rgba(255, 255, 255, 0.85) !important;
}
/* Header 内的图标按钮 */
/* Icon buttons inside the header */
.anticon {
color: rgba(255, 255, 255, 0.85) !important;
@@ -49,7 +49,7 @@
}
}
/* Header 内的链接 */
/* Links inside the header */
a {
color: rgba(255, 255, 255, 0.85) !important;
@@ -58,7 +58,7 @@
}
}
/* Header 内的工具提示容器 */
/* Tooltip container inside the header */
.ant-tooltip-open {
.anticon {
color: rgba(255, 255, 255, 0.85) !important;
@@ -67,7 +67,7 @@
}
/* 浅色主题下的 ant-layout-header(确保覆盖默认样式) */
/* ant-layout-header under the light theme, ensuring the default styles are overridden */
body.light .ant-layout-header,
.ant-layout.light .ant-layout-header,
.ant-pro-layout.light .ant-layout-header {
@@ -77,8 +77,8 @@
color: rgba(0, 0, 0, 0.85) !important;
}
/* ========== ant-layout-content 主题适配(中间页面内容区域) ========== */
/* 暗黑主题下的内容区域 */
/* ========== ant-layout-content theme adaptation for the middle content area ========== */
/* Content area under the dark theme */
body.dark .ant-layout-content,
body.realdark .ant-layout-content,
.ant-layout.dark .ant-layout-content,
@@ -91,7 +91,7 @@
/* 浅色主题下的内容区域 */
/* Content area under the light theme */
body.light .ant-layout-content,
.ant-layout.light .ant-layout-content,
.ant-pro-layout.light .ant-layout-content {
@@ -103,8 +103,8 @@
/* ========== GlobalHeader 核心样式(基于 .ant-pro-global-header ========== */
/* 暗黑主题下的 .ant-pro-global-header - 这是控制顶部标题栏的关键样式 */
/* ========== GlobalHeader core styles based on .ant-pro-global-header ========== */
/* .ant-pro-global-header under the dark theme - key styles for the top title bar */
body.dark .ant-pro-global-header,
body.realdark .ant-pro-global-header,
.ant-layout.dark .ant-pro-global-header,
@@ -115,18 +115,18 @@
body.realdark .ant-layout-header .ant-pro-global-header,
.ant-pro-layout.dark .ant-layout-header .ant-pro-global-header,
.ant-pro-layout.realdark .ant-layout-header .ant-pro-global-header {
/* 核心:背景色和边框 */
/* Core: background color and border */
background: #001529 !important;
border-bottom: 1px solid #1f1f1f !important;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08) !important;
color: rgba(255, 255, 255, 0.85) !important;
/* 强制所有子元素文字颜色 */
/* Force text color for all child elements */
* {
color: rgba(255, 255, 255, 0.85) !important;
}
/* Logo 区域 */
/* Logo area */
.ant-pro-global-header-logo {
background: transparent !important;
color: rgba(255, 255, 255, 0.85) !important;
@@ -145,7 +145,7 @@
}
}
/* Header 内容区域(中间部分,包含刷新按钮等) */
/* Header content area, including the middle section and refresh button */
.ant-pro-global-header-content {
background: transparent !important;
color: rgba(255, 255, 255, 0.85) !important;
@@ -154,7 +154,7 @@
color: rgba(255, 255, 255, 0.85) !important;
}
/* 刷新按钮等图标 */
/* Refresh button and related icons */
.anticon {
color: rgba(255, 255, 255, 0.85) !important;
@@ -165,13 +165,13 @@
}
}
/* 工具提示内的图标 */
/* Icons inside tooltips */
.ant-tooltip-open .anticon {
color: rgba(255, 255, 255, 0.85) !important;
}
}
/* Header 右侧内容区域(包含头像、语言选择、设置按钮) */
/* Header right-side content area, including avatar, language selector, and settings button */
.ant-pro-global-header-index-right {
background: transparent !important;
color: rgba(255, 255, 255, 0.85) !important;
@@ -180,7 +180,7 @@
color: rgba(255, 255, 255, 0.85) !important;
}
/* 操作按钮(头像、语言、设置) */
/* Action buttons: avatar, language, settings */
.ant-pro-global-header-index-action {
color: rgba(255, 255, 255, 0.85) !important;
@@ -194,7 +194,7 @@
}
}
/* 头像区域 */
/* Avatar area */
.ant-pro-account-avatar {
color: rgba(255, 255, 255, 0.85) !important;
@@ -207,7 +207,7 @@
}
}
/* 下拉菜单触发器 */
/* Dropdown trigger */
.ant-pro-drop-down,
.ant-dropdown-trigger {
color: rgba(255, 255, 255, 0.85) !important;
@@ -223,7 +223,7 @@
}
}
/* 链接样式 */
/* Link styles */
a {
color: rgba(255, 255, 255, 0.85) !important;
@@ -232,7 +232,7 @@
}
}
/* 所有图标 */
/* All icons */
.anticon {
color: rgba(255, 255, 255, 0.85) !important;
@@ -241,7 +241,7 @@
}
}
/* 浅色主题下的 .ant-pro-global-header */
/* .ant-pro-global-header under the light theme */
body.light .ant-pro-global-header,
.ant-layout.light .ant-pro-global-header,
.ant-pro-layout.light .ant-pro-global-header,
@@ -253,17 +253,17 @@
}
}
/* ========== 全局布局主题适配 - 强化覆盖 ========== */
/* ========== Global layout theme adaptation - stronger overrides ========== */
:global(.ant-pro-layout) {
/* 暗黑主题下的完整布局 */
/* Full layout under the dark theme */
&.dark,
&.realdark {
/* 0. 基础 Layout 背景 - 确保整体背景变暗 */
/* 0. Base layout background - make sure the whole background turns dark */
:global(.ant-layout) {
background-color: #001529 !important;
}
/* 1. 顶部 Header */
/* 1. Top header */
:global(.ant-layout-header) {
background: #001529 !important;
border-bottom: 1px solid #1f1f1f !important;
@@ -280,7 +280,7 @@
}
}
/* 2. 中间内容区域 */
/* 2. Middle content area */
:global(.ant-layout-content) {
background: #141414 !important;
color: rgba(255, 255, 255, 0.85) !important;
@@ -293,21 +293,21 @@
}
/* 浅色主题下的完整布局 */
/* Full layout under the light theme */
&.light {
/* 0. 基础 Layout 背景 */
/* 0. Base layout background */
:global(.ant-layout) {
background-color: #f0f2f5 !important;
}
/* 1. 顶部 Header */
/* 1. Top header */
:global(.ant-layout-header) {
background: #fff !important;
border-bottom: 1px solid #e8e8e8 !important;
color: rgba(0, 0, 0, 0.85) !important;
}
/* 2. 中间内容区域 */
/* 2. Middle content area */
:global(.ant-layout-content) {
background: #f0f2f5 !important;
color: rgba(0, 0, 0, 0.85) !important;
@@ -333,7 +333,7 @@
}
}
/* 暗黑主题下的头像下拉菜单 */
/* Avatar dropdown under the dark theme */
.ant-pro-account-avatar {
color: rgba(255, 255, 255, 0.85);
@@ -346,7 +346,7 @@
}
}
/* 语言选择器和设置按钮 */
/* Language selector and settings button */
.ant-pro-drop-down {
color: rgba(255, 255, 255, 0.85);
@@ -357,7 +357,7 @@
}
}
/* 浅色主题下的头像样式 */
/* Avatar styles under the light theme */
.ant-pro-account-avatar {
.antd-pro-global-header-index-avatar {
margin: ~'calc((@{layout-header-height} - 24px) / 2)' 0;
@@ -379,7 +379,7 @@
}
}
/* 暗黑主题下的下拉菜单样式 */
/* Dropdown menu styles under the dark theme */
:global {
.ant-pro-layout.dark,
.ant-pro-layout.realdark {
@@ -404,10 +404,10 @@
}
}
/* 手机端适配 */
/* Mobile adaptation */
@media (max-width: 768px) {
:global {
/* 使用更具体的选择器强制覆盖 */
/* Use more specific selectors for a forced override */
.ant-layout-header .ant-pro-global-header .ant-pro-global-header-logo,
.ant-pro-layout .ant-layout-header .ant-pro-global-header .ant-pro-global-header-logo,
body .ant-layout-header .ant-pro-global-header .ant-pro-global-header-logo,
@@ -420,4 +420,3 @@
}
}
}
+109 -109
View File
@@ -17,8 +17,8 @@
<h1>{{ title }}</h1>
</div>
</template>
<!-- 1.0.0+ 版本 pro-layout 提供 API,
增加 Header 左侧内容区自定义
<!-- pro-layout 1.0.0+ provides an API
for customizing the left side of the header content area
-->
<template #headerContentRender>
<div>
@@ -28,7 +28,7 @@
</div>
</template>
<!-- 用户协议弹窗 -->
<!-- User agreement modal -->
<a-modal :visible="showLegalModal" :footer="null" :title="$t('menu.footer.userAgreement')" @cancel="showLegalModal = false" :width="800">
<div style="max-height: 60vh; overflow: auto; white-space: pre-wrap; line-height: 1.8; padding: 16px;">
{{ menuFooterConfig.legal.user_agreement || $t('user.login.legal.content') }}
@@ -38,7 +38,7 @@
</div>
</a-modal>
<!-- 隐私条例弹窗 -->
<!-- Privacy policy modal -->
<a-modal :visible="showPrivacyModal" :footer="null" :title="$t('menu.footer.privacyPolicy')" @cancel="showPrivacyModal = false" :width="800">
<div style="max-height: 60vh; overflow: auto; white-space: pre-wrap; line-height: 1.8; padding: 16px;">
{{ menuFooterConfig.legal.privacy_policy || $t('user.login.privacy.content') }}
@@ -63,10 +63,10 @@
<router-view :key="refreshKey" />
</pro-layout>
<!-- 菜单底部 footer - 直接写不依赖插槽 -->
<!-- Menu footer at the bottom, rendered directly without slots -->
<div class="custom-menu-footer" :class="{ 'collapsed': collapsed, 'drawer-open': isMobile && isDrawerOpen, 'drawer-animating': isMobile && isDrawerAnimating }">
<div v-if="!collapsed" class="menu-footer-content">
<!-- 联系我们 -->
<!-- Contact us -->
<div class="footer-section">
<div class="section-title">{{ $t('menu.footer.contactUs') }}</div>
<div class="section-links">
@@ -76,7 +76,7 @@
</div>
</div>
<!-- 获取支持 -->
<!-- Get support -->
<div class="footer-section">
<div class="section-title">{{ $t('menu.footer.getSupport') }}</div>
<div class="section-links">
@@ -86,7 +86,7 @@
</div>
</div>
<!-- 社交账户 -->
<!-- Social accounts -->
<div class="footer-section" v-if="menuFooterConfig.social_accounts && menuFooterConfig.social_accounts.length > 0">
<div class="section-title">{{ $t('menu.footer.socialAccounts') }}</div>
<div class="social-icons">
@@ -104,7 +104,7 @@
</div>
</div>
<!-- 用户协议和隐私条例 -->
<!-- User agreement and privacy policy -->
<div class="footer-section">
<div class="section-links">
<a @click="showLegalModal = true">{{ $t('menu.footer.userAgreement') }}</a>
@@ -113,11 +113,11 @@
</div>
</div>
<!-- 版权信息 -->
<!-- Copyright information -->
<div class="footer-section copyright">
{{ menuFooterConfig.copyright }}
</div>
<!-- 版本号 -->
<!-- Version number -->
<div class="footer-section version">
V2.2.1
</div>
@@ -167,17 +167,17 @@ export default {
isDev: process.env.NODE_ENV === 'development' || process.env.VUE_APP_PREVIEW === 'true',
// base - menus moved to computed property
//
// Sidebar collapsed state
collapsed: false,
title: defaultSettings.title,
settings: {
//
// Layout type
layout: defaultSettings.layout, // 'sidemenu', 'topmenu'
// CONTENT_WIDTH_TYPE
contentWidth: defaultSettings.layout === 'sidemenu' ? CONTENT_WIDTH_TYPE.Fluid : defaultSettings.contentWidth,
// 'dark' | 'light'
// Theme: 'dark' | 'light'
theme: defaultSettings.navTheme,
//
// Primary color
primaryColor: defaultSettings.primaryColor,
fixedHeader: defaultSettings.fixedHeader,
fixSiderbar: defaultSettings.fixSiderbar,
@@ -186,19 +186,19 @@ export default {
hideHintAlert: false,
hideCopyButton: false
},
//
// Media query
query: {},
//
// Whether mobile mode is active
isMobile: false,
//
// Legal disclaimer modal visibility state
showLegalModal: false,
showPrivacyModal: false,
// key
// Key used to refresh the content area
refreshKey: 0,
// drawer
// Whether the drawer is open on mobile
isDrawerOpen: false,
// drawer
// Whether the drawer is animating on mobile
isDrawerAnimating: false,
// Static footer config (local OSS build)
menuFooterConfig: {
@@ -221,16 +221,16 @@ export default {
},
copyright: '© 2025-2026 QuantDinger. All rights reserved.'
},
// ""
// Whether this is the first theme-color initialization, used to decide whether to show the "switching theme" notice
isInitialThemeColorLoad: true
}
},
computed: {
...mapState({
//
// Dynamic main routes
mainMenu: state => state.permission.addRouters
}),
// - addRouters
// Responsive menu - update dynamically based on addRouters
menus () {
const routes = this.mainMenu.find(item => item.path === '/')
return (routes && routes.children) || []
@@ -238,17 +238,17 @@ export default {
},
created () {
// menus is now a computed property - no need to set here
// store localStorage
// Sync theme settings from the store, restoring them from localStorage
this.settings.theme = this.$store.state.app.theme
this.settings.primaryColor = this.$store.state.app.color || defaultSettings.primaryColor
//
// Handle sidebar collapse state
this.$watch('collapsed', () => {
this.$store.commit(SIDEBAR_TYPE, this.collapsed)
})
this.$watch('isMobile', () => {
this.$store.commit(TOGGLE_MOBILE_TYPE, this.isMobile)
})
// store settings body
// Watch theme changes in the store and sync them to settings and body classes
this.$watch('$store.state.app.theme', (val) => {
this.settings.theme = val
if (val === 'dark' || val === 'realdark') {
@@ -259,22 +259,22 @@ export default {
document.body.classList.add('light')
}
}, { immediate: true })
// store settings
// Watch theme-color changes in the store and sync them to settings
this.$watch('$store.state.app.color', (val) => {
if (val) {
this.settings.primaryColor = val
//
// Apply the theme color
if (process.env.NODE_ENV !== 'production' || process.env.VUE_APP_PREVIEW === 'true') {
// ""
// Update silently on first load without showing the "switching theme" notice
updateTheme(val, this.isInitialThemeColorLoad)
// false
// Clear the first-call flag after the first update
if (this.isInitialThemeColorLoad) {
this.isInitialThemeColorLoad = false
}
}
}
}, { immediate: true })
// settings.theme body
// Watch settings.theme and sync body classes as an extra safeguard
this.$watch('settings.theme', (val) => {
if (val === 'dark' || val === 'realdark') {
document.body.classList.add('dark')
@@ -298,10 +298,10 @@ export default {
// first update color
// TIPS: THEME COLOR HANDLER!! PLEASE CHECK THAT!!
// created() watch
// ""
// Theme-color updates are already handled by the watch in created(); do not call them again here
// to avoid showing the "switching theme" notice twice
//
// Listen for the event that opens the settings drawer
this.$root.$on('show-setting-drawer', () => {
if (this.$refs.settingDrawer) {
this.$refs.settingDrawer.showDrawer()
@@ -310,46 +310,46 @@ export default {
// Footer config is static for local OSS build
// DOM
// Update the footer position after a delay so the DOM has finished rendering
this.$nextTick(() => {
setTimeout(() => {
this.updateMenuFooterPosition()
}, 200)
})
//
// Listen for window size changes
window.addEventListener('resize', this.updateMenuFooterPosition)
// footer
// Desktop: periodically check and update the footer position to keep it visible
if (!this.isMobile) {
this._desktopFooterInterval = setInterval(() => {
this.updateMenuFooterPosition()
}, 1000)
}
// drawer /
// 使 MutationObserver drawer /
// Listen for opening and closing of the mobile menu drawer
// Use MutationObserver to watch drawer visibility changes
const observer = new MutationObserver(() => {
if (this.isMobile) {
// drawer
// Check whether the drawer is open
const drawer = document.querySelector('.ant-drawer.ant-drawer-open')
const wasOpen = this.isDrawerOpen
const isOpen = !!drawer
this.isDrawerOpen = isOpen
// footer
// Update the footer position when the state changes
if (wasOpen !== this.isDrawerOpen) {
if (this.isDrawerOpen) {
// drawer footer
// The drawer just opened; mark it as animating and delay footer visibility
this.isDrawerAnimating = true
// drawer Ant Design Drawer 0.3s
// Wait for the drawer animation to complete; Ant Design Drawer uses 0.3s
setTimeout(() => {
this.isDrawerAnimating = false
this.updateMenuFooterPosition()
}, 300)
} else {
// drawer footer
// The drawer closed; hide the footer immediately
this.isDrawerAnimating = false
this.updateMenuFooterPosition()
}
@@ -357,7 +357,7 @@ export default {
}
})
// body drawer / class
// Observe body changes to detect drawer insertion, removal, and class changes
observer.observe(document.body, {
childList: true,
subtree: true,
@@ -365,17 +365,17 @@ export default {
attributeFilter: ['class']
})
// observer 便
// Store the observer for cleanup
this._menuFooterObserver = observer
// footer
// Periodic check as a fallback to keep the footer position correct
this._menuFooterInterval = setInterval(() => {
if (this.isMobile) {
const drawer = document.querySelector('.ant-drawer.ant-drawer-open')
const currentState = !!drawer
if (this.isDrawerOpen !== currentState) {
this.isDrawerOpen = currentState
// drawer
// Mark the drawer as animating if it just opened
if (currentState) {
this.isDrawerAnimating = true
setTimeout(() => {
@@ -387,28 +387,28 @@ export default {
this.updateMenuFooterPosition()
}
} else if (currentState && !this.isDrawerAnimating) {
// drawer drawer
// If the drawer is open and no longer animating, update the position to avoid drift
this.updateMenuFooterPosition()
}
}
}, 200)
},
beforeDestroy () {
//
// Remove event listeners
this.$root.$off('show-setting-drawer')
window.removeEventListener('resize', this.updateMenuFooterPosition)
// MutationObserver
// Clean up the MutationObserver
if (this._menuFooterObserver) {
this._menuFooterObserver.disconnect()
}
//
// Clean up timers
if (this._menuFooterInterval) {
clearInterval(this._menuFooterInterval)
}
//
// Clean up desktop timers
if (this._desktopFooterInterval) {
clearInterval(this._desktopFooterInterval)
}
@@ -417,12 +417,12 @@ export default {
i18nRender,
updateMenuFooterPosition () {
this.$nextTick(() => {
// 使 requestAnimationFrame CSS
// Use requestAnimationFrame to update before the next repaint and avoid interrupting CSS transitions
requestAnimationFrame(() => {
const menuFooter = this.$el?.querySelector('.custom-menu-footer')
if (!menuFooter) return
//
// Mobile: find the drawer menu container
if (this.isMobile) {
const drawer = document.querySelector('.ant-drawer.ant-drawer-open')
this.isDrawerOpen = !!drawer
@@ -431,21 +431,21 @@ export default {
// const drawerRect = drawer.getBoundingClientRect()
menuFooter.style.position = 'fixed'
// menuFooter.style.left = `${drawerRect.left}px`
// CSS .collapsed
// Width is controlled by the CSS .collapsed class, so do not set it here
menuFooter.style.bottom = '0px'
menuFooter.style.zIndex = '1001'
menuFooter.style.display = 'block'
menuFooter.style.opacity = '1'
// footerdrawer bodypadding
// Compute the footer height dynamically and apply drawer body padding
const footerHeight = menuFooter.offsetHeight || 280
const drawerBody = drawer.querySelector('.ant-drawer-body')
if (drawerBody) {
// CSSCSS使
// Set a CSS variable for styles to consume
drawer.style.setProperty('--footer-height', `${footerHeight}px`)
// padding-bottom
// Set padding-bottom directly so menu content is not obscured
drawerBody.style.paddingBottom = `${footerHeight + 10}px`
// drawer body
// Ensure the drawer body can scroll
drawerBody.style.overflowY = 'auto'
drawerBody.style.overflowX = 'hidden'
drawerBody.style.webkitOverflowScrolling = 'touch'
@@ -453,14 +453,14 @@ export default {
return
} else if (drawer && this.isDrawerAnimating) {
// drawer footer
// While the drawer is animating, the footer should be hidden or transparent
menuFooter.style.opacity = '0'
menuFooter.style.display = 'block'
return
} else {
menuFooter.style.display = 'none'
menuFooter.style.opacity = '0'
// drawer bodypadding
// Clear the drawer body padding
const drawer = document.querySelector('.ant-drawer')
if (drawer) {
const drawerBody = drawer.querySelector('.ant-drawer-body')
@@ -474,20 +474,20 @@ export default {
}
}
//
// Desktop: find the regular menu container
const sider = this.$el?.querySelector('.ant-pro-sider') || document.querySelector('.ant-pro-sider')
if (sider) {
const siderRect = sider.getBoundingClientRect()
const footerHeight = menuFooter.offsetHeight || 220
menuFooter.style.position = 'fixed'
menuFooter.style.left = `${siderRect.left}px`
// CSS .collapsed
// Width is controlled by the CSS .collapsed class, so do not set it here
menuFooter.style.bottom = '0px'
menuFooter.style.zIndex = '100'
menuFooter.style.display = 'block'
// footer CSS 便使
// Write the footer height to a CSS variable for use in styles
sider.style.setProperty('--menu-footer-height', `${footerHeight}px`)
// footer
// Reserve footer height in the sidebar body and allow scrolling
const siderChildren = sider.querySelector('.ant-layout-sider-children')
if (siderChildren) {
siderChildren.style.paddingBottom = `${footerHeight + 12}px`
@@ -495,7 +495,7 @@ export default {
siderChildren.style.overflowX = 'hidden'
siderChildren.style.webkitOverflowScrolling = 'touch'
}
// footer
// Further restrict menu area height so the footer does not cover it
const menuScroll = sider.querySelector('.ant-pro-sider-menu') ||
sider.querySelector('.ant-menu-root') ||
sider.querySelector('.ant-menu')
@@ -507,10 +507,10 @@ export default {
menuScroll.style.webkitOverflowScrolling = 'touch'
}
} else {
// 使
// Fall back to the default position if the menu cannot be found
menuFooter.style.position = 'fixed'
menuFooter.style.left = '0px'
// CSS .collapsed
// Width is controlled by the CSS .collapsed class
menuFooter.style.bottom = '0px'
menuFooter.style.zIndex = '100'
menuFooter.style.display = 'block'
@@ -519,7 +519,7 @@ export default {
})
},
handleRefresh () {
// key router-view
// Refresh only the content area by changing the key and forcing router-view to re-render
this.refreshKey += 1
},
handleMediaQuery (val) {
@@ -543,18 +543,18 @@ export default {
},
handleCollapse (val) {
this.collapsed = val
//
// CSS transition
// Update the footer position when the menu collapse state changes
// CSS transitions handle smooth width and position changes automatically
this.$nextTick(() => {
this.updateMenuFooterPosition()
})
},
handleMobileMenuToggle () {
// /
// Listen for mobile menu open and close changes
this.$nextTick(() => {
setTimeout(() => {
this.updateMenuFooterPosition()
}, 300) // drawer
}, 300) // Wait for the drawer animation to complete
})
},
handleSettingChange ({ type, value }) {
@@ -605,7 +605,7 @@ export default {
.ant-pro-sider-menu-sider.light .ant-menu-light {
height: 60vh!important;
}
/* 完全隐藏所有 footer */
/* Fully hide all footers */
.basic-layout-wrapper {
.ant-layout-footer {
display: none !important;
@@ -616,61 +616,61 @@ export default {
}
}
/* 菜单底部 footer 样式 - 直接定位到菜单底部 */
/* Menu footer styles - positioned directly at the bottom of the menu */
.basic-layout-wrapper {
position: relative;
/* 自定义菜单底部 - 通过 CSS 选择器定位到菜单区域 */
/* Custom menu footer - positioned in the menu area via CSS selectors */
.custom-menu-footer {
position: fixed;
bottom: 0;
left: 0;
z-index: 100;
width: 256px; /* 统一固定宽度 256px */
background: #001529; /* 默认暗色背景 */
width: 256px; /* Unified fixed width: 256px */
background: #001529; /* Default dark background */
border-top: 1px solid rgba(255, 255, 255, 0.1);
/* 与菜单栏抽屉动画同步:使用相同的过渡时间和缓动函数 */
/* Ant Design Vue Drawer 使用 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86) */
/* Sync with the menu drawer animation by using the same timing and easing */
/* Ant Design Vue Drawer uses 0.3s and cubic-bezier(0.78, 0.14, 0.15, 0.86) */
transition: left 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86),
width 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86),
max-width 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86),
opacity 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
max-width: 256px;
display: block; /* 默认显示 */
display: block; /* Visible by default */
opacity: 1;
&.collapsed {
width: 80px; /* 折叠时菜单宽度 */
width: 80px; /* Menu width when collapsed */
max-width: 80px;
}
/* 手机端:当菜单在 drawer 中时,需要更高的 z-index */
/* Mobile: use a higher z-index when the menu is inside the drawer */
@media (max-width: 768px) {
z-index: 1001; /* drawer z-index 通常是 1000 */
z-index: 1001; /* The drawer z-index is usually 1000 */
/* 当 drawer 未打开时,隐藏 footer */
/* Hide the footer while the drawer is closed */
&:not(.drawer-open) {
display: none !important;
opacity: 0;
}
/* 当 drawer 正在动画中时,footer 应该透明,等待动画完成 */
/* While the drawer is animating, keep the footer transparent until the animation completes */
&.drawer-animating {
opacity: 0;
transition: opacity 0.1s ease-out;
}
/* 当 drawer 完全打开且不在动画中时,footer 才显示 */
/* Show the footer only after the drawer is fully open and no longer animating */
&.drawer-open:not(.drawer-animating) {
opacity: 1;
transition: left 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86),
width 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86),
max-width 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86),
opacity 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86) 0.1s; /* 延迟 0.1s 显示,确保 drawer 先出现 */
opacity 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86) 0.1s; /* Delay visibility by 0.1s so the drawer appears first */
}
}
/* 浅色主题 */
/* Light theme */
body.light &,
.ant-pro-layout.light & {
background: #fff;
@@ -697,7 +697,7 @@ export default {
}
}
/* 暗黑主题 */
/* Dark theme */
body.dark &,
body.realdark &,
.ant-pro-layout.dark &,
@@ -734,7 +734,7 @@ export default {
overflow-y: auto;
overflow-x: hidden;
/* 隐藏滚动条但保持滚动功能 */
/* Hide scrollbars while preserving scrolling */
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
&::-webkit-scrollbar {
@@ -878,7 +878,7 @@ export default {
}
}
/* 监听菜单折叠状态,动态调整宽度 */
/* Watch menu collapse state and adjust width dynamically */
::v-deep .ant-pro-layout {
&.ant-pro-sider-collapsed ~ .custom-menu-footer,
.ant-pro-sider-collapsed ~ .custom-menu-footer {
@@ -887,7 +887,7 @@ export default {
}
}
/* 侧栏菜单滚动 & 为自定义 footer 预留空间 */
/* Sidebar menu scrolling and reserved space for the custom footer */
.basic-layout-wrapper {
.ant-layout-sider-children {
padding-bottom: calc(var(--menu-footer-height, 220px) + 12px);
@@ -922,7 +922,7 @@ export default {
}
}
/* 强制侧栏和菜单区域可滚动,避免被 footer 遮挡 */
/* Force the sidebar and menu area to scroll so the footer does not cover them */
.ant-pro-sider {
height: 100vh;
display: flex;
@@ -948,10 +948,10 @@ export default {
}
}
/* 暗黑主题样式 */
/* Dark theme styles */
.basic-layout-wrapper.dark,
.basic-layout-wrapper.realdark {
/* Header 适配 */
/* Header adaptation */
.ant-pro-global-header {
background: #001529 !important;
color: rgba(255, 255, 255, 0.85) !important;
@@ -971,22 +971,22 @@ export default {
}
}
/* Content 适配 */
/* Content adaptation */
.ant-pro-basicLayout-content {
background-color: #141414 !important;
}
/* 确保 Layout 本身也是深色 */
/* Ensure the layout itself is also dark */
.ant-layout {
background-color: #141414 !important;
}
}
/* 手机端:修复footer遮挡菜单的问题 */
/* Mobile: fix the footer covering the menu */
@media (max-width: 768px) {
/* drawer body可以滚动,并添加底部padding避免被footer遮挡 */
/* Let the drawer body scroll and add bottom padding so the footer does not cover it */
.ant-drawer.ant-drawer-open {
/* 确保drawer容器可以正常显示 */
/* Ensure the drawer container can render correctly */
.ant-drawer-content-wrapper {
overflow: visible;
}
@@ -1006,15 +1006,15 @@ export default {
}
.ant-drawer-body {
/* 让菜单内容可以滚动 */
/* Let the menu content scroll */
overflow-y: auto !important;
overflow-x: hidden !important;
/* 添加底部padding,高度等于footer的高度(由JS动态设置) */
/* 默认值280px作为fallback */
/* Add bottom padding equal to the footer height, set dynamically by JS */
/* Use 280px as the fallback default */
padding-bottom: var(--footer-height, 280px) !important;
/* 确保滚动流畅 */
/* Keep scrolling smooth */
-webkit-overflow-scrolling: touch;
/* 隐藏滚动条但保持滚动功能 */
/* Hide scrollbars while preserving scrolling */
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
&::-webkit-scrollbar {
@@ -1030,7 +1030,7 @@ export default {
background: rgba(255, 255, 255, 0.3);
}
}
/* 确保菜单内容区域有足够的高度 */
/* Ensure the menu content area has enough height */
min-height: 0;
flex: 1;
}
+3 -3
View File
@@ -20,9 +20,9 @@ export default {
const notKeep = (
<router-view />
)
// multiTab multiTab
//
// return meta.keepAlive ? inKeep : notKeep
// This adds a multiTab check. When multiTab is enabled,
// all components should be cached, otherwise switching pages may reset them to their initial state.
// If that is not needed, change it to return meta.keepAlive ? inKeep : notKeep
if (!getters.multiTab && !meta.keepAlive) {
return notKeep
}
+1 -1
View File
@@ -43,7 +43,7 @@ function setI18nLanguage (lang) {
export function loadLanguageAsync (lang = defaultLang) {
return new Promise(resolve => {
// 缓存语言设置
// Cache the language setting
storage.set('lang', lang)
if (i18n.locale !== lang) {
if (!loadedLanguages.includes(lang)) {
+99 -21
View File
@@ -80,9 +80,9 @@ const locale = {
'aiAssetAnalysis.opportunities.signal.oversold': 'Oversold',
'aiAssetAnalysis.opportunities.signal.bullish_momentum': 'Bullish',
'aiAssetAnalysis.opportunities.signal.bearish_momentum': 'Bearish',
'aiAssetAnalysis.opportunities.market.Crypto': '🪙 Crypto',
'aiAssetAnalysis.opportunities.market.USStock': '📈 US Stock',
'aiAssetAnalysis.opportunities.market.Forex': '💱 Forex',
'aiAssetAnalysis.opportunities.market.Crypto': '🪙 Crypto',
'aiAssetAnalysis.opportunities.market.USStock': '📈 US Stock',
'aiAssetAnalysis.opportunities.market.Forex': '💱 Forex',
'aiAssetAnalysis.opportunities.reason.crypto.overbought': '24h +{change}%, 7d +{change7d}%, overbought risk',
'aiAssetAnalysis.opportunities.reason.crypto.bullish_momentum': '24h +{change}%, strong bullish momentum',
'aiAssetAnalysis.opportunities.reason.crypto.oversold': '24h -{change}%, possible oversold bounce',
@@ -168,7 +168,7 @@ const locale = {
'app.setting.multitab': 'Multi-Tab Mode',
'app.setting.copy': 'Copy Setting',
'app.setting.loading': 'Loading theme',
'app.setting.copyinfo': 'copy successplease replace defaultSettings in src/config/defaultSettings.js',
'app.setting.copyinfo': 'copy success,please replace defaultSettings in src/config/defaultSettings.js',
'app.setting.copy.success': 'Copy Success',
'app.setting.copy.fail': 'Copy Failed',
'app.setting.theme.switching': 'Switching theme...',
@@ -242,7 +242,7 @@ const locale = {
'user.register.confirm-password.placeholder': 'Confirm password',
'user.register.get-verification-code': 'Get code',
'user.register.sign-in': 'Already have an account?',
'user.register-result.msg': 'Accountregistered at {email}',
'user.register-result.msg': 'Account:registered at {email}',
'user.register-result.activation-email': 'The activation email has been sent to your email address and is valid for 24 hours. Please log in to the email in time and click on the link in the email to activate the account.',
'user.register-result.back-home': 'Back to home',
'user.register-result.view-mailbox': 'View mailbox',
@@ -510,7 +510,7 @@ const locale = {
'wallet.rechargeAddressHint': 'Please ensure you use {chain} chain to recharge {coin} to this address',
'wallet.qdtPrice.loading': 'Loading...',
'wallet.rechargeTip.new': 'Please select coin and chain, then scan the QR code below to recharge',
'wallet.exchangeRate': '1 QDT {rate} USDT',
'wallet.exchangeRate': '1 QDT ≈ {rate} USDT',
'wallet.withdrawableAmount': 'Withdrawable Amount',
'wallet.totalBalance': 'Total Balance',
'wallet.insufficientWithdrawable': 'Insufficient withdrawable amount, current withdrawable: {amount} QDT',
@@ -847,7 +847,7 @@ const locale = {
'dashboard.indicator.editor.aiPromptPlaceholder': 'Describe your signal logic (buy/sell only) and plots. Position sizing, risk, scaling, fees/slippage are configured in backtest.',
'dashboard.indicator.boundary.message': 'Note: The indicator script only computes plots + buy/sell signals. Position sizing, risk, scaling, fees/slippage belong to execution config.',
'dashboard.indicator.boundary.indicatorRule': "Keep the script to buy/sell only (and set df['buy']/df['sell']). Do NOT implement position management, TP/SL, scaling in the script.",
'dashboard.indicator.boundary.backtestRule': 'Rule: If a bar has a main signal (buy/sell open/close/reverse), scaling in/out is skipped on the same bar.',
'dashboard.indicator.boundary.backtestRule': 'Rule: If a bar has a main signal (buy/sell → open/close/reverse), scaling in/out is skipped on the same bar.',
'dashboard.indicator.editor.aiGenerateBtn': 'AI Generate Code',
'dashboard.indicator.editor.aiPromptRequired': 'Please enter your idea',
'dashboard.indicator.editor.aiGenerateSuccess': 'Code generated successfully',
@@ -922,11 +922,11 @@ const locale = {
'dashboard.indicator.guide.section6.exampleText': 'Buy',
'dashboard.indicator.guide.section7.title': '7. Complete Code Examples',
'dashboard.indicator.guide.section7.exampleA': 'Example A: Dual EMA Strategy (Supports Backtesting)',
'dashboard.indicator.guide.section7.exampleACode': "# 1. Calculate indicators<br/>df['ema_fast'] = df['close'].ewm(span=5, adjust=False).mean()<br/>df['ema_slow'] = df['close'].ewm(span=13, adjust=False).mean()<br/><br/># 2. Calculate crossover signals<br/>cross_up = (df['ema_fast'] > df['ema_slow']) & (df['ema_fast'].shift(1) <= df['ema_slow'].shift(1))<br/>cross_down = (df['ema_fast'] < df['ema_slow']) & (df['ema_fast'].shift(1) >= df['ema_slow'].shift(1))<br/><br/># 3. ImportantSet NEW backtest signal columns (bool)<br/>df['open_long'] = cross_up<br/>df['close_long'] = cross_down<br/>df['open_short'] = cross_down<br/>df['close_short'] = cross_up<br/><br/># 4. Generate signal data for chart display<br/>buy_signals = [df['low'].iloc[i] * 0.995 if cross_up.iloc[i] else None for i in range(len(df))]<br/>sell_signals = [df['high'].iloc[i] * 1.005 if cross_down.iloc[i] else None for i in range(len(df))]<br/><br/># 5. Build output<br/>output = {<br/> \"name\": \"Dual EMA Strategy\",<br/> \"plots\": [<br/> {\"name\": \"EMA 5\", \"data\": df['ema_fast'].tolist(), \"color\": \"#FF6B6B\", \"overlay\": True},<br/> {\"name\": \"EMA 13\", \"data\": df['ema_slow'].tolist(), \"color\": \"#4ECDC4\", \"overlay\": True}<br/> ],<br/> \"signals\": [<br/> {\"type\": \"buy\", \"text\": \"BUY\", \"data\": buy_signals, \"color\": \"#00E676\"},<br/> {\"type\": \"sell\", \"text\": \"SELL\", \"data\": sell_signals, \"color\": \"#FF5252\"}<br/> ]<br/>}",
'dashboard.indicator.guide.section7.exampleACode': "# 1. Calculate indicators<br/>df['ema_fast'] = df['close'].ewm(span=5, adjust=False).mean()<br/>df['ema_slow'] = df['close'].ewm(span=13, adjust=False).mean()<br/><br/># 2. Calculate crossover signals<br/>cross_up = (df['ema_fast'] > df['ema_slow']) & (df['ema_fast'].shift(1) <= df['ema_slow'].shift(1))<br/>cross_down = (df['ema_fast'] < df['ema_slow']) & (df['ema_fast'].shift(1) >= df['ema_slow'].shift(1))<br/><br/># 3. 【Important】Set NEW backtest signal columns (bool)<br/>df['open_long'] = cross_up<br/>df['close_long'] = cross_down<br/>df['open_short'] = cross_down<br/>df['close_short'] = cross_up<br/><br/># 4. Generate signal data for chart display<br/>buy_signals = [df['low'].iloc[i] * 0.995 if cross_up.iloc[i] else None for i in range(len(df))]<br/>sell_signals = [df['high'].iloc[i] * 1.005 if cross_down.iloc[i] else None for i in range(len(df))]<br/><br/># 5. Build output<br/>output = {<br/> \"name\": \"Dual EMA Strategy\",<br/> \"plots\": [<br/> {\"name\": \"EMA 5\", \"data\": df['ema_fast'].tolist(), \"color\": \"#FF6B6B\", \"overlay\": True},<br/> {\"name\": \"EMA 13\", \"data\": df['ema_slow'].tolist(), \"color\": \"#4ECDC4\", \"overlay\": True}<br/> ],<br/> \"signals\": [<br/> {\"type\": \"buy\", \"text\": \"BUY\", \"data\": buy_signals, \"color\": \"#00E676\"},<br/> {\"type\": \"sell\", \"text\": \"SELL\", \"data\": sell_signals, \"color\": \"#FF5252\"}<br/> ]<br/>}",
'dashboard.indicator.guide.section7.exampleB': 'Example B: Bollinger Bands - Chart Display Only',
'dashboard.indicator.guide.section7.exampleBCode': "# Calculate Bollinger Bands (chart display only, no backtesting)<br/>length = 20<br/>mult = 2.0<br/>df['sma'] = df['close'].rolling(length).mean()<br/>df['std'] = df['close'].rolling(length).std()<br/>df['upper'] = df['sma'] + mult * df['std']<br/>df['lower'] = df['sma'] - mult * df['std']<br/><br/>output = {<br/> \"name\": \"Bollinger Bands\",<br/> \"plots\": [<br/> {\"name\": \"Upper\", \"data\": df['upper'].tolist(), \"color\": \"#FF5252\", \"overlay\": True},<br/> {\"name\": \"Middle\", \"data\": df['sma'].tolist(), \"color\": \"#2196F3\", \"overlay\": True},<br/> {\"name\": \"Lower\", \"data\": df['lower'].tolist(), \"color\": \"#00E676\", \"overlay\": True}<br/> ]<br/>}",
'dashboard.indicator.guide.section7.exampleC': 'Example C: RSI Strategy (Supports Backtesting)',
'dashboard.indicator.guide.section7.exampleCCode': "# Calculate RSI<br/>delta = df['close'].diff()<br/>up = delta.clip(lower=0)<br/>down = -1 * delta.clip(upper=0)<br/>ema_up = up.ewm(com=13, adjust=False).mean()<br/>ema_down = down.ewm(com=13, adjust=False).mean()<br/>rs = ema_up / ema_down<br/>df['rsi'] = 100 - (100 / (1 + rs))<br/><br/># Generate trading signals<br/>buy_condition = df['rsi'] < 30 # Oversold buy<br/>sell_condition = df['rsi'] > 70 # Overbought sell<br/><br/># ImportantSet NEW backtest signal columns (bool)<br/>df['open_long'] = buy_condition<br/>df['close_long'] = sell_condition<br/>df['open_short'] = sell_condition<br/>df['close_short'] = buy_condition<br/><br/># Generate chart signals<br/>buy_signals = [df['low'].iloc[i] * 0.995 if buy_condition.iloc[i] else None for i in range(len(df))]<br/>sell_signals = [df['high'].iloc[i] * 1.005 if sell_condition.iloc[i] else None for i in range(len(df))]<br/><br/>output = {<br/> \"name\": \"RSI Strategy\",<br/> \"plots\": [<br/> {\"name\": \"RSI\", \"data\": df['rsi'].tolist(), \"color\": \"#9C27B0\", \"overlay\": False},<br/> {\"name\": \"Overbought\", \"data\": [70] * len(df), \"color\": \"#FF5252\", \"style\": \"dashed\", \"overlay\": False},<br/> {\"name\": \"Oversold\", \"data\": [30] * len(df), \"color\": \"#00E676\", \"style\": \"dashed\", \"overlay\": False}<br/> ],<br/> \"signals\": [<br/> {\"type\": \"buy\", \"text\": \"BUY\", \"data\": buy_signals, \"color\": \"#00E676\"},<br/> {\"type\": \"sell\", \"text\": \"SELL\", \"data\": sell_signals, \"color\": \"#FF5252\"}<br/> ]<br/>}",
'dashboard.indicator.guide.section7.exampleCCode': "# Calculate RSI<br/>delta = df['close'].diff()<br/>up = delta.clip(lower=0)<br/>down = -1 * delta.clip(upper=0)<br/>ema_up = up.ewm(com=13, adjust=False).mean()<br/>ema_down = down.ewm(com=13, adjust=False).mean()<br/>rs = ema_up / ema_down<br/>df['rsi'] = 100 - (100 / (1 + rs))<br/><br/># Generate trading signals<br/>buy_condition = df['rsi'] < 30 # Oversold buy<br/>sell_condition = df['rsi'] > 70 # Overbought sell<br/><br/># 【Important】Set NEW backtest signal columns (bool)<br/>df['open_long'] = buy_condition<br/>df['close_long'] = sell_condition<br/>df['open_short'] = sell_condition<br/>df['close_short'] = buy_condition<br/><br/># Generate chart signals<br/>buy_signals = [df['low'].iloc[i] * 0.995 if buy_condition.iloc[i] else None for i in range(len(df))]<br/>sell_signals = [df['high'].iloc[i] * 1.005 if sell_condition.iloc[i] else None for i in range(len(df))]<br/><br/>output = {<br/> \"name\": \"RSI Strategy\",<br/> \"plots\": [<br/> {\"name\": \"RSI\", \"data\": df['rsi'].tolist(), \"color\": \"#9C27B0\", \"overlay\": False},<br/> {\"name\": \"Overbought\", \"data\": [70] * len(df), \"color\": \"#FF5252\", \"style\": \"dashed\", \"overlay\": False},<br/> {\"name\": \"Oversold\", \"data\": [30] * len(df), \"color\": \"#00E676\", \"style\": \"dashed\", \"overlay\": False}<br/> ],<br/> \"signals\": [<br/> {\"type\": \"buy\", \"text\": \"BUY\", \"data\": buy_signals, \"color\": \"#00E676\"},<br/> {\"type\": \"sell\", \"text\": \"SELL\", \"data\": sell_signals, \"color\": \"#FF5252\"}<br/> ]<br/>}",
'dashboard.indicator.guide.section6.param': 'Parameters',
'dashboard.indicator.guide.section6.calc': 'Calculate',
'dashboard.indicator.guide.section6.output': 'Output',
@@ -1262,7 +1262,7 @@ const locale = {
'form.basic-form.phone-number.placeholder': 'Phone number',
'form.basic-form.verification-code.placeholder': 'Verification code',
'result.success.title': 'Submission Success',
'result.success.description': 'The submission results page is used to feed back the results of a series of operational tasks. If it is a simple operation, use the Message global prompt feedback. This text area can show a simple supplementary explanation. If there is a similar requirement for displaying documents, the following gray area can present more complicated content.',
'result.success.description': 'The submission results page is used to feed back the results of a series of operational tasks. If it is a simple operation, use the Message global prompt feedback. This text area can show a simple supplementary explanation. If there is a similar requirement for displaying “documents”, the following gray area can present more complicated content.',
'result.success.operate-title': 'Project Name',
'result.success.operate-id': 'Project ID',
'result.success.principal': 'Principal',
@@ -1310,13 +1310,13 @@ const locale = {
'account.settings.security.medium': 'Medium',
'account.settings.security.weak': 'Weak',
'account.settings.security.password': 'Account Password',
'account.settings.security.password-description': 'Current password strength',
'account.settings.security.password-description': 'Current password strength:',
'account.settings.security.phone': 'Security Phone',
'account.settings.security.phone-description': 'Bound phone',
'account.settings.security.phone-description': 'Bound phone:',
'account.settings.security.question': 'Security Question',
'account.settings.security.question-description': 'The security question is not set, and the security policy can effectively protect the account security',
'account.settings.security.email': 'Bound Email',
'account.settings.security.email-description': 'Bound Email',
'account.settings.security.email-description': 'Bound Email:',
'account.settings.security.mfa': 'MFA Device',
'account.settings.security.mfa-description': 'Unbound MFA device, after binding, can be confirmed twice',
'account.settings.security.modify': 'Modify',
@@ -1362,9 +1362,77 @@ const locale = {
'trading-assistant.strategyType.IndicatorStrategy': 'Indicator Strategy',
'trading-assistant.strategyType.PromptBasedStrategy': 'Prompt Strategy',
'trading-assistant.strategyType.GridStrategy': 'Grid Strategy',
'trading-assistant.tabs.overview': 'Overview',
'trading-assistant.tabs.strategyManage': 'Strategy Manager',
'trading-assistant.tabs.tradingRecords': 'Trading Records',
'trading-assistant.tabs.positions': 'Positions',
'trading-assistant.tabs.equityCurve': 'Equity Curve',
'trading-assistant.tabs.performance': 'Performance',
'trading-assistant.tabs.logs': 'Logs',
'trading-assistant.guide.eyebrow': 'Trading Workflow',
'trading-assistant.guide.title': 'Build, launch, and monitor strategies from one workspace',
'trading-assistant.guide.desc': 'The latest private frontend adds a clearer entry flow for strategy creation and management. This screen now mirrors that structure more closely.',
'trading-assistant.guide.step1Title': 'Choose an indicator',
'trading-assistant.guide.step1Desc': 'Start from your own or purchased indicator, then define market and symbol scope.',
'trading-assistant.guide.step2Title': 'Configure execution',
'trading-assistant.guide.step2Desc': 'Set notifications, risk controls, and optional live-trading credentials.',
'trading-assistant.guide.step3Title': 'Track runtime data',
'trading-assistant.guide.step3Desc': 'Review positions, trade history, performance, and runtime logs after launch.',
'trading-assistant.guide.secondary': 'Open Strategy Manager',
'trading-assistant.guide.primary': 'Create Strategy',
'trading-assistant.empty.title': 'No strategy yet',
'trading-assistant.empty.desc': 'Create a strategy from one of your indicators or open a prefilled strategy from indicator-analysis.',
'trading-assistant.empty.path': 'Indicator Analysis -> Strategy Rocket -> Trading Assistant',
'trading-assistant.empty.primary': 'Create your first strategy',
'trading-assistant.emptyDetail.title': 'Select a strategy to inspect runtime details',
'trading-assistant.emptyDetail.hint': 'Open a strategy from the left list to review positions, trades, performance, and logs in one place.',
'trading-assistant.selectMode': 'Select Strategy Mode',
'trading-assistant.strategyMode.indicator': 'Indicator Strategy',
'trading-assistant.strategyMode.indicatorDesc': 'Build a strategy from your custom or purchased indicators, then add execution and risk settings.',
'trading-assistant.strategyMode.script': 'Script Strategy',
'trading-assistant.strategyMode.scriptDesc': 'Write Python hooks directly, use built-in starter templates, and verify the strategy before launch.',
'trading-assistant.strategyMode.useTemplate': 'Use Starter Template',
'trading-assistant.indicatorEmpty.title': 'No indicator available yet',
'trading-assistant.indicatorEmpty.desc': 'Create or purchase an indicator first, then come back to build a strategy from it.',
'trading-assistant.indicatorEmpty.cta': 'Open Indicator Analysis',
'trading-assistant.editor.title': 'Script Editor',
'trading-assistant.editor.subtitle': 'Write Python hooks directly, verify syntax and safety, or start from a template.',
'trading-assistant.editor.verify': 'Verify',
'trading-assistant.editor.docsButton': 'Open Docs',
'trading-assistant.editor.hintTitle': 'Script strategies run custom Python hooks',
'trading-assistant.editor.hintDesc': 'Define at least on_init(ctx) and on_bar(ctx, bar). Use the docs tab for the helper API and the template tab for starter code.',
'trading-assistant.editor.templateTab': 'Templates',
'trading-assistant.editor.templateIntroTitle': 'Starter Templates',
'trading-assistant.editor.templateIntroDesc': 'Choose a strategy scaffold, tune the parameters, then inject the generated code into the editor.',
'trading-assistant.editor.paramsTab': 'Template Parameters',
'trading-assistant.editor.paramsHint': 'Apply to overwrite the current editor content with the selected template.',
'trading-assistant.editor.resetTemplateParams': 'Reset',
'trading-assistant.editor.applyTemplateParams': 'Apply Template',
'trading-assistant.editor.docsTab': 'Docs',
'trading-assistant.editor.docsIntroTitle': 'Strategy API Reference',
'trading-assistant.editor.docsIntroDesc': 'The private build exposes the same hook model below. Keep the required hooks and use the ctx helpers shown here.',
'trading-assistant.editor.requiredHooks': 'Required Hooks',
'trading-assistant.editor.contextHelpers': 'Context Helpers',
'trading-assistant.editor.barFields': 'Bar Fields',
'trading-assistant.editor.aiTab': 'AI',
'trading-assistant.editor.aiGenerate': 'Generate with AI',
'trading-assistant.editor.aiHint': 'Describe your trading idea in plain English. The backend returns Python code only.',
'trading-assistant.editor.aiPromptPlaceholder': 'Example: Build a BTC breakout strategy that buys when price closes above the highest close of the last 20 bars and exits with a 2% stop loss.',
'trading-assistant.editor.aiGenerateBtn': 'Generate Code',
'trading-assistant.editor.aiSuggestionImprove': 'Improve MA crossover',
'trading-assistant.editor.aiSuggestionRisk': 'Add risk controls',
'trading-assistant.editor.aiSuggestionExplain': 'Explainable reversion',
'trading-assistant.editor.templateApplied': 'Template applied to the editor',
'trading-assistant.editor.codeHint': 'Please add strategy code before continuing',
'trading-assistant.editor.verifySuccess': 'Code verification passed',
'trading-assistant.editor.verifyFailed': 'Code verification failed',
'trading-assistant.editor.aiPromptRequired': 'Please enter your idea before generating code',
'trading-assistant.editor.aiGenerateFailed': 'AI code generation failed',
'trading-assistant.editor.aiGenerateSuccess': 'Code generated successfully',
'trading-assistant.editor.defaultStrategyName': 'Script Strategy',
'trading-assistant.editor.scriptModeTitle': 'Direct Python strategy mode',
'trading-assistant.editor.scriptModeDesc': 'The next step opens a full code editor with starter templates, API reference, and backend verification.',
'trading-assistant.editor.symbolHint': 'Script strategies currently run on a single watchlist symbol.',
'trading-assistant.form.step1': 'Select Indicator',
'trading-assistant.form.step2': 'Exchange Configuration',
'trading-assistant.form.step3': 'Strategy Parameters',
@@ -1450,6 +1518,8 @@ const locale = {
'trading-assistant.form.timeframe1H': '1 Hour',
'trading-assistant.form.timeframe4H': '4 Hours',
'trading-assistant.form.timeframe1D': '1 Day',
'trading-assistant.form.defaultStrategyName': 'New Strategy',
'trading-assistant.form.defaultStrategySuffix': 'Strategy',
'trading-assistant.form.selectStrategyType': 'Select Strategy Type',
'trading-assistant.form.indicatorStrategy': 'Indicator Strategy',
'trading-assistant.form.indicatorStrategyDesc': 'Automated trading strategy based on technical indicators',
@@ -1489,7 +1559,7 @@ const locale = {
'trading-assistant.form.liveTradingCryptoOnlyHint': 'Live trading is available for Crypto only. Other markets can only push signals.',
'trading-assistant.form.liveTradingNotSupportedHint': 'Live trading is not available for this market',
'trading-assistant.form.broker': 'Broker',
'trading-assistant.form.localDeploymentRequired': '⚠️ Local Deployment Required',
'trading-assistant.form.localDeploymentRequired': 'Local Deployment Required',
'trading-assistant.form.localDeploymentHint': 'IBKR and MT5 are external trading interfaces that require local deployment of QuantDinger. Cloud SaaS mode is not supported. Please ensure you have installed and configured the trading software (TWS/IB Gateway or MT5 terminal) on your local machine.',
'trading-assistant.form.ibkrConnectionTitle': 'Interactive Brokers Connection',
'trading-assistant.form.ibkrConnectionHint': 'Make sure TWS or IB Gateway is running with API enabled',
@@ -1728,8 +1798,7 @@ const locale = {
'ftx': 'FTX',
'ftxus': 'FTX US',
'binanceus': 'Binance US',
'binancecoinm': 'Binance COIN-M',
'binanceusdm': 'Binance USDⓈ-M',
'binanceusdm': 'Binance USD-M',
'ibkr': 'Interactive Brokers (IBKR)',
'deepcoin': 'Deepcoin'
},
@@ -2925,6 +2994,14 @@ const locale = {
'fastAnalysis.analysisTime': 'Analysis time',
'fastAnalysis.startAnalysis': 'Analyze',
'fastAnalysis.history': 'History',
'fastAnalysis.analysisSubmitted': 'Analysis task submitted. You can check progress in History.',
'fastAnalysis.analysisSubmittedWithCredits': 'Task submitted. Remaining credits: {remaining}',
'fastAnalysis.analysisStillProcessing': 'This task is still processing. Please refresh history shortly.',
'fastAnalysis.analysisInProgress': 'An analysis is already running for this symbol. Please wait and try again.',
'fastAnalysis.analysisInProgressTitle': 'Analysis in progress',
'fastAnalysis.insufficientCredits': 'Insufficient credits',
'fastAnalysis.insufficientCreditsDetail': 'Insufficient credits: need {required}, you have {current}, short by {shortage}.',
'fastAnalysis.analysisCompleteWithCredits': 'Analysis complete. Remaining credits: {remaining}',
'fastAnalysis.systemTitle': 'QUANTDINGER AI',
'fastAnalysis.systemOnline': 'Online',
'fastAnalysis.version': 'Fast',
@@ -3090,11 +3167,11 @@ const locale = {
// AI Quant Prompt Templates
'aiQuant.field.promptTemplate': 'Strategy Template',
'aiQuant.placeholder.selectTemplate': 'Select a preset template',
'aiQuant.template.default': '📊 Comprehensive Analysis (Recommended)',
'aiQuant.template.trend': '📈 Trend Following',
'aiQuant.template.swing': '🔄 Swing Trading',
'aiQuant.template.news': '📰 News Driven',
'aiQuant.template.custom': '✏️ Custom',
'aiQuant.template.default': '📊 Comprehensive Analysis (Recommended)',
'aiQuant.template.trend': '📈 Trend Following',
'aiQuant.template.swing': '🔄 Swing Trading',
'aiQuant.template.news': '📰 News Driven',
'aiQuant.template.custom': '✏️ Custom',
'aiQuant.hint.dataProvided': 'System auto-provides: real-time price, indicators (RSI/MACD/MA), recent news, macro data. AI analyzes these with your prompt to determine direction.',
'aiQuant.hint.liveWarning': 'Live mode will trade with REAL money! Make sure you have configured exchange API and understand the risks!',
@@ -3149,6 +3226,7 @@ const locale = {
'community.vipFree': 'VIP Free',
// Indicator publish
'dashboard.indicator.action.createStrategy': 'Create Strategy',
'dashboard.indicator.publish.vipFree': 'VIP Free',
'dashboard.indicator.publish.vipFreeHint': 'When enabled: VIP users can use this indicator for free (non-VIP still need to purchase).'
}
+19 -19
View File
@@ -29,7 +29,7 @@ router.beforeEach((to, from, next) => {
to.meta && typeof to.meta.title !== 'undefined' && setDocumentTitle(`${i18nRender(to.meta.title)} - ${domTitle}`)
// Check whether we have a token (local-only auth).
// 处理 token 可能是字符串或对象的情况
// Handle tokens stored as either strings or objects
let token = storage.get(ACCESS_TOKEN)
if (token && typeof token !== 'string') {
token = token.token || token.value || (typeof token === 'object' ? null : token)
@@ -37,32 +37,32 @@ router.beforeEach((to, from, next) => {
token = typeof token === 'string' ? token : null
if (token) {
// 有 token,允许访问所有页面
// 如果访问登录页,跳转到默认页面
// Token present, allow access to all pages
// If visiting the login page, redirect to the default page
if (to.path === loginRoutePath) {
next({ path: defaultRoutePath })
NProgress.done()
} else {
// 检查用户信息是否已加载
// Check whether user info has been loaded
if (store.getters.roles.length === 0) {
store.dispatch('GetInfo')
.then(res => {
// 拉取用户信息成功
// User info loaded successfully
// const roles = res && res.role
// 生成路由
// Generate routes
store.dispatch('GenerateRoutes', { token }).then(() => {
// 动态添加可访问路由表
resetRouter() // 重置路由
// Dynamically add accessible routes
resetRouter() // Reset routes
store.getters.addRouters.forEach(r => {
router.addRoute(r)
})
// 请求带有 redirect 重定向时,登录自动重定向到该地址
// If a redirect query is present, automatically redirect there after login
const redirect = decodeURIComponent(from.query.redirect || to.path)
if (to.path === redirect) {
// hack方法 确保addRoutes已完成 ,set the replace: true so the navigation will not leave a history record
// Hack to ensure addRoutes has completed. Use replace: true so navigation does not create a history entry
next({ ...to, replace: true })
} else {
// 跳转到目的路由
// Navigate to the target route
next({ path: redirect })
}
})
@@ -90,17 +90,17 @@ router.beforeEach((to, from, next) => {
})
})
} else {
// 检查路由是否已初始化
// Check whether routes have been initialized
const addRouters = store.getters.addRouters
// 如果路由未初始化,先初始化路由
// Initialize routes first if they are not ready
if (!addRouters || addRouters.length === 0) {
store.dispatch('GenerateRoutes', { token }).then(() => {
// 动态添加可访问路由表
resetRouter() // 重置路由 防止退出重新登录或者 token 过期后页面未刷新,导致的路由重复添加
// Dynamically add accessible routes
resetRouter() // Reset routes to avoid duplicates after logout or token expiration without a page refresh
store.getters.addRouters.forEach(r => {
router.addRoute(r)
})
// 重新进入当前路由,避免首次刷新空白
// Re-enter the current route to avoid a blank first refresh
next({ ...to, replace: true })
}).catch(() => {
next()
@@ -111,12 +111,12 @@ router.beforeEach((to, from, next) => {
}
}
} else {
// 没有 token
// No token
if (allowList.includes(to.name)) {
// 在免登录名单,直接进入
// Allow direct access to public routes
next()
} else {
// 跳转到登录页
// Redirect to the login page
next({ path: loginRoutePath, query: { redirect: to.fullPath } })
NProgress.done() // if current page is login will not trigger afterEach hook, so manually handle it
}
+1 -1
View File
@@ -19,7 +19,7 @@ const createRouter = () =>
const router = createRouter()
// 定义一个resetRouter 方法,在退出登录后或token过期后 需要重新登录时,调用即可
// Define resetRouter and call it after logout or token expiration when re-login is required
export function resetRouter () {
const newRouter = createRouter()
router.matcher = newRouter.matcher
@@ -1,6 +1,6 @@
<template>
<div class="fast-analysis-report" :class="{ 'theme-dark': isDarkTheme }">
<!-- Loading State - 专业进度条 -->
<!-- Loading State - progress tracker -->
<div v-if="loading" class="loading-container">
<div class="loading-content-pro">
<div class="loading-header">
@@ -8,7 +8,7 @@
<span class="loading-title">{{ $t('fastAnalysis.analyzing') }}</span>
</div>
<!-- 进度条 -->
<!-- Progress bar -->
<div class="progress-wrapper">
<a-progress
:percent="progressPercent"
@@ -19,32 +19,32 @@
<span class="progress-text">{{ progressPercent }}%</span>
</div>
<!-- 当前步骤 -->
<!-- Current step -->
<div class="current-step">
<a-icon type="loading" spin />
<span>{{ currentStepText }}</span>
</div>
<!-- 步骤列表 -->
<!-- Step list -->
<div class="steps-list">
<div class="step-item" :class="{ done: step > 1, active: step === 1 }">
<span class="step-dot"></span>
<span class="step-text">{{ $t('fastAnalysis.step1') || '获取实时数据' }}</span>
<span class="step-text">{{ $t('fastAnalysis.step1') || 'Fetch live market data' }}</span>
<a-icon v-if="step > 1" type="check" class="step-check" />
</div>
<div class="step-item" :class="{ done: step > 2, active: step === 2 }">
<span class="step-dot"></span>
<span class="step-text">{{ $t('fastAnalysis.step2') || '计算技术指标' }}</span>
<span class="step-text">{{ $t('fastAnalysis.step2') || 'Compute technical indicators' }}</span>
<a-icon v-if="step > 2" type="check" class="step-check" />
</div>
<div class="step-item" :class="{ done: step > 3, active: step === 3 }">
<span class="step-dot"></span>
<span class="step-text">{{ $t('fastAnalysis.step3') || 'AI深度分析' }}</span>
<span class="step-text">{{ $t('fastAnalysis.step3') || 'Run AI analysis' }}</span>
<a-icon v-if="step > 3" type="check" class="step-check" />
</div>
<div class="step-item" :class="{ done: step > 4, active: step === 4 }">
<span class="step-dot"></span>
<span class="step-text">{{ $t('fastAnalysis.step4') || '生成报告' }}</span>
<span class="step-text">{{ $t('fastAnalysis.step4') || 'Generate report' }}</span>
<a-icon v-if="step > 4" type="check" class="step-check" />
</div>
</div>
@@ -57,7 +57,10 @@
<!-- Error State -->
<div v-else-if="error" class="error-container">
<a-result status="error" :title="$t('fastAnalysis.error')" :sub-title="error">
<a-result
:status="errorTone === 'warning' ? 'warning' : 'error'"
:title="errorTone === 'warning' ? $t('fastAnalysis.analysisInProgressTitle') : $t('fastAnalysis.error')"
:sub-title="error">
<template #extra>
<a-button type="primary" @click="$emit('retry')">
{{ $t('fastAnalysis.retry') }}
@@ -196,7 +199,7 @@
<a-icon type="line-chart" />
<span>{{ $t('fastAnalysis.technicalAnalysis') }}</span>
<a-tag :color="getScoreTagColor(result.scores?.technical)">
{{ result.scores?.technical || 50 }}
{{ result.scores?.technical || 50 }} pts
</a-tag>
</div>
<div class="analysis-card-content">
@@ -210,7 +213,7 @@
<a-icon type="bank" />
<span>{{ $t('fastAnalysis.fundamentalAnalysis') }}</span>
<a-tag :color="getScoreTagColor(result.scores?.fundamental)">
{{ result.scores?.fundamental || 50 }}
{{ result.scores?.fundamental || 50 }} pts
</a-tag>
</div>
<div class="analysis-card-content">
@@ -224,7 +227,7 @@
<a-icon type="heart" />
<span>{{ $t('fastAnalysis.sentimentAnalysis') }}</span>
<a-tag :color="getScoreTagColor(result.scores?.sentiment)">
{{ result.scores?.sentiment || 50 }}
{{ result.scores?.sentiment || 50 }} pts
</a-tag>
</div>
<div class="analysis-card-content">
@@ -353,13 +356,17 @@ export default {
error: {
type: String,
default: null
},
errorTone: {
type: String,
default: 'error'
}
},
data () {
return {
userFeedback: null,
feedbackLoading: null,
//
// Simplified progress state
progress: 0, // 0-95
elapsedSeconds: 0,
mainTimer: null
@@ -375,7 +382,7 @@ export default {
progressPercent () {
return this.progress
},
//
// Map progress to the visible analysis step.
step () {
if (this.progress < 25) return 1
if (this.progress < 50) return 2
@@ -384,12 +391,12 @@ export default {
},
currentStepText () {
const steps = {
1: this.$t('fastAnalysis.step1') || '获取实时数据',
2: this.$t('fastAnalysis.step2') || '计算技术指标',
3: this.$t('fastAnalysis.step3') || 'AI深度分析',
4: this.$t('fastAnalysis.step4') || '生成报告'
1: this.$t('fastAnalysis.step1') || 'Fetch live market data',
2: this.$t('fastAnalysis.step2') || 'Compute technical indicators',
3: this.$t('fastAnalysis.step3') || 'Run AI analysis',
4: this.$t('fastAnalysis.step4') || 'Generate report'
}
return steps[this.step] || this.$t('fastAnalysis.preparing') || '准备中...'
return steps[this.step] || this.$t('fastAnalysis.preparing') || 'Preparing...'
},
elapsedTimeText () {
if (this.elapsedSeconds < 60) {
@@ -437,7 +444,7 @@ export default {
}
},
mounted () {
//
// Start the progress timer on mount if loading is already active.
if (this.loading) {
this.startProgressTimer()
}
@@ -490,22 +497,22 @@ export default {
if (level === 'low') return 'low-volatility'
return ''
},
//
// Translate technical indicator signals.
translateSignal (signal) {
if (!signal) return '--'
const key = `fastAnalysis.signal.${signal}`
const translated = this.$t(key)
// ,
// Fall back to the raw value if no translation key exists.
return translated === key ? signal : translated
},
//
// Translate trend labels.
translateTrend (trend) {
if (!trend) return '--'
const key = `fastAnalysis.trend.${trend}`
const translated = this.$t(key)
return translated === key ? trend : translated
},
//
// Translate volatility labels.
translateVolatility (level) {
if (!level) return '--'
const key = `fastAnalysis.volatilityLevel.${level}`
@@ -514,8 +521,8 @@ export default {
},
async submitFeedback (feedback) {
if (!this.result?.memory_id) {
// memory_id
this.$message.warning(this.$t('fastAnalysis.feedbackUnavailable') || '反馈功能暂不可用,请刷新后重试')
// Warn the user when `memory_id` is unavailable. This usually means the backend is outdated or persistence failed.
this.$message.warning(this.$t('fastAnalysis.feedbackUnavailable') || 'Feedback is unavailable right now. Refresh and try again.')
return
}
@@ -535,32 +542,32 @@ export default {
}
},
startProgressTimer () {
//
// Clear any existing timer before starting a new cycle.
this.stopProgressTimer()
//
// Reset the local progress state.
this.progress = 0
this.elapsedSeconds = 0
const startTime = Date.now()
// 100ms
// Use a single timer that updates every 100ms.
this.mainTimer = window.setInterval(() => {
//
// Update the elapsed time in seconds.
this.elapsedSeconds = Math.floor((Date.now() - startTime) / 1000)
// - 1295%
// Advance toward 95% over roughly 12 seconds, then wait for the real completion event.
if (this.progress < 75) {
// 75%100ms1% (7.5)
// First 75%: +1 every 100ms, about 7.5 seconds.
this.progress = Math.min(this.progress + 1, 75)
} else if (this.progress < 90) {
// 75-90%100ms0.5% (3)
// 75-90%: +0.5 every 100ms, about 3 seconds.
this.progress = Math.min(this.progress + 0.5, 90)
} else if (this.progress < 95) {
// 90-95%100ms0.2% (2.5)
// 90-95%: +0.2 every 100ms, about 2.5 seconds.
this.progress = Math.min(this.progress + 0.2, 95)
}
// 95%
// Stop growing after 95% and wait for the actual backend completion.
}, 100)
},
stopProgressTimer () {
@@ -583,7 +590,7 @@ export default {
background: linear-gradient(135deg, #f5f7fa 0%, #e4e8ec 100%);
border-radius: 12px;
// Loading State -
// Loading state with the professional progress tracker.
.loading-container {
display: flex;
align-items: center;
@@ -901,8 +908,7 @@ export default {
}
}
// Analysis Details
//
// Detailed analysis cards.
.detailed-analysis {
display: flex;
flex-direction: column;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -255,7 +255,14 @@
</div>
<div class="run-section">
<a-button type="primary" block size="large" :loading="indicatorRunning" :disabled="!canRunIndicator" @click="runIndicatorBacktest">
<a-button
type="primary"
block
size="large"
:loading="indicatorRunning"
:disabled="!canRunIndicator"
@click="runIndicatorBacktest"
>
<a-icon v-if="!indicatorRunning" type="thunderbolt" />
{{ indicatorRunning ? tt('backtest-center.running', 'Running backtest...') : tt('backtest-center.indicator.runBacktest', 'Run indicator backtest') }}
</a-button>
@@ -468,7 +475,14 @@
</div>
<div class="run-section">
<a-button type="primary" block size="large" :loading="strategyRunning" :disabled="!canRunStrategy" @click="runStrategyBacktest">
<a-button
type="primary"
block
size="large"
:loading="strategyRunning"
:disabled="!canRunStrategy"
@click="runStrategyBacktest"
>
<a-icon v-if="!strategyRunning" type="thunderbolt" />
{{ strategyRunning ? tt('backtest-center.running', 'Running backtest...') : tt('backtest-center.strategy.runBacktest', 'Run strategy backtest') }}
</a-button>
+1 -1
View File
@@ -319,7 +319,7 @@ export default {
async buy (plan) {
this.purchasing = plan
try {
// Prefer USDT scan-to-pay flow (B)
// Prefer the USDT scan-to-pay flow (Option B)
const res = await createUsdtOrder(plan)
if (res && res.code === 1 && res.data) {
this.usdtOrder = res.data
+49 -49
View File
@@ -1,9 +1,9 @@
<template>
<div class="dashboard-pro" :class="{ 'theme-dark': isDarkTheme }">
<!-- 主要KPI指标卡片 -->
<!-- Primary KPI cards -->
<div class="kpi-grid">
<!-- 总权益 -->
<!-- Total equity -->
<div class="kpi-card kpi-primary">
<div class="kpi-glow"></div>
<div class="kpi-content">
@@ -26,14 +26,14 @@
</div>
</div>
<!-- 胜率 -->
<!-- Win rate -->
<div class="kpi-card kpi-win-rate">
<div class="kpi-content">
<div class="kpi-header">
<span class="kpi-icon">
<a-icon type="trophy" />
</span>
<span class="kpi-label">{{ $t('dashboard.winRate') || '胜率' }}</span>
<span class="kpi-label">{{ $t('dashboard.winRate') || 'Win Rate' }}</span>
</div>
<div class="kpi-value">
<span class="amount">{{ formatNumber(performance.win_rate, 1) }}</span>
@@ -62,14 +62,14 @@
</div>
</div>
<!-- 盈亏比 -->
<!-- Profit factor -->
<div class="kpi-card kpi-profit-factor">
<div class="kpi-content">
<div class="kpi-header">
<span class="kpi-icon">
<a-icon type="rise" />
</span>
<span class="kpi-label">{{ $t('dashboard.profitFactor') || '盈亏比' }}</span>
<span class="kpi-label">{{ $t('dashboard.profitFactor') || 'Profit Factor' }}</span>
</div>
<div class="kpi-value">
<span class="amount">{{ formatNumber(performance.profit_factor, 2) }}</span>
@@ -82,14 +82,14 @@
</div>
</div>
<!-- 最大回撤 -->
<!-- Max drawdown -->
<div class="kpi-card kpi-drawdown">
<div class="kpi-content">
<div class="kpi-header">
<span class="kpi-icon">
<a-icon type="fall" />
</span>
<span class="kpi-label">{{ $t('dashboard.maxDrawdown') || '最大回撤' }}</span>
<span class="kpi-label">{{ $t('dashboard.maxDrawdown') || 'Max Drawdown' }}</span>
</div>
<div class="kpi-value">
<span class="amount negative">{{ formatNumber(performance.max_drawdown_pct, 1) }}</span>
@@ -101,14 +101,14 @@
</div>
</div>
<!-- 总交易数 -->
<!-- Total trades -->
<div class="kpi-card kpi-trades">
<div class="kpi-content">
<div class="kpi-header">
<span class="kpi-icon">
<a-icon type="swap" />
</span>
<span class="kpi-label">{{ $t('dashboard.totalTrades') || '总交易' }}</span>
<span class="kpi-label">{{ $t('dashboard.totalTrades') || 'Total Trades' }}</span>
</div>
<div class="kpi-value">
<span class="amount">{{ performance.total_trades }}</span>
@@ -122,14 +122,14 @@
</div>
</div>
<!-- 运行策略 -->
<!-- Running strategies -->
<div class="kpi-card kpi-strategies clickable" @click="goToStrategyManagement">
<div class="kpi-content">
<div class="kpi-header">
<span class="kpi-icon">
<a-icon type="thunderbolt" theme="filled" />
</span>
<span class="kpi-label">{{ $t('dashboard.runningStrategies') || '运行中策略' }}</span>
<span class="kpi-label">{{ $t('dashboard.runningStrategies') || 'Running Strategies' }}</span>
</div>
<div class="kpi-value">
<span class="amount">{{ summary.indicator_strategy_count }}</span>
@@ -164,14 +164,14 @@
</div>
</div>
<!-- 图表区域 - 第一行 -->
<!-- Chart area - first row -->
<div class="chart-row">
<!-- 收益日历 -->
<!-- Profit calendar -->
<div class="chart-panel chart-main">
<div class="panel-header">
<div class="panel-title">
<a-icon type="calendar" />
<span>{{ $t('dashboard.profitCalendar') || '收益日曆' }}</span>
<span>{{ $t('dashboard.profitCalendar') || 'Profit Calendar' }}</span>
</div>
<div class="calendar-nav">
<a-button type="link" size="small" @click="prevMonth" :disabled="currentCalendarIndex >= calendarMonths.length - 1">
@@ -235,49 +235,49 @@
</div>
</div>
<!-- 策略表现饼图 -->
<!-- Strategy allocation pie chart -->
<div class="chart-panel chart-side">
<div class="panel-header">
<div class="panel-title">
<a-icon type="pie-chart" />
<span>{{ $t('dashboard.strategyAllocation') || '策略分布' }}</span>
<span>{{ $t('dashboard.strategyAllocation') || 'Strategy Allocation' }}</span>
</div>
</div>
<div ref="pieChart" class="chart-body"></div>
</div>
</div>
<!-- 图表区域 - 第二行 -->
<!-- Chart area - second row -->
<div class="chart-row">
<!-- 回撤曲线 -->
<!-- Drawdown curve -->
<div class="chart-panel chart-half">
<div class="panel-header">
<div class="panel-title">
<a-icon type="area-chart" />
<span>{{ $t('dashboard.drawdownCurve') || '回撤曲线' }}</span>
<span>{{ $t('dashboard.drawdownCurve') || 'Drawdown Curve' }}</span>
</div>
</div>
<div ref="drawdownChart" class="chart-body chart-sm"></div>
</div>
<!-- 交易时段分布 -->
<!-- Hourly trading distribution -->
<div class="chart-panel chart-half">
<div class="panel-header">
<div class="panel-title">
<a-icon type="clock-circle" />
<span>{{ $t('dashboard.hourlyDistribution') || '交易时段' }}</span>
<span>{{ $t('dashboard.hourlyDistribution') || 'Trading Hours' }}</span>
</div>
</div>
<div ref="hourlyChart" class="chart-body chart-sm"></div>
</div>
</div>
<!-- 策略排行榜 -->
<!-- Strategy ranking -->
<div class="chart-panel">
<div class="panel-header">
<div class="panel-title">
<a-icon type="ordered-list" />
<span>{{ $t('dashboard.strategyRanking') || '策略排行榜' }}</span>
<span>{{ $t('dashboard.strategyRanking') || 'Strategy Ranking' }}</span>
</div>
</div>
<div class="strategy-ranking">
@@ -328,9 +328,9 @@
</div>
</div>
<!-- 数据表格区域 -->
<!-- Data tables -->
<div class="table-row">
<!-- 当前持仓 -->
<!-- Current positions -->
<div class="table-panel">
<div class="panel-header">
<div class="panel-title">
@@ -372,7 +372,7 @@
</a-table>
</div>
<!-- 最近交易 -->
<!-- Recent trades -->
<div class="table-panel">
<div class="panel-header">
<div class="panel-title">
@@ -406,7 +406,7 @@
</div>
</div>
<!-- 订单执行记录 -->
<!-- Order execution records -->
<div class="chart-panel orders-panel">
<div class="panel-header">
<div class="panel-title">
@@ -555,7 +555,7 @@ export default {
pageSize: 20,
total: 0
},
//
// Sound notification logic
orderPollTimer: null,
lastOrderId: 0,
orderPollIntervalMs: 5000,
@@ -805,12 +805,12 @@ export default {
this.ordersPagination.total = Number(data.total || 0)
}
} catch (e) {
console.error('获取订单列表失败:', e)
console.error('Failed to get order list:', e)
} finally {
this.ordersLoading = false
}
},
// ========== ==========
// ========== Order sound notifications ==========
playOrderBeep () {
if (!this.soundEnabled) return
try {
@@ -818,11 +818,11 @@ export default {
if (!AudioCtx) return
if (!this.beepCtx) this.beepCtx = new AudioCtx()
const ctx = this.beepCtx
//
// Some browsers require user interaction before audio can be played
if (ctx.state === 'suspended' && typeof ctx.resume === 'function') {
ctx.resume().catch(() => {})
}
//
// Play two short alert tones
const playTone = (startTime, freq) => {
const o = ctx.createOscillator()
const g = ctx.createGain()
@@ -835,15 +835,15 @@ export default {
o.stop(startTime + 0.12)
}
const now = ctx.currentTime
playTone(now, 880) //
playTone(now + 0.18, 1100) //
playTone(now, 880) // first tone
playTone(now + 0.18, 1100) // second tone at a higher pitch
} catch (e) {
console.error('播放提示音失败:', e)
console.error('Failed to play alert sound:', e)
}
},
startOrderPolling () {
this.stopOrderPolling()
// lastOrderId
// Initialize lastOrderId
this.initLastOrderId()
this.orderPollTimer = setInterval(() => {
this.pollNewOrders()
@@ -859,11 +859,11 @@ export default {
try {
const res = await getPendingOrders({ page: 1, pageSize: 1 })
if (res.code === 1 && res.data && res.data.list && res.data.list.length > 0) {
// ID
// Get the newest order ID
this.lastOrderId = res.data.list[0].id || 0
}
} catch (e) {
console.error('初始化订单ID失败:', e)
console.error('Failed to initialize the order ID:', e)
}
},
async pollNewOrders () {
@@ -874,7 +874,7 @@ export default {
const orders = res.data.list || []
if (orders.length === 0) return
//
// Check for new orders
let hasNew = false
let maxId = this.lastOrderId
for (const order of orders) {
@@ -888,9 +888,9 @@ export default {
if (hasNew) {
this.lastOrderId = maxId
this.playOrderBeep()
//
// Refresh the order list
this.fetchPendingOrders()
//
// Show the notification
this.$notification.info({
message: this.$t('dashboard.newOrderNotify'),
description: this.$t('dashboard.newOrderDesc'),
@@ -898,7 +898,7 @@ export default {
})
}
} catch (e) {
console.error('轮询订单失败:', e)
console.error('Failed to poll orders:', e)
}
},
toggleSound () {
@@ -995,19 +995,19 @@ export default {
if (num === undefined || num === null) return '0.00'
return Number(num).toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits })
},
//
// Format PnL values, including signal-only mode without live positions
formatProfitValue (value, record) {
if (value === null || value === undefined) return '--'
const numValue = parseFloat(value)
// 0open_long/open_short--
// If the value is 0 and the signal opens a position (open_long/open_short), show --
const openTypes = ['open_long', 'open_short', 'add_long', 'add_short']
if (numValue === 0 && record && openTypes.includes(record.type)) {
return '--'
}
// 0E-80
// Treat extremely small values, such as 0E-8, as 0
if (Math.abs(numValue) < 0.000001) {
if (record && openTypes.includes(record.type)) {
return '--'
@@ -1015,7 +1015,7 @@ export default {
return '$0.00'
}
//
// Render normally
const sign = numValue >= 0 ? '+' : ''
return `${sign}$${this.formatNumber(numValue)}`
},
@@ -1151,7 +1151,7 @@ export default {
return `
<div style="padding: 4px 0;">
<div style="font-weight:600;margin-bottom:6px;">${p.name}</div>
<div style="color:${textColor}">占比 <span style="font-weight:600;color:${isDark ? '#f3f4f6' : '#1f2937'}">${p.percent}%</span></div>
<div style="color:${textColor}">Share <span style="font-weight:600;color:${isDark ? '#f3f4f6' : '#1f2937'}">${p.percent}%</span></div>
<div style="color:${textColor}">PNL <span style="font-weight:600;color:${svColor}">$${svStr}</span></div>
</div>
`
@@ -1171,7 +1171,7 @@ export default {
color: colors,
series: [
{
name: '策略分布',
name: 'Strategy Allocation',
type: 'pie',
radius: ['50%', '75%'],
center: ['50%', '45%'],
@@ -392,7 +392,6 @@ export default {
// Create new editor instance
this.codeEditor = CodeMirror(this.$refs.codeEditorContainer, {
value: (() => {
const lang = (this.$i18n && this.$i18n.locale) ? this.$i18n.locale : 'en-US'
const existing = this.indicator ? (this.indicator.code || '') : ''
return existing && String(existing).trim() ? existing : this.getDefaultIndicatorCode()
})(),
@@ -1658,7 +1658,7 @@ registerOverlay({
} catch (e) {
}
//
// Track the number of prepended rows so the visible range can be restored.
const newDataCount = filteredNewData.length
// Prepended new data to existing data
@@ -1673,7 +1673,7 @@ registerOverlay({
// Restore scroll position
// Since new data is prepended, original indices need an offset of newDataCount
if (savedVisibleRange && typeof savedVisibleRange.from === 'number') {
//
// Shift the saved visible range forward after prepending data.
// Originally viewed indices from-to now become from+newDataCount to to+newDataCount
const newFrom = savedVisibleRange.from + newDataCount
const newTo = savedVisibleRange.to + newDataCount
@@ -1835,11 +1835,11 @@ registerOverlay({
const newLast = newData[newData.length - 1]
existingData[existingData.length - 1] = {
timestamp: existingLast.timestamp, //
open: existingLast.open, //
high: Math.max(existingLast.high, newLast.high), //
low: Math.min(existingLast.low, newLast.low), //
close: newLast.close, //
timestamp: existingLast.timestamp, // Keep the original timestamp in milliseconds.
open: existingLast.open, // Open stays fixed for the active candle.
high: Math.max(existingLast.high, newLast.high), // Use the highest value seen in the window.
low: Math.min(existingLast.low, newLast.low), // Use the lowest value seen in the window.
close: newLast.close, // Close tracks the latest traded price.
volume: newLast.volume // volume from latest API value (already total volume for the period)
}
klineData.value = existingData
@@ -1850,7 +1850,7 @@ registerOverlay({
// Update KLineChart - use updateData to maintain scroll position
if (chartRef.value && typeof chartRef.value.updateData === 'function') {
// K线
// Update only the last candle so the current scroll position stays intact.
// updateData needs only one argument: data object to update (v9.8.0+ no longer accepts callback)
const lastIndex = klineData.value.length - 1
chartRef.value.updateData(existingData[lastIndex])
@@ -1875,29 +1875,29 @@ registerOverlay({
if (uniqueNewData.length > 0) {
klineData.value = [...existingData, ...uniqueNewData]
//
// Keep only the newest candles once the local cache grows too large.
if (klineData.value.length > 500) {
klineData.value = klineData.value.slice(-500)
}
// 使
// Refresh the price panel using the normalized internal shape.
const internalData = convertToInternalFormat(klineData.value)
updatePricePanel(internalData)
// Update KLineChart - use applyMoreData to maintain scroll position
if (chartRef.value && typeof chartRef.value.applyMoreData === 'function') {
// K线使 applyMoreData
// Append the new candles while preserving the current viewport.
chartRef.value.applyMoreData(uniqueNewData)
// Force single indicator refresh when new Kline appears
maybeUpdateIndicators(true)
} else if (chartRef.value) {
// 使 applyNewData
// Fallback path: applyNewData resets scroll, but still keeps the chart in sync.
chartRef.value.applyNewData(klineData.value)
maybeUpdateIndicators(true)
}
}
}
//
// Ignore stale data that predates the latest candle already on screen.
}
}
} catch (err) {
@@ -1914,11 +1914,11 @@ registerOverlay({
// Intelligently adjust update frequency based on timeframe
const intervalMap = {
'1m': 5000, // 1m K-line, 5s interval
'1m': 5000, // 1m K-line, 5s interval
'5m': 10000, // 5m K-line, 10s interval
'15m': 15000, // 15m K-line, 15s interval
'30m': 30000, // 30m K-line, 30s interval
'1H': 60000, // 1H K-line, 60s interval
'1H': 60000, // 1H K-line, 60s interval
'4H': 300000, // 4H K-line, 5m interval
'1D': 600000, // Daily, 10m interval
'1W': 1800000 // Weekly, 30m interval
@@ -1933,7 +1933,7 @@ registerOverlay({
realtimeTimer.value = setInterval(() => {
// Incrementally update Kline only when not loading and data exists
if (!loading.value && props.symbol && klineData.value && klineData.value.length > 0) {
updateKlineRealtime() // K线
updateKlineRealtime() // Incrementally refresh the latest candles.
}
}, realtimeInterval.value)
}
@@ -1982,12 +1982,12 @@ registerOverlay({
// Initialize KLineChart
const container = document.getElementById('kline-chart-container')
if (!container) {
throw new Error('容器元素不存在')
throw new Error('Chart container element is missing')
}
// Try initializing with options to check for built-in drawing toolbar support
try {
// 使
// Try passing options during init first; some KLineChart builds support it.
chartRef.value = init(container, {
drawingBarVisible: true, // Try enabling built-in drawing toolbar
overlay: {
@@ -2009,7 +2009,7 @@ registerOverlay({
}
if (!chartRef.value) {
throw new Error('图表初始化失败:无法创建图表实例')
throw new Error('Chart initialization failed: could not create the chart instance')
}
// Debug: output all chart instance methods, check for drawing toolbar related ones
@@ -2026,19 +2026,19 @@ registerOverlay({
}
}
//
// Apply the current theme after the chart instance is ready.
updateChartTheme()
// Listen for overlay creation completion, automatically exit drawing mode
if (chartRef.value && typeof chartRef.value.subscribeAction === 'function') {
//
// Track created overlays so custom drawing tools can clean up after themselves.
chartRef.value.subscribeAction('onOverlayCreated', (overlay) => {
// If overlay created via drawing tool, record ID and exit drawing mode
if (activeDrawingTool.value && overlay && overlay.id) {
addedDrawingOverlayIds.value.push(overlay.id)
//
// Reset the active tool once drawing is complete.
activeDrawingTool.value = null
// 退
// Exit drawing mode after one overlay is placed.
try {
if (typeof chartRef.value.overrideOverlay === 'function') {
chartRef.value.overrideOverlay(null)
@@ -2059,13 +2059,13 @@ registerOverlay({
}
})
} catch (e) {
// onOverlayComplete
// Ignore older library versions that do not expose onOverlayComplete.
}
}
// Listen for overlay removal event
chartRef.value.subscribeAction('onOverlayRemoved', (overlayId) => {
//
// Keep the overlay registry in sync when an item is deleted.
const index = addedDrawingOverlayIds.value.indexOf(overlayId)
if (index > -1) {
addedDrawingOverlayIds.value.splice(index, 1)
@@ -2107,7 +2107,7 @@ registerOverlay({
if (chartRef.value && typeof chartRef.value.setVisibleRange === 'function') {
const dataLength = klineData.value.length
if (dataLength > 0) {
//
// Capture the current visible range before loading more history.
const currentRange = chartRef.value.getVisibleRange()
if (currentRange) {
// Calculate number of visible data points
@@ -2129,7 +2129,7 @@ registerOverlay({
// Only trigger on active left scroll (lastVisibleFrom > data.from)
// [Critical] Check both loadingHistory and loadingHistoryPromise to ensure no ongoing requests
if (data.from <= 5 && !loadingHistory.value && !loadingHistoryPromise && hasMoreHistory.value && chartInitialized.value) {
//
// Only trigger load-more when the user actually scrolls left.
if (lastVisibleFrom !== null && lastVisibleFrom > data.from) {
if (klineData.value.length > 0) {
const earliestTimestamp = klineData.value[0].timestamp
@@ -2138,7 +2138,7 @@ registerOverlay({
}
}
//
// Save the latest visible range for the next scroll event.
lastVisibleFrom = data.from
}
})
@@ -2160,7 +2160,7 @@ registerOverlay({
try {
chartRef.value.applyNewData(validData)
} catch (e) {
//
// Fall back to a simpler load-more path for older KLineChart versions.
try {
chartRef.value.applyNewData(validData)
} catch (e2) {
@@ -2182,7 +2182,7 @@ registerOverlay({
window.addEventListener('resize', handleResize)
} catch (error) {
error.value = proxy.$t('dashboard.indicator.error.chartInitFailed') + ': ' + (error.message || '未知错误')
error.value = proxy.$t('dashboard.indicator.error.chartInitFailed') + ': ' + (error.message || 'Unknown error')
}
}
@@ -2323,7 +2323,7 @@ registerOverlay({
}
registerIndicator(indicatorConfig)
// console.log(`: ${name}, series: ${indicatorConfig.series}`)
// console.log(`Registered indicator successfully: ${name}, series: ${indicatorConfig.series}`)
return true
} catch (err) {
// If already registered, ignore error
@@ -2359,7 +2359,7 @@ registerOverlay({
} catch (err) {
}
})
//
// Clear the registry before rebuilding overlays and indicators.
addedSignalOverlayIds.value = []
}
} catch (e) {
@@ -2373,7 +2373,7 @@ registerOverlay({
const name = typeof info === 'string' ? info : info.name
const paneId = typeof info === 'string' ? undefined : info.paneId
//
// Best-effort cleanup for indicator panes created by previous runs.
// KLineChart v9: removeIndicator(paneId, name)
if (paneId) {
chartRef.value.removeIndicator(paneId, name)
@@ -2384,7 +2384,7 @@ registerOverlay({
chartRef.value.removeIndicator(name)
}
})
//
// Clear the registry before rebuilding overlays and indicators.
addedIndicatorIds.value = []
}
} catch (e) {
@@ -2413,11 +2413,11 @@ registerOverlay({
allPlots = [...result.plots]
}
// signals - 使 KLineChart createOverlay
// Render signals as overlays instead of registering them as indicator series.
if (result && result.signals && Array.isArray(result.signals)) {
for (const signal of result.signals) {
if (signal.data && Array.isArray(signal.data) && signal.data.length > 0) {
//
// Sample a few non-null values to validate the returned signal payload.
const sampleValues = []
for (let i = 0; i < Math.min(signal.data.length, 20); i++) {
const val = signal.data[i]
@@ -2428,7 +2428,7 @@ registerOverlay({
}
}
//
// Build overlay points for every valid signal value.
const signalPoints = []
for (let i = 0; i < signal.data.length && i < internalData.length; i++) {
const signalValue = signal.data[i]
@@ -2436,8 +2436,7 @@ registerOverlay({
const klineItem = internalData[i]
const timestamp = klineItem.timestamp || klineItem.time
// K 线 High Low
// internalData
// Use candle high/low so labels can anchor above or below the bar cleanly.
const highPrice = klineItem.high
const lowPrice = klineItem.low
@@ -2472,26 +2471,26 @@ registerOverlay({
}
}
// 使 KLineChart createOverlay
// Add signal markers through KLineChart overlays on the candle pane.
if (signalPoints.length > 0 && chartRef.value) {
for (const point of signalPoints) {
try {
//
// KLineChart expects millisecond timestamps.
let timestamp = point.timestamp
if (timestamp < 1e10) {
timestamp = timestamp * 1000
}
// buy sell
// Keep signal labels minimal: buy/sell only, without numeric amounts.
const displaySimpleText = point.text
// === 使 signalTag ===
// Use the custom signalTag overlay.
if (typeof chartRef.value.createOverlay === 'function') {
const overlayId = chartRef.value.createOverlay({
name: 'signalTag',
//
// Point 0: ()
// Point 1: K线 ()
// Pass two points:
// Point 0 anchors the signal price dot.
// Point 1 anchors the label near the candle extreme.
points: [
{ timestamp: timestamp, value: point.price },
{ timestamp: timestamp, value: point.anchorPrice }
@@ -2503,14 +2502,14 @@ registerOverlay({
action: point.action,
price: point.price
},
lock: true //
}, 'candle_pane') //
lock: true // Lock the overlay to prevent dragging.
}, 'candle_pane') // Draw on the main candle pane.
if (overlayId) {
addedSignalOverlayIds.value.push(overlayId)
}
}
// === ===
// End signalTag overlay creation.
} catch (overlayErr) {
}
}
@@ -2520,13 +2519,13 @@ registerOverlay({
}
}
// plots signals
// Only register plot output here; signals are handled separately as overlays.
if (allPlots.length > 0) {
// plots
// Filter out empty plot payloads before registering the indicator.
const validPlots = allPlots.filter(plot => plot.data && Array.isArray(plot.data) && plot.data.length > 0)
if (validPlots.length > 0) {
// figures plots
// Build the figure list expected by registerCustomIndicator.
const figures = []
const plotDataMap = {}
@@ -2536,7 +2535,7 @@ registerOverlay({
const figureKey = plotName.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '_')
const plotColor = plot.color || getIndicatorColor(plotIdx)
// plot使 'line'
// Default to line plots when the backend does not specify a type.
const figureType = plot.type || 'line'
figures.push({
@@ -2549,7 +2548,7 @@ registerOverlay({
plotDataMap[figureKey] = plot.data
}
// plots overlay
// Overlay on the main pane only when every returned plot supports it.
const allOverlay = validPlots.every(plot => plot.overlay !== false)
// const customIndicatorName = `${indicator.id}_combined`
let customIndicatorName = `${indicator.id}_combined`
@@ -2557,7 +2556,7 @@ registerOverlay({
customIndicatorName = result.name
}
try {
//
// Register a merged custom indicator for the returned plot set.
const registered = registerCustomIndicator(
customIndicatorName,
(kLineDataList) => {
@@ -2580,7 +2579,7 @@ registerOverlay({
if (registered) {
if (allOverlay) {
//
// Main-pane indicator.
const paneId = chartRef.value.createIndicator(
customIndicatorName,
false,
@@ -2592,7 +2591,7 @@ registerOverlay({
addedIndicatorIds.value.push({ paneId: 'candle_pane', name: customIndicatorName })
}
} else {
//
// Sub-pane indicator.
const indicatorId = chartRef.value.createIndicator(
customIndicatorName,
false,
@@ -2629,11 +2628,11 @@ registerOverlay({
allPlots = [...pythonResult.plots]
}
// signals - 使 KLineChart createOverlay
// Render signals as overlays instead of registering them as indicator series.
if (pythonResult && pythonResult.signals && Array.isArray(pythonResult.signals)) {
for (const signal of pythonResult.signals) {
if (signal.data && Array.isArray(signal.data) && signal.data.length > 0) {
//
// Sample a few non-null values to validate the returned signal payload.
const sampleValues = []
for (let i = 0; i < Math.min(signal.data.length, 20); i++) {
const val = signal.data[i]
@@ -2644,7 +2643,7 @@ registerOverlay({
}
}
//
// Build overlay points for every valid signal value.
const signalPoints = []
for (let i = 0; i < signal.data.length && i < internalData.length; i++) {
const signalValue = signal.data[i]
@@ -2652,8 +2651,7 @@ registerOverlay({
const klineItem = internalData[i]
const timestamp = klineItem.timestamp || klineItem.time
// K 线 High Low
// internalData
// Use candle high/low so labels can anchor above or below the bar cleanly.
const highPrice = klineItem.high
const lowPrice = klineItem.low
@@ -2676,7 +2674,7 @@ registerOverlay({
signalPoints.push({
timestamp,
price: signalValue,
// Low High
// Anchor buy labels to the low and sell labels to the high.
anchorPrice: isBuySignal ? lowPrice : highPrice,
side: isBuySignal ? 'buy' : 'sell',
action: signalType,
@@ -2686,26 +2684,26 @@ registerOverlay({
}
}
// 使 KLineChart createOverlay
// Add signal markers through KLineChart overlays on the candle pane.
if (signalPoints.length > 0 && chartRef.value) {
for (const point of signalPoints) {
try {
//
// KLineChart expects millisecond timestamps.
let timestamp = point.timestamp
if (timestamp < 1e10) {
timestamp = timestamp * 1000
}
// buy sell
// Keep signal labels minimal: buy/sell only, without numeric amounts.
const displaySimpleText = point.text
// === 使 signalTag ===
// Use the custom signalTag overlay.
if (typeof chartRef.value.createOverlay === 'function') {
const overlayId = chartRef.value.createOverlay({
name: 'signalTag',
//
// Point 0: ()
// Point 1: K线 ()
// Pass two points:
// Point 0 anchors the signal price dot.
// Point 1 anchors the label near the candle extreme.
points: [
{ timestamp: timestamp, value: point.price },
{ timestamp: timestamp, value: point.anchorPrice }
@@ -2717,14 +2715,14 @@ registerOverlay({
action: point.action,
price: point.price
},
lock: true //
}, 'candle_pane') //
lock: true // Lock the overlay to prevent dragging.
}, 'candle_pane') // Draw on the main candle pane.
if (overlayId) {
addedSignalOverlayIds.value.push(overlayId)
}
}
// === ===
// End signalTag overlay creation.
} catch (overlayErr) {
}
}
@@ -2734,13 +2732,13 @@ registerOverlay({
}
}
// plots signals
// Only register plot output here; signals are handled separately as overlays.
if (allPlots.length > 0) {
// plots
// Filter out empty plot payloads before registering the indicator.
const validPlots = allPlots.filter(plot => plot.data && Array.isArray(plot.data) && plot.data.length > 0)
if (validPlots.length > 0) {
// figures plots
// Build the figure list expected by registerCustomIndicator.
const figures = []
const plotDataMap = {}
@@ -2750,7 +2748,7 @@ registerOverlay({
const figureKey = plotName.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '_')
const plotColor = plot.color || getIndicatorColor(plotIdx)
// plot使 'line'
// Default to line plots when the backend does not specify a type.
const figureType = plot.type || 'line'
figures.push({
@@ -2763,7 +2761,7 @@ registerOverlay({
plotDataMap[figureKey] = plot.data
}
// plots overlay
// Overlay on the main pane only when every returned plot supports it.
const allOverlay = validPlots.every(plot => plot.overlay !== false)
// const customIndicatorName = `${indicator.id}_combined`
let customIndicatorName = `${indicator.id}_combined`
@@ -2772,7 +2770,7 @@ registerOverlay({
}
try {
//
// Register a merged custom indicator for the returned plot set.
const registered = registerCustomIndicator(
customIndicatorName,
(kLineDataList) => {
@@ -2795,7 +2793,7 @@ registerOverlay({
if (registered) {
if (allOverlay) {
//
// Main-pane indicator.
const paneId = chartRef.value.createIndicator(
customIndicatorName,
false,
@@ -2807,7 +2805,7 @@ registerOverlay({
addedIndicatorIds.value.push({ paneId: 'candle_pane', name: customIndicatorName })
}
} else {
//
// Sub-pane indicator.
const indicatorId = chartRef.value.createIndicator(
customIndicatorName,
false,
@@ -2825,7 +2823,7 @@ registerOverlay({
}
} catch (err) {
// If Python engine not ready error, set load failure state
if (err.message && err.message.includes('Python 引擎未就绪')) {
if (err.message && (err.message.includes('Python \u5f15\u64ce\u672a\u5c31\u7eea') || err.message.includes('Python engine not ready') || err.message.includes('Python engine is not ready'))) {
if (!loadingPython.value) {
pyodideLoadFailed.value = true
}
@@ -2861,11 +2859,11 @@ registerOverlay({
[{ key: figureKey, title: `${maType}(${period})`, type: 'line' }],
[period],
2,
true // shouldOverlay: true
true // shouldOverlay: true renders the indicator on the main pane.
)
if (registered) {
//
// Create the indicator instance after registration succeeds.
const paneId = chartRef.value.createIndicator(
customIndicatorName,
false, // isStack
@@ -2924,7 +2922,7 @@ registerOverlay({
],
[length, mult], // calcParams
2, // precision
true // shouldOverlay: true
true // shouldOverlay: true renders the indicator on the main pane.
)
if (registered) {
@@ -2958,7 +2956,7 @@ registerOverlay({
close: d.close
}))
const atrValues = calculateATR(data, period)
// KLineChart
// Convert to KLineChart's expected object-array format.
return atrValues.map(value => ({ atr: value }))
},
[{
@@ -2994,7 +2992,7 @@ registerOverlay({
close: d.close
}))
const wrValues = calculateWilliamsR(data, length)
// KLineChart
// Convert to KLineChart's expected object-array format.
return wrValues.map(value => ({ wr: value }))
},
[{
@@ -3031,7 +3029,7 @@ registerOverlay({
volume: d.volume
}))
const mfiValues = calculateMFI(data, length)
// KLineChart
// Convert to KLineChart's expected object-array format.
return mfiValues.map(value => ({ mfi: value }))
},
[{
@@ -3067,7 +3065,7 @@ registerOverlay({
close: d.close
}))
const cciValues = calculateCCI(data, length)
// KLineChart
// Convert to KLineChart's expected object-array format.
return cciValues.map(value => ({ cci: value }))
},
[{
@@ -3103,7 +3101,7 @@ registerOverlay({
close: d.close
}))
const result = calculateADX(data, length)
// KLineChart
// Convert to KLineChart's expected object-array format.
return result.adx.map(value => ({ adx: value }))
},
[{
@@ -3612,7 +3610,7 @@ registerOverlay({
.kline-chart-container {
flex: 1;
width: 100%;
min-width: 0; /* 防止 flex 子元素溢出 */
min-width: 0; /* Prevent flex children from overflowing. */
background: #fff;
transition: background-color 0.3s;
touch-action: pan-x pan-y;
@@ -171,7 +171,7 @@
<div class="card-header">
<span class="card-name">{{ indicator.name }}</span>
<div class="card-actions">
<!-- 编辑图标 -->
<!-- Edit indicator -->
<a-tooltip :title="$t('dashboard.indicator.action.edit')">
<a-icon
type="edit"
@@ -179,7 +179,7 @@
@click.stop="handleEditIndicator(indicator)"
/>
</a-tooltip>
<!-- 删除图标 -->
<!-- Delete indicator -->
<a-tooltip :title="$t('dashboard.indicator.action.delete')">
<a-icon
type="delete"
@@ -187,7 +187,7 @@
@click.stop="handleDeleteIndicator(indicator)"
/>
</a-tooltip>
<!-- 启动开关 -->
<!-- Toggle indicator -->
<a-tooltip :title="isIndicatorActive('custom-' + indicator.id) ? $t('dashboard.indicator.action.stop') : $t('dashboard.indicator.action.start')">
<a-icon
:type="isIndicatorActive('custom-' + indicator.id) ? 'pause-circle' : 'play-circle'"
@@ -195,7 +195,7 @@
@click.stop="toggleIndicator(indicator, 'custom')"
/>
</a-tooltip>
<!-- 回测按钮 -->
<!-- Open backtest -->
<a-tooltip :title="$t('dashboard.indicator.backtest.title')">
<a-icon
type="experiment"
@@ -203,7 +203,7 @@
@click.stop="handleOpenBacktest(indicator)"
/>
</a-tooltip>
<!-- 回测记录 -->
<!-- Open backtest history -->
<a-tooltip :title="$t('dashboard.indicator.backtest.historyTitle')">
<a-icon
type="clock-circle"
@@ -211,7 +211,7 @@
@click.stop="handleOpenBacktestHistory(indicator)"
/>
</a-tooltip>
<!-- 发布到社区 -->
<!-- Publish to community -->
<a-tooltip :title="indicator.publish_to_community ? $t('dashboard.indicator.action.unpublish') : $t('dashboard.indicator.action.publish')">
<a-icon
:type="indicator.publish_to_community ? 'cloud' : 'cloud-upload'"
@@ -219,7 +219,7 @@
@click.stop="handlePublishIndicator(indicator)"
/>
</a-tooltip>
<a-tooltip :title="tt('trading-assistant.createStrategy', 'Create Strategy')">
<a-tooltip :title="tt('dashboard.indicator.action.createStrategy', 'Create Strategy')">
<a-icon
type="rocket"
class="action-icon create-strategy-icon"
@@ -231,7 +231,7 @@
<span class="card-desc">{{ indicator.description || '' }}</span>
</div>
</div>
<!-- 空状态 -->
<!-- Empty state -->
<div v-if="customIndicators.length === 0" class="empty-indicators">
<a-icon type="info-circle" />
<span>{{ $t('dashboard.indicator.empty') }}</span>
@@ -297,7 +297,7 @@
<span class="card-desc">{{ indicator.description || '' }}</span>
</div>
</div>
<!-- 空状态 -->
<!-- Empty state -->
<div v-if="purchasedIndicators.length === 0" class="empty-indicators">
<a-icon type="shopping" />
<span>{{ $t('dashboard.indicator.emptyPurchased') }}</span>
@@ -321,7 +321,7 @@
<div class="card-header">
<span class="card-name">{{ indicator.name }}</span>
<div class="card-actions">
<!-- 编辑图标 -->
<!-- Edit indicator -->
<a-tooltip :title="$t('dashboard.indicator.action.edit')">
<a-icon
type="edit"
@@ -329,7 +329,7 @@
@click.stop="handleEditIndicator(indicator)"
/>
</a-tooltip>
<!-- 删除图标 -->
<!-- Delete indicator -->
<a-tooltip :title="$t('dashboard.indicator.action.delete')">
<a-icon
type="delete"
@@ -337,7 +337,7 @@
@click.stop="handleDeleteIndicator(indicator)"
/>
</a-tooltip>
<!-- 启动开关 -->
<!-- Toggle indicator -->
<a-tooltip :title="isIndicatorActive('custom-' + indicator.id) ? $t('dashboard.indicator.action.stop') : $t('dashboard.indicator.action.start')">
<a-icon
:type="isIndicatorActive('custom-' + indicator.id) ? 'pause-circle' : 'play-circle'"
@@ -345,7 +345,7 @@
@click.stop="toggleIndicator(indicator, 'custom')"
/>
</a-tooltip>
<!-- 回测按钮 -->
<!-- Open backtest -->
<a-tooltip :title="$t('dashboard.indicator.backtest.title')">
<a-icon
type="experiment"
@@ -353,7 +353,7 @@
@click.stop="handleOpenBacktest(indicator)"
/>
</a-tooltip>
<!-- 回测记录 -->
<!-- Open backtest history -->
<a-tooltip :title="$t('dashboard.indicator.backtest.historyTitle')">
<a-icon
type="clock-circle"
@@ -361,7 +361,7 @@
@click.stop="handleOpenBacktestHistory(indicator)"
/>
</a-tooltip>
<!-- 发布到社区 -->
<!-- Publish to community -->
<a-tooltip :title="indicator.publish_to_community ? $t('dashboard.indicator.action.unpublish') : $t('dashboard.indicator.action.publish')">
<a-icon
:type="indicator.publish_to_community ? 'cloud' : 'cloud-upload'"
@@ -369,7 +369,7 @@
@click.stop="handlePublishIndicator(indicator)"
/>
</a-tooltip>
<a-tooltip :title="tt('trading-assistant.createStrategy', 'Create Strategy')">
<a-tooltip :title="tt('dashboard.indicator.action.createStrategy', 'Create Strategy')">
<a-icon
type="rocket"
class="action-icon create-strategy-icon"
@@ -381,7 +381,7 @@
<span class="card-desc">{{ indicator.description || '' }}</span>
</div>
</div>
<!-- 空状态 -->
<!-- Empty state -->
<div v-if="customIndicators.length === 0" class="empty-indicators">
<a-icon type="info-circle" />
<span>{{ $t('dashboard.indicator.empty') }}</span>
@@ -785,17 +785,18 @@ export default {
// Map 'dark' or 'realdark' to 'dark', others to 'light'
return (this.navTheme === 'dark' || this.navTheme === 'realdark') ? 'dark' : 'light'
},
//
// Used for theme-specific wrapper classes.
isDarkTheme () {
return this.navTheme === 'dark' || this.navTheme === 'realdark'
}
},
setup () {
// 访 $t
// Access the component proxy so setup() can call $t dynamically.
const instance = getCurrentInstance()
const { proxy } = instance || {}
// userId=1/
// Local single-user mode keeps userId fixed at 1 so watchlist and indicators
// still load even when auth state is not initialized.
const userId = ref(1)
const loadingUserInfo = ref(false)
@@ -807,7 +808,7 @@ export default {
const symbolSearchValue = ref('') // Search input value
const symbolSearchOpen = ref(false) // Whether dropdown is open
//
// Add-symbol modal state.
const showAddStockModal = ref(false)
const addingStock = ref(false)
const selectedMarketTab = ref('') // Currently selected market type tab
@@ -842,6 +843,9 @@ export default {
const timeframe = ref('1D')
const activeIndicators = ref([])
const customIndicators = ref([])
const purchasedIndicators = ref([])
const loadingIndicators = ref(false)
const isMobile = ref(false)
// SMA and EMA moving average group definition (deprecated, kept for compatibility)
@@ -862,6 +866,7 @@ export default {
// Indicator parameter configuration modal
const pendingIndicator = ref(null) // Indicator pending to run
const pendingSource = ref('') // Source of pending indicator (custom/purchased)
const showParamsModal = ref(false)
const indicatorParams = ref([]) // Indicator parameter declaration
const indicatorParamValues = ref({}) // Parameter values set by user
const loadingParams = ref(false)
@@ -1398,18 +1403,18 @@ export default {
return activeIndicators.value.some(i => i.id === id)
}
//
// Return only user-defined active indicators, excluding built-ins.
const getCustomActiveIndicators = () => {
// idsmaGroupemaGroup
// Build the ID set for all built-in indicators, including SMA and EMA groups.
const defaultIndicatorIds = new Set()
smaGroup.forEach(ind => defaultIndicatorIds.add(ind.id))
emaGroup.forEach(ind => defaultIndicatorIds.add(ind.id))
//
// Remove built-in indicators from the active list.
return activeIndicators.value.filter(i => !defaultIndicatorIds.has(i.id))
}
//
// Load indicators from the backend.
const loadIndicators = async () => {
if (!userId.value) return
loadingIndicators.value = true
@@ -1423,9 +1428,9 @@ export default {
})
if (res.code === 1 && res.data) {
// is_buy=0
// Indicators created by the current user (is_buy = 0 or unset).
const customItems = res.data.filter(item => !item.is_buy || item.is_buy === 0 || item.is_buy === '0')
// is_buy=1
// Indicators purchased by the current user (is_buy = 1).
const purchasedItems = res.data.filter(item => item.is_buy === 1 || item.is_buy === '1')
customIndicators.value = customItems.map(item => ({
@@ -1461,13 +1466,13 @@ export default {
try {
const pythonCode = indicator.code || ''
//
// Ensure the chart component is ready.
if (!klineChart.value) {
message.error(proxy.$t('dashboard.indicator.error.chartNotReady'))
return
}
//
// Guard against missing chart methods.
if (typeof klineChart.value.parsePythonStrategy !== 'function') {
message.error(proxy.$t('dashboard.indicator.error.chartMethodNotReady'))
return
@@ -1521,9 +1526,9 @@ export default {
...pythonIndicator,
params: indicatorParamsFromParsed
})
// KlineChart watch activeIndicators
// KlineChart reacts to activeIndicators changes and refreshes the chart itself.
} catch (error) {
message.error(proxy.$t('dashboard.indicator.error.addIndicatorFailed') + ': ' + (error.message || '未知错误'))
message.error(proxy.$t('dashboard.indicator.error.addIndicatorFailed') + ': ' + (error.message || 'Unknown error'))
}
}
@@ -1574,7 +1579,7 @@ export default {
pendingSource.value = source
showParamsModal.value = true
} else {
//
// Run immediately when the indicator has no configurable params.
addPythonIndicator(indicator, source)
}
} catch (err) {
@@ -1594,7 +1599,7 @@ export default {
const indicatorKey = `${pendingSource.value}-${pendingIndicator.value.id}`
savedIndicatorParams.value[indicatorKey] = { ...indicatorParamValues.value }
//
// Pass the confirmed params into the indicator before activating it.
const indicatorWithParams = {
...pendingIndicator.value,
userParams: { ...indicatorParamValues.value }
@@ -1643,13 +1648,13 @@ export default {
// Create a temporary indicator object for running
try {
//
// Ensure the chart component is ready.
if (!klineChart.value) {
message.error(proxy.$t('dashboard.indicator.error.chartNotReady'))
return
}
//
// Guard against missing chart methods.
if (typeof klineChart.value.parsePythonStrategy !== 'function') {
message.error(proxy.$t('dashboard.indicator.error.chartMethodNotReady'))
return
@@ -1665,7 +1670,7 @@ export default {
return
}
// Python
// Create a temporary Python indicator definition.
const pythonIndicator = {
id: 'temp-editor-indicator',
name: name || 'Temporary Indicator',
@@ -1694,7 +1699,7 @@ export default {
params: { ...parsed.params }
})
// KlineChart watch activeIndicators
// KlineChart reacts to activeIndicators changes and refreshes the chart itself.
message.success(proxy.$t('dashboard.indicator.success.runIndicator'))
} catch (error) {
message.error(proxy.$t('dashboard.indicator.error.runIndicatorFailed') + ': ' + (error.message || 'Unknown Error'))
@@ -1905,7 +1910,7 @@ export default {
// Close modal
showIndicatorEditor.value = false
editingIndicator.value = null
//
// Refresh the indicator list after saving.
await loadIndicators()
} else {
message.error(res.msg || proxy.$t('dashboard.indicator.save.failed'))
@@ -1,6 +1,6 @@
<template>
<div class="comment-list">
<!-- 评论输入框可评论/可编辑时显示 -->
<!-- Comment input shown when commenting or editing is allowed -->
<div v-if="canComment || isEditing" class="comment-form">
<div class="form-header" v-if="isEditing">
<span class="edit-label">{{ $t('community.editComment') }}</span>
@@ -24,7 +24,7 @@
</div>
</div>
<!-- 已评论提示不能再次评论但可以编辑 -->
<!-- Commented hint: cannot comment again, but editing is allowed -->
<div v-else-if="myComment && !canComment && !isEditing" class="my-comment-hint">
<a-icon type="check-circle" theme="twoTone" two-tone-color="#52c41a" />
<span>{{ $t('community.alreadyCommented') }}</span>
@@ -33,7 +33,7 @@
</a-button>
</div>
<!-- 评论列表 -->
<!-- Comment list -->
<a-spin :spinning="loading">
<div v-if="comments.length === 0" class="empty-comments">
<a-empty :description="$t('community.noComments')" />
@@ -55,7 +55,7 @@
</span>
</div>
</div>
<!-- 编辑按钮只有自己的评论显示 -->
<!-- Edit button shown only for your own comments -->
<div v-if="comment.user && comment.user.id === currentUserId" class="comment-actions">
<a-button type="link" size="small" @click="startEdit(comment)">
<a-icon type="edit" />
@@ -67,7 +67,7 @@
</div>
</a-spin>
<!-- 加载更多 -->
<!-- Load more -->
<div v-if="hasMore" class="load-more">
<a-button type="link" @click="$emit('load-more')">
{{ $t('community.loadMore') }}
@@ -122,7 +122,7 @@ export default {
}
},
watch: {
// myComment
// Auto-fill the form when myComment is provided for edit mode
myComment: {
immediate: true,
handler (val) {
@@ -162,17 +162,17 @@ export default {
}
if (this.isEditing && this.editingCommentId) {
//
// Update comment
await this.$emit('update-comment', {
comment_id: this.editingCommentId,
...data
})
} else {
//
// Add comment
await this.$emit('add-comment', data)
}
//
// Reset form
this.cancelEdit()
} finally {
this.submitting = false
@@ -185,26 +185,26 @@ export default {
const now = new Date()
const diff = now - date
// 1
// Less than 1 minute
if (diff < 60000) {
return this.$t('community.justNow')
}
// 1
// Less than 1 hour
if (diff < 3600000) {
const mins = Math.floor(diff / 60000)
return `${mins} ${this.$t('community.minutesAgo')}`
}
// 24
// Less than 24 hours
if (diff < 86400000) {
const hours = Math.floor(diff / 3600000)
return `${hours} ${this.$t('community.hoursAgo')}`
}
// 30
// Less than 30 days
if (diff < 2592000000) {
const days = Math.floor(diff / 86400000)
return `${days} ${this.$t('community.daysAgo')}`
}
//
// Earlier
return date.toLocaleDateString()
}
}
@@ -354,7 +354,7 @@ export default {
}
}
//
// Dark theme
[data-theme='dark'] {
.comment-list {
.comment-form {
@@ -5,7 +5,7 @@
:body-style="{ padding: '12px' }"
@click="$emit('click', indicator)"
>
<!-- 预览图 / 默认生成封面 -->
<!-- Preview image / generated fallback cover -->
<div class="card-cover" :style="coverStyle">
<img
v-if="indicator.preview_image && !imageError"
@@ -13,7 +13,7 @@
:alt="indicator.name"
@error="handleImageError"
/>
<!-- 默认封面使用渐变背景 + 标题 -->
<!-- Fallback cover: gradient background + title -->
<div v-else class="default-cover" :style="{ background: coverGradient }">
<span class="cover-title">{{ indicatorInitials }}</span>
<span class="cover-subtitle">{{ indicator.name }}</span>
@@ -32,18 +32,18 @@
</div>
</div>
<!-- 内容 -->
<!-- Content -->
<div class="card-content">
<h3 class="card-title" :title="indicator.name">{{ indicator.name }}</h3>
<p class="card-desc">{{ indicator.description || $t('community.noDescription') }}</p>
<!-- 作者信息 -->
<!-- Author information -->
<div class="card-author">
<a-avatar :src="indicator.author.avatar" :size="24" />
<span class="author-name">{{ indicator.author.nickname || indicator.author.username }}</span>
</div>
<!-- 统计信息 -->
<!-- Statistics -->
<div class="card-stats">
<span class="stat-item">
<a-icon type="download" />
@@ -63,7 +63,7 @@
</template>
<script>
//
// Predefined gradient presets
const GRADIENT_PRESETS = [
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
@@ -96,19 +96,19 @@ export default {
isPaid () {
return this.indicator.pricing_type !== 'free' && this.indicator.price > 0
},
// ID
// Generate a fixed gradient from the indicator ID
coverGradient () {
const index = (this.indicator.id || 0) % GRADIENT_PRESETS.length
return GRADIENT_PRESETS[index]
},
//
// Generate indicator initials
indicatorInitials () {
const name = this.indicator.name || 'I'
//
// Use the first two characters for Chinese names
if (/[\u4e00-\u9fa5]/.test(name)) {
return name.slice(0, 2)
}
//
// Use uppercase initials for English names
const words = name.split(/\s+/)
if (words.length >= 2) {
return (words[0][0] + words[1][0]).toUpperCase()
@@ -170,7 +170,7 @@ export default {
position: relative;
overflow: hidden;
//
// Add decorative circles
&::before {
content: '';
position: absolute;
@@ -329,7 +329,7 @@ export default {
}
}
//
// Dark theme adaptation
.theme-dark .indicator-card,
.dark-theme .indicator-card,
[data-theme='dark'] .indicator-card {
@@ -10,7 +10,7 @@
>
<a-spin :spinning="loading">
<div v-if="detail" class="detail-container">
<!-- 头部区域 -->
<!-- Header section -->
<div class="detail-header" :style="headerStyle">
<div class="header-cover" v-if="detail.preview_image">
<img :src="detail.preview_image" :alt="detail.name" @error="imageError = true" />
@@ -50,15 +50,15 @@
</div>
</div>
<!-- 内容区域 -->
<!-- Content section -->
<div class="detail-body">
<!-- 描述 -->
<!-- Description -->
<div class="section">
<h3>{{ $t('community.description') }}</h3>
<p class="description">{{ detail.description || $t('community.noDescription') }}</p>
</div>
<!-- 实盘表现 -->
<!-- Live performance -->
<div class="section" v-if="performance">
<h3>{{ $t('community.performance') }}</h3>
<div class="performance-grid">
@@ -85,7 +85,7 @@
</div>
</div>
<!-- 评论区域 -->
<!-- Comment section -->
<div class="section">
<h3>{{ $t('community.reviews') }} ({{ comments.total || 0 }})</h3>
<comment-list
@@ -102,7 +102,7 @@
</div>
</div>
<!-- 底部操作区域 -->
<!-- Bottom action section -->
<div class="detail-footer">
<div class="price-info">
<a-tag v-if="detail.vip_free" color="gold" style="margin-right: 8px;">
@@ -178,10 +178,10 @@ export default {
}
},
computed: {
//
// Header background styles
headerStyle () {
if (!this.detail) return {}
// ID
// Generate a gradient based on the indicator ID
const gradients = [
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
@@ -193,7 +193,7 @@ export default {
const index = (this.detail.id || 0) % gradients.length
return { background: gradients[index] }
},
//
// Indicator initials
indicatorInitials () {
if (!this.detail) return ''
const name = this.detail.name || 'I'
@@ -316,7 +316,7 @@ export default {
this.$message.success(this.$t('community.commentSuccess'))
this.loadComments(1)
this.loadMyComment()
//
// Refresh the details to update the rating
this.loadDetail()
} else {
const msgKey = `community.${res.msg}`
@@ -341,7 +341,7 @@ export default {
this.$message.success(this.$t('community.commentUpdateSuccess'))
this.loadComments(1)
this.loadMyComment()
//
// Refresh the details to update the rating
this.loadDetail()
} else {
const msgKey = `community.${res.msg}`
@@ -576,7 +576,7 @@ export default {
}
}
//
// Dark theme
[data-theme='dark'] {
.indicator-detail-modal {
.detail-body {
@@ -1,9 +1,9 @@
<template>
<div class="indicator-community-container" :class="{ 'theme-dark': isDarkTheme }">
<!-- 管理员标签切换 -->
<!-- Admin: tab switch -->
<a-tabs v-if="isAdmin" v-model="activeTab" class="admin-tabs" @change="handleTabChange">
<a-tab-pane key="market" :tab="$t('community.title')">
<!-- 市场内容在下方 -->
<!-- Market content below -->
</a-tab-pane>
<a-tab-pane key="review">
<template slot="tab">
@@ -14,7 +14,7 @@
</a-tab-pane>
</a-tabs>
<!-- 顶部工具栏市场模式 -->
<!-- Top toolbar (market mode) -->
<div v-show="activeTab === 'market'" class="market-header">
<div class="header-left">
<h2 class="page-title">
@@ -23,7 +23,7 @@
</h2>
</div>
<div class="header-right">
<!-- 搜索 -->
<!-- Search -->
<a-input-search
v-model="filters.keyword"
:placeholder="$t('community.searchPlaceholder')"
@@ -32,13 +32,13 @@
@search="handleSearch"
@pressEnter="handleSearch"
/>
<!-- 价格筛选 -->
<!-- Price filter -->
<a-radio-group v-model="filters.pricingType" button-style="solid" @change="handleFilterChange">
<a-radio-button value="">{{ $t('community.all') }}</a-radio-button>
<a-radio-button value="free">{{ $t('community.freeOnly') }}</a-radio-button>
<a-radio-button value="paid">{{ $t('community.paidOnly') }}</a-radio-button>
</a-radio-group>
<!-- 排序 -->
<!-- Sort -->
<a-select v-model="filters.sortBy" style="width: 140px" @change="handleFilterChange">
<a-select-option value="newest">{{ $t('community.sortNewest') }}</a-select-option>
<a-select-option value="hot">{{ $t('community.sortHot') }}</a-select-option>
@@ -46,7 +46,7 @@
<a-select-option value="price_asc">{{ $t('community.sortPriceLow') }}</a-select-option>
<a-select-option value="price_desc">{{ $t('community.sortPriceHigh') }}</a-select-option>
</a-select>
<!-- 我的购买 -->
<!-- My purchases -->
<a-button type="link" @click="showMyPurchases = true">
<a-icon type="shopping" />
{{ $t('community.myPurchases') }}
@@ -54,7 +54,7 @@
</div>
</div>
<!-- 指标网格市场模式 -->
<!-- Indicator grid (market mode) -->
<template v-if="activeTab === 'market'">
<a-spin :spinning="loading">
<div v-if="indicators.length === 0 && !loading" class="empty-state">
@@ -74,7 +74,7 @@
</div>
</a-spin>
<!-- 分页 -->
<!-- Pagination -->
<div v-if="pagination.total > 0" class="pagination-wrapper">
<a-pagination
v-model="pagination.current"
@@ -87,10 +87,10 @@
</div>
</template>
<!-- 管理员审核区域 -->
<!-- Admin review area -->
<template v-if="activeTab === 'review' && isAdmin">
<div class="review-panel">
<!-- 审核状态筛选 -->
<!-- Review status filter -->
<div class="review-header">
<a-radio-group v-model="reviewFilter" button-style="solid" @change="loadPendingIndicators">
<a-radio-button value="pending">
@@ -107,7 +107,7 @@
</a-radio-group>
</div>
<!-- 审核列表 -->
<!-- Review list -->
<a-spin :spinning="reviewLoading">
<div v-if="pendingIndicators.length === 0 && !reviewLoading" class="empty-state">
<a-empty :description="$t('community.admin.noItems')" />
@@ -174,7 +174,7 @@
</div>
</a-spin>
<!-- 审核分页 -->
<!-- Review pagination -->
<div v-if="reviewPagination.total > 0" class="pagination-wrapper">
<a-pagination
v-model="reviewPagination.current"
@@ -187,7 +187,7 @@
</div>
</template>
<!-- 审核弹窗 -->
<!-- Review modal -->
<a-modal
v-model="showReviewModal"
:title="reviewAction === 'approve' ? $t('community.admin.approveTitle') : $t('community.admin.rejectTitle')"
@@ -206,7 +206,7 @@
</a-form>
</a-modal>
<!-- 详情弹窗 -->
<!-- Details modal -->
<indicator-detail
:visible="detailVisible"
:indicator-id="selectedIndicatorId"
@@ -215,7 +215,7 @@
@purchased="handlePurchased"
/>
<!-- 我的购买弹窗 -->
<!-- My purchases modal -->
<a-modal
v-model="showMyPurchases"
:title="$t('community.myPurchases')"
@@ -295,7 +295,7 @@ export default {
showMyPurchases: false,
purchasesLoading: false,
myPurchases: [],
//
// Admin review state
activeTab: 'market',
reviewFilter: 'pending',
reviewLoading: false,
@@ -311,7 +311,7 @@ export default {
rejected: 0
},
expandedCodes: {},
//
// Review modal
showReviewModal: false,
reviewAction: 'approve',
reviewNote: '',
@@ -410,7 +410,7 @@ export default {
},
handlePurchased () {
//
// Refresh the list
this.loadIndicators()
},
@@ -428,7 +428,7 @@ export default {
return new Date(dateStr).toLocaleString()
},
// ==================== ====================
// ==================== Admin review methods ====================
handleTabChange (tab) {
if (tab === 'review') {
@@ -649,7 +649,7 @@ export default {
padding: 40px 0;
}
//
// Admin tabs
.admin-tabs {
margin-bottom: 16px;
padding: 0 20px;
@@ -657,7 +657,7 @@ export default {
border-radius: 8px;
}
//
// Review area
.review-panel {
.review-header {
margin-bottom: 20px;
@@ -762,7 +762,7 @@ export default {
}
}
//
// Dark theme
.indicator-community-container.theme-dark {
background: #141414;
@@ -833,7 +833,7 @@ export default {
background: #1f1f1f;
}
// 穿 - IndicatorCard
// Reach into child component styles - IndicatorCard
/deep/ .indicator-card {
background: #1f1f1f;
border-color: #303030;
@@ -857,7 +857,7 @@ export default {
}
}
//
// Fix dark theme styles for the search box, dropdowns, and similar components
/deep/ .ant-input {
background: #262626;
border-color: #434343;
@@ -949,7 +949,7 @@ export default {
}
}
//
// My purchases modal
/deep/ .ant-modal-content {
background: #1f1f1f;
@@ -980,7 +980,7 @@ export default {
}
}
//
// Responsive
@media (max-width: 768px) {
.indicator-community-container {
padding: 12px;
+98 -98
View File
@@ -1,6 +1,6 @@
<template>
<div class="portfolio-container" :class="{ 'theme-dark': isDarkTheme, embedded: embedded }">
<!-- 资产总览 - 第一行核心数据 -->
<!-- Asset overview - first row: core metrics -->
<div class="summary-section">
<div class="summary-card total-value">
<div class="card-icon">
@@ -62,7 +62,7 @@
</div>
</div>
<!-- 资产总览 - 第二行详细统计 -->
<!-- Asset overview - second row: detailed statistics -->
<div class="summary-section secondary">
<div class="summary-card mini">
<div class="card-icon today" :class="summary.today_pnl >= 0 ? 'profit' : 'loss'">
@@ -121,9 +121,9 @@
</div>
</div>
<!-- 主内容区域 -->
<!-- Main content area -->
<div class="main-content">
<!-- 持仓列表 -->
<!-- Position list -->
<div class="positions-section">
<div class="section-header">
<h3>
@@ -131,7 +131,7 @@
<span>{{ $t('portfolio.positions.title') }}</span>
</h3>
<div class="header-actions">
<!-- 视图切换 -->
<!-- View switch -->
<a-radio-group v-model="viewMode" size="small" style="margin-right: 12px;">
<a-radio-button value="grid">
<a-icon type="appstore" />
@@ -140,7 +140,7 @@
<a-icon type="folder" />
</a-radio-button>
</a-radio-group>
<!-- 分组筛选仅网格视图时显示 -->
<!-- Group filter, shown only in grid view -->
<a-select
v-if="viewMode === 'grid'"
v-model="selectedGroup"
@@ -173,7 +173,7 @@
</a-empty>
</div>
<!-- 网格视图 -->
<!-- Grid view -->
<div v-else-if="viewMode === 'grid'" class="position-grid">
<div
v-for="pos in filteredPositions"
@@ -260,10 +260,10 @@
</div>
</div>
<!-- 分组折叠视图 -->
<!-- Grouped collapse view -->
<div v-else class="position-collapse-view">
<a-collapse v-model="activeGroups" :bordered="false">
<!-- 未分组 -->
<!-- Ungrouped -->
<a-collapse-panel v-if="ungroupedPositions.length > 0" key="__ungrouped__" class="group-panel">
<template slot="header">
<div class="group-header">
@@ -321,7 +321,7 @@
</div>
</a-collapse-panel>
<!-- 各分组 -->
<!-- Each group -->
<a-collapse-panel v-for="group in groupsWithPositions" :key="group.name" class="group-panel">
<template slot="header">
<div class="group-header">
@@ -384,7 +384,7 @@
</a-spin>
</div>
<!-- 监控任务 -->
<!-- Monitor tasks -->
<div class="monitors-section" ref="monitorsSection">
<div class="section-header">
<h3>
@@ -483,7 +483,7 @@
</div>
</div>
<!-- 添加/编辑持仓弹窗 -->
<!-- Add/Edit position modal -->
<a-modal
:title="editingPosition ? $t('portfolio.modal.editPosition') : $t('portfolio.modal.addPosition')"
:visible="showAddPositionModal"
@@ -493,7 +493,7 @@
width="600px"
>
<a-form :form="positionForm" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<!-- 市场类型 -->
<!-- Market type -->
<a-form-item :label="$t('portfolio.form.market')">
<a-select
v-decorator="['market', { rules: [{ required: true, message: $t('portfolio.form.marketRequired') }] }]"
@@ -507,7 +507,7 @@
</a-select>
</a-form-item>
<!-- 标的搜索/选择 -->
<!-- Instrument search / selection -->
<a-form-item :label="$t('portfolio.form.symbol')">
<a-select
v-decorator="['symbol', { rules: [{ required: true, message: $t('portfolio.form.symbolRequired') }] }]"
@@ -522,14 +522,14 @@
:disabled="!!editingPosition"
style="width: 100%"
>
<!-- 搜索结果选项 -->
<!-- Search result options -->
<a-select-option v-for="item in symbolSearchResults" :key="item.symbol" :value="item.symbol">
<div class="symbol-option">
<strong>{{ item.symbol }}</strong>
<span class="symbol-name">{{ item.name }}</span>
</div>
</a-select-option>
<!-- 手动输入选项当搜索无结果且有输入时显示 -->
<!-- Manual input option shown when search has no results and there is input -->
<a-select-option
v-if="symbolSearchKeyword && symbolSearchResults.length === 0"
:key="'__manual__' + symbolSearchKeyword.toUpperCase()"
@@ -548,7 +548,7 @@
</div>
</a-form-item>
<!-- 方向 -->
<!-- Direction -->
<a-form-item :label="$t('portfolio.form.side')">
<a-radio-group v-decorator="['side', { initialValue: 'long' }]" :disabled="!!editingPosition">
<a-radio-button value="long">{{ $t('portfolio.positions.long') }}</a-radio-button>
@@ -556,7 +556,7 @@
</a-radio-group>
</a-form-item>
<!-- 数量 -->
<!-- Quantity -->
<a-form-item :label="$t('portfolio.form.quantity')">
<a-input-number
v-decorator="['quantity', { rules: [{ required: true, message: $t('portfolio.form.quantityRequired') }] }]"
@@ -567,7 +567,7 @@
/>
</a-form-item>
<!-- 买入价 -->
<!-- Entry price -->
<a-form-item :label="$t('portfolio.form.entryPrice')">
<a-input-number
v-decorator="['entry_price', { rules: [{ required: true, message: $t('portfolio.form.entryPriceRequired') }] }]"
@@ -578,7 +578,7 @@
/>
</a-form-item>
<!-- 备注 -->
<!-- Notes -->
<a-form-item :label="$t('portfolio.form.notes')">
<a-textarea
v-decorator="['notes']"
@@ -587,7 +587,7 @@
/>
</a-form-item>
<!-- 分组 -->
<!-- Group -->
<a-form-item :label="$t('portfolio.form.group')">
<a-auto-complete
v-decorator="['group_name']"
@@ -598,14 +598,14 @@
</a-form>
</a-modal>
<!-- 添加/编辑预警弹窗 -->
<!-- Add/Edit alert modal -->
<a-modal
:title="editingAlert ? $t('portfolio.modal.editAlert') : $t('portfolio.modal.addAlert')"
:visible="showAddAlertModal"
@cancel="closeAlertModal"
width="560px"
>
<!-- 自定义 Footer -->
<!-- Custom footer -->
<template slot="footer">
<div class="alert-modal-footer">
<a-button
@@ -628,7 +628,7 @@
</div>
</template>
<a-form :form="alertForm" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<!-- 标的信息 -->
<!-- Instrument info -->
<a-form-item :label="$t('portfolio.form.symbol')">
<div class="alert-symbol-info">
<a-input
@@ -643,7 +643,7 @@
</div>
</a-form-item>
<!-- 预警类型 -->
<!-- Alert type -->
<a-form-item :label="$t('portfolio.alerts.alertType')">
<a-select
v-decorator="['alert_type', { initialValue: 'price_above', rules: [{ required: true }] }]"
@@ -667,7 +667,7 @@
</a-select>
</a-form-item>
<!-- 阈值 -->
<!-- Threshold -->
<a-form-item :label="$t('portfolio.alerts.threshold')">
<div class="threshold-input-wrapper">
<a-input-number
@@ -685,7 +685,7 @@
</div>
</a-form-item>
<!-- 重复提醒 -->
<!-- Repeat reminder -->
<a-form-item :label="$t('portfolio.alerts.repeatInterval')">
<a-select
v-decorator="['repeat_interval', { initialValue: 0 }]"
@@ -700,7 +700,7 @@
</a-select>
</a-form-item>
<!-- 通知渠道 - 使用 v-model 直接绑定 -->
<!-- Notification channels bound directly with v-model -->
<a-form-item :label="$t('portfolio.form.notifyChannels')">
<a-checkbox-group v-model="alertChannels">
<a-checkbox value="browser">
@@ -735,7 +735,7 @@
</template>
</a-alert>
<!-- 启用状态 -->
<!-- Enabled state -->
<a-form-item :label="$t('portfolio.alerts.enabled')">
<a-switch
v-decorator="['is_active', { initialValue: true, valuePropName: 'checked' }]"
@@ -743,7 +743,7 @@
<span class="switch-label">{{ $t('portfolio.alerts.enabledDesc') }}</span>
</a-form-item>
<!-- 备注 -->
<!-- Notes -->
<a-form-item :label="$t('portfolio.form.notes')">
<a-textarea
v-decorator="['notes']"
@@ -754,7 +754,7 @@
</a-form>
</a-modal>
<!-- 添加/编辑监控弹窗 -->
<!-- Add/Edit monitor modal -->
<a-modal
:title="editingMonitor ? $t('portfolio.modal.editMonitor') : $t('portfolio.modal.addMonitor')"
:visible="showAddMonitorModal"
@@ -764,7 +764,7 @@
width="600px"
>
<a-form :form="monitorForm" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<!-- 监控名称 -->
<!-- Monitor name -->
<a-form-item :label="$t('portfolio.form.monitorName')">
<a-input
v-decorator="['name', { rules: [{ required: true, message: $t('portfolio.form.monitorNameRequired') }] }]"
@@ -772,7 +772,7 @@
/>
</a-form-item>
<!-- 执行间隔 -->
<!-- Execution interval -->
<a-form-item :label="$t('portfolio.form.interval')">
<a-select
v-decorator="['interval_minutes', { initialValue: 60, rules: [{ required: true }] }]"
@@ -788,7 +788,7 @@
</a-select>
</a-form-item>
<!-- 通知渠道 - 使用 v-model 直接绑定 -->
<!-- Notification channels bound directly with v-model -->
<a-form-item :label="$t('portfolio.form.notifyChannels')">
<a-checkbox-group v-model="monitorChannels">
<a-checkbox value="browser">{{ $t('portfolio.form.browser') }}</a-checkbox>
@@ -814,7 +814,7 @@
</template>
</a-alert>
<!-- 监控范围 -->
<!-- Monitor scope -->
<a-form-item :label="$t('portfolio.form.monitorScope')">
<a-radio-group v-model="monitorScope" @change="handleMonitorScopeChange">
<a-radio value="all">{{ $t('portfolio.form.allPositions') }}</a-radio>
@@ -822,7 +822,7 @@
</a-radio-group>
</a-form-item>
<!-- 选择持仓 -->
<!-- Select positions -->
<a-form-item
:label="$t('portfolio.form.selectPositions')"
v-if="monitorScope === 'selected'"
@@ -868,7 +868,7 @@
</div>
</a-form-item>
<!-- 自定义提示 -->
<!-- Custom prompt -->
<a-form-item :label="$t('portfolio.form.customPrompt')">
<a-textarea
v-decorator="['prompt']"
@@ -937,17 +937,17 @@ export default {
symbolSearchResults: [],
searchTimer: null,
selectedSymbolName: '',
symbolSearchKeyword: '', //
symbolSearchKeyword: '', // Current search keyword used for manual input
// Price refresh
priceRefreshTimer: null,
lastSyncTime: null, //
isSyncing: false, //
lastSyncTime: null, // Last sync time
isSyncing: false, // Whether syncing is in progress
// Groups
groups: [],
selectedGroup: '',
// View mode
viewMode: 'grid', // 'grid' or 'group'
activeGroups: [], //
activeGroups: [], // Expanded groups in the collapse panel
// Alerts
alerts: [],
loadingAlerts: false,
@@ -1008,13 +1008,13 @@ export default {
const alertType = this.alertForm.getFieldValue('alert_type')
return alertType && alertType.startsWith('price_')
},
// /
// Profitable / losing position stats
profitLossStats () {
const profit = this.positions.filter(p => p.pnl >= 0).length
const loss = this.positions.filter(p => p.pnl < 0).length
return { profit, loss }
},
//
// Best-performing position
bestPerformer () {
if (this.positions.length === 0) return null
return this.positions.reduce((best, pos) => {
@@ -1022,7 +1022,7 @@ export default {
return best
}, null)
},
//
// Worst-performing position
worstPerformer () {
if (this.positions.length === 0) return null
return this.positions.reduce((worst, pos) => {
@@ -1030,11 +1030,11 @@ export default {
return worst
}, null)
},
//
// Ungrouped positions
ungroupedPositions () {
return this.positions.filter(p => !p.group_name)
},
//
// Positions grouped by section
groupsWithPositions () {
const groupMap = {}
this.positions.forEach(pos => {
@@ -1108,21 +1108,21 @@ export default {
filterByGroup () {
// Filter is handled by computed property
},
//
// Refresh prices, forcing a cache bypass
async refreshPrices () {
if (this.isSyncing) return
this.isSyncing = true
try {
await Promise.all([
this.loadPositions(true), //
this.loadSummary(true) //
this.loadPositions(true), // Force refresh
this.loadSummary(true) // Force refresh
])
this.lastSyncTime = new Date()
} finally {
this.isSyncing = false
}
},
//
// Format sync time
formatSyncTime (time) {
if (!time) return '-'
const now = new Date()
@@ -1131,7 +1131,7 @@ export default {
if (diff < 3600) return `${Math.floor(diff / 60)} ${this.$t('portfolio.form.minutes')}${this.$t('portfolio.summary.ago')}`
return time.toLocaleTimeString()
},
//
// Calculate group PnL totals
getGroupPnl (positions) {
return positions.reduce((sum, pos) => sum + (pos.pnl || 0), 0)
},
@@ -1220,7 +1220,7 @@ export default {
this.positionForm.setFieldsValue({ symbol: '' })
},
handleSymbolSearch (value) {
//
// Save the search keyword for manual input
this.symbolSearchKeyword = value || ''
if (this.searchTimer) {
@@ -1238,25 +1238,25 @@ export default {
if (res && res.code === 1) {
this.symbolSearchResults = res.data || []
} else {
//
// If search returns no results, clear the list but keep the keyword for manual input
this.symbolSearchResults = []
}
} catch (e) {
//
// Allow manual input even when search fails
this.symbolSearchResults = []
}
}, 300)
},
handleSymbolSelect (value, option) {
//
// Check the search results first
const item = this.symbolSearchResults.find(s => s.symbol === value)
if (item) {
this.selectedSymbolName = item.name
} else {
//
// For manual input, leave the name empty and let the backend try to resolve it
this.selectedSymbolName = ''
}
//
// Clear the search keyword
this.symbolSearchKeyword = ''
},
editPosition (pos) {
@@ -1337,14 +1337,14 @@ export default {
this.alertChannels = channels || []
},
showAddAlertForPosition (pos) {
// Alert
// Check whether an alert already exists for this position and edit it if it does
const existingAlert = this.alerts.find(a => a.position_id === pos.id)
if (existingAlert) {
// Alert
// Edit the existing alert
this.editAlert(existingAlert)
return
}
// Alert - 使
// Create a new alert using the user default notification settings
this.editingAlert = null
this.alertPosition = pos
this.alertChannels = [...(this.userNotificationSettings.default_channels || ['browser'])]
@@ -1362,7 +1362,7 @@ export default {
},
editAlert (alert) {
this.editingAlert = alert
//
// Find the matching position
this.alertPosition = this.positions.find(p => p.id === alert.position_id) || {
market: alert.market,
symbol: alert.symbol,
@@ -1392,7 +1392,7 @@ export default {
if (err) return
this.savingAlert = true
try {
// - 使
// Build notification targets using the values configured in the profile center
const targets = {}
if (this.alertChannels.includes('telegram') && this.userNotificationSettings.telegram_chat_id) {
targets.telegram = this.userNotificationSettings.telegram_chat_id
@@ -1423,10 +1423,10 @@ export default {
alert_type: values.alert_type,
threshold: values.threshold,
notification_config: {
// 使 v-model
// Use the value bound by v-model
channels: this.alertChannels.length > 0 ? this.alertChannels : ['browser'],
targets: targets,
language: this.$store.getters.lang || 'en-US' //
language: this.$store.getters.lang || 'en-US' // Save the current language
},
is_active: values.is_active !== false,
repeat_interval: values.repeat_interval || 0,
@@ -1648,11 +1648,11 @@ export default {
async runMonitorNow (id) {
this.runningMonitor = id
try {
// 使
// Pass the current language to the backend and use async mode
const currentLang = this.$store.getters.lang || 'en-US'
const res = await runMonitor(id, { language: currentLang, async: true })
if (res && res.code === 1) {
//
// Async mode: the backend returns immediately and runs in the background
if (res.data?.status === 'running') {
this.$message.success(this.$t('portfolio.message.monitorRunning'))
this.$notification.info({
@@ -1661,7 +1661,7 @@ export default {
duration: 5
})
} else if (res.data?.success) {
//
// Sync mode returns the result directly for backward compatibility
this.$message.success(this.$t('portfolio.message.monitorRunSuccess'))
if (res.data.analysis) {
this.$notification.open({
@@ -1764,21 +1764,21 @@ export default {
formatTime (timestamp) {
if (!timestamp) return '-'
let d
// 1000
// If the value is numeric, treat it as seconds and multiply by 1000
if (typeof timestamp === 'number') {
d = new Date(timestamp * 1000)
} else if (typeof timestamp === 'string') {
//
// If it is a pure numeric string, treat it as a seconds timestamp
if (/^\d+$/.test(timestamp)) {
d = new Date(parseInt(timestamp, 10) * 1000)
} else {
// ISO
// Parse ISO date strings or other date formats directly
d = new Date(timestamp)
}
} else {
return '-'
}
//
// Check whether the date is valid
if (isNaN(d.getTime())) {
return '-'
}
@@ -1818,7 +1818,7 @@ export default {
.card-value { color: #d1d4dc; }
.card-sub { color: #868993; }
//
// Total value card in dark mode
&.total-value {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
border: 1px solid rgba(59, 130, 246, 0.3);
@@ -1887,7 +1887,7 @@ export default {
}
}
//
// Compact card dark mode
&.compact {
.position-compact-body {
.compact-item {
@@ -1916,7 +1916,7 @@ export default {
}
}
//
// Collapse view dark mode
.position-collapse-view {
::v-deep .ant-collapse {
.ant-collapse-item {
@@ -1974,7 +1974,7 @@ export default {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
// -
// Total value card - vivid gradient style
&.total-value {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a855f7 100%);
border: none;
@@ -2043,7 +2043,7 @@ export default {
}
}
//
// Mini card
&.mini {
padding: 14px 16px;
@@ -2062,7 +2062,7 @@ export default {
}
}
//
// Sync status card
&.sync-card {
.card-sub {
display: flex;
@@ -2406,7 +2406,7 @@ export default {
}
}
//
// Collapse view styles
.position-collapse-view {
::v-deep .ant-collapse {
background: transparent;
@@ -2477,7 +2477,7 @@ export default {
}
}
//
// Compact position cards
.position-grid.compact {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
@@ -2541,7 +2541,7 @@ export default {
}
}
//
// Alert modal styles
.alert-symbol-info {
.symbol-input {
margin-bottom: 8px;
@@ -2594,7 +2594,7 @@ export default {
font-size: 13px;
}
//
// Alert modal footer buttons
.alert-modal-footer {
display: flex;
justify-content: space-between;
@@ -2606,7 +2606,7 @@ export default {
}
}
//
// Position selector styles
.position-checkbox-group {
display: flex;
flex-direction: column;
@@ -2716,7 +2716,7 @@ export default {
}
}
//
// Alert modal under the dark theme
&.theme-dark {
.position-checkbox-group {
background: #2a2e39;
@@ -2759,9 +2759,9 @@ export default {
}
}
// ==================== ====================
// ==================== Mobile responsive adaptation ====================
// (768px - 1024px)
// Tablet (768px - 1024px)
@media screen and (max-width: 1024px) {
.portfolio-container {
padding: 16px;
@@ -2791,13 +2791,13 @@ export default {
}
}
// (< 768px)
// Mobile (< 768px)
@media screen and (max-width: 768px) {
.portfolio-container {
padding: 12px;
}
// -
// Summary cards - mobile
.summary-section {
grid-template-columns: 1fr 1fr;
gap: 10px;
@@ -2813,7 +2813,7 @@ export default {
border-radius: 10px;
gap: 10px;
//
// Make the total value card span two columns on mobile
&.total-value {
grid-column: span 2;
padding: 16px;
@@ -2881,13 +2881,13 @@ export default {
}
}
//
// Main content area
.main-content {
grid-template-columns: 1fr;
gap: 12px;
}
//
// Positions area
.positions-section {
padding: 12px;
border-radius: 10px;
@@ -2923,7 +2923,7 @@ export default {
}
}
// -
// Position grid - single column on mobile
.position-grid {
grid-template-columns: 1fr;
gap: 10px;
@@ -2933,7 +2933,7 @@ export default {
}
}
// -
// Position cards - mobile optimization
.position-card {
border-radius: 10px;
@@ -2993,7 +2993,7 @@ export default {
}
}
//
// Compact cards on mobile
&.compact {
.position-compact-body {
flex-direction: column;
@@ -3013,7 +3013,7 @@ export default {
}
}
// -
// Monitor area - mobile
.monitors-section {
padding: 12px;
border-radius: 10px;
@@ -3059,7 +3059,7 @@ export default {
}
}
//
// Collapse view on mobile
.position-collapse-view {
::v-deep .ant-collapse {
.ant-collapse-item {
@@ -3090,7 +3090,7 @@ export default {
}
}
//
// Empty state
.empty-state {
padding: 30px 16px;
@@ -3100,7 +3100,7 @@ export default {
}
}
// (< 480px)
// Very small screens (< 480px)
@media screen and (max-width: 480px) {
.portfolio-container {
padding: 8px;
@@ -3110,7 +3110,7 @@ export default {
gap: 8px;
&.secondary {
//
// Make the second row horizontally scrollable on very small screens
display: flex;
overflow-x: auto;
padding-bottom: 8px;
+83 -83
View File
@@ -49,76 +49,76 @@
<!-- Right Column: Credits and Referral Cards -->
<a-col :xs="24" :md="16" class="right-cards-col">
<a-row :gutter="16" class="right-cards-row">
<!-- Credits Card (积分卡片) -->
<!-- Credits Card -->
<a-col :xs="24" :md="12">
<a-card :bordered="false" class="credits-card">
<div class="credits-header">
<h3 class="credits-title">
<a-icon type="wallet" />
{{ $t('profile.credits.title') || '我的积分' }}
{{ $t('profile.credits.title') || 'My Credits' }}
</h3>
</div>
<div class="credits-body">
<div class="credits-amount">
<span class="amount-value">{{ formatCredits(billing.credits) }}</span>
<span class="amount-label">{{ $t('profile.credits.unit') || '积分' }}</span>
<span class="amount-label">{{ $t('profile.credits.unit') || 'Credits' }}</span>
</div>
<div class="vip-status" v-if="billing.vip_expires_at">
<a-icon type="crown" :style="{ color: isVip ? '#faad14' : '#999' }" />
<span v-if="isVip" class="vip-active">
{{ $t('profile.credits.vipExpires') || 'VIP有效期至' }}: {{ formatDate(billing.vip_expires_at) }}
{{ $t('profile.credits.vipExpires') || 'VIP valid until' }}: {{ formatDate(billing.vip_expires_at) }}
</span>
<span v-else class="vip-expired">
{{ $t('profile.credits.vipExpired') || 'VIP已过期' }}
{{ $t('profile.credits.vipExpired') || 'VIP expired' }}
</span>
</div>
<div class="vip-status" v-else-if="!billing.is_vip">
<span class="no-vip">{{ $t('profile.credits.noVip') || '非VIP用户' }}</span>
<span class="no-vip">{{ $t('profile.credits.noVip') || 'Non-VIP user' }}</span>
</div>
</div>
<a-divider />
<div class="credits-actions">
<a-button type="primary" icon="shopping" @click="handleRecharge">
{{ $t('profile.credits.recharge') || '开通/充值' }}
{{ $t('profile.credits.recharge') || 'Activate / Recharge' }}
</a-button>
</div>
<div class="credits-hint" v-if="billing.billing_enabled">
<a-icon type="info-circle" />
<span>{{ $t('profile.credits.hint') || '使用AI分析/回测/监控等功能会消耗积分;VIP仅可免费使用VIP免费指标。' }}</span>
<span>{{ $t('profile.credits.hint') || 'Using AI analysis, backtesting, monitoring, and similar features consumes credits; VIP users can only use VIP-free indicators for free.' }}</span>
</div>
</a-card>
</a-col>
<!-- Referral Card (邀请卡片) -->
<!-- Referral Card -->
<a-col :xs="24" :md="12">
<a-card :bordered="false" class="referral-card">
<div class="referral-header">
<h3 class="referral-title">
<a-icon type="team" />
{{ $t('profile.referral.title') || '邀请好友' }}
{{ $t('profile.referral.title') || 'Invite Friends' }}
</h3>
</div>
<div class="referral-body">
<div class="referral-stats">
<div class="stat-item">
<span class="stat-value">{{ referralData.total || 0 }}</span>
<span class="stat-label">{{ $t('profile.referral.totalInvited') || '已邀请' }}</span>
<span class="stat-label">{{ $t('profile.referral.totalInvited') || 'Invited' }}</span>
</div>
<div class="stat-item" v-if="referralData.referral_bonus > 0">
<span class="stat-value">+{{ referralData.referral_bonus }}</span>
<span class="stat-label">{{ $t('profile.referral.bonusPerInvite') || '每邀请获得' }}</span>
<span class="stat-label">{{ $t('profile.referral.bonusPerInvite') || 'Per Invite' }}</span>
</div>
</div>
<a-divider style="margin: 12px 0" />
<div class="referral-link-section">
<div class="link-label">{{ $t('profile.referral.yourLink') || '您的邀请链接' }}</div>
<div class="link-label">{{ $t('profile.referral.yourLink') || 'Your Referral Link' }}</div>
<div class="link-box">
<a-input
:value="referralLink"
readonly
size="small"
>
<a-tooltip slot="suffix" :title="$t('profile.referral.copyLink') || '复制链接'">
<a-tooltip slot="suffix" :title="$t('profile.referral.copyLink') || 'Copy Link'">
<a-icon type="copy" style="cursor: pointer" @click="copyReferralLink" />
</a-tooltip>
</a-input>
@@ -126,7 +126,7 @@
</div>
<div class="referral-hint" v-if="referralData.register_bonus > 0">
<a-icon type="gift" />
<span>{{ $t('profile.referral.newUserBonus') || '新用户注册获得' }} {{ referralData.register_bonus }} {{ $t('profile.credits.unit') || '积分' }}</span>
<span>{{ $t('profile.referral.newUserBonus') || 'New users receive' }} {{ referralData.register_bonus }} {{ $t('profile.credits.unit') || 'Credits' }}</span>
</div>
</div>
</a-card>
@@ -252,8 +252,8 @@
</a-form>
</a-tab-pane>
<!-- Credits Log Tab (消费记录) -->
<a-tab-pane key="credits" :tab="$t('profile.creditsLog') || '消费记录'">
<!-- Credits Log Tab -->
<a-tab-pane key="credits" :tab="$t('profile.creditsLog') || 'Usage History'">
<a-table
:columns="creditsLogColumns"
:dataSource="creditsLog"
@@ -284,11 +284,11 @@
</a-table>
</a-tab-pane>
<!-- Notification Settings Tab (通知设置) -->
<a-tab-pane key="notifications" :tab="$t('profile.notifications.title') || '通知设置'">
<!-- Notification Settings Tab -->
<a-tab-pane key="notifications" :tab="$t('profile.notifications.title') || 'Notification Settings'">
<div class="notification-settings-form">
<a-alert
:message="$t('profile.notifications.hint') || '配置您的默认通知方式,在创建资产监控和预警时将自动使用这些设置'"
:message="$t('profile.notifications.hint') || 'Configure your default notification methods. These settings will be used automatically for asset monitors and alerts.'"
type="info"
showIcon
style="margin-bottom: 24px"
@@ -296,14 +296,14 @@
<a-form :form="notificationForm" layout="vertical" style="max-width: 600px;">
<!-- Default Channels -->
<a-form-item :label="$t('profile.notifications.defaultChannels') || '默认通知渠道'">
<a-form-item :label="$t('profile.notifications.defaultChannels') || 'Default Notification Channels'">
<a-checkbox-group
v-decorator="['default_channels', { initialValue: notificationSettings.default_channels || ['browser'] }]"
>
<a-row :gutter="16">
<a-col :span="8">
<a-checkbox value="browser">
<a-icon type="bell" /> {{ $t('profile.notifications.browser') || '站内通知' }}
<a-icon type="bell" /> {{ $t('profile.notifications.browser') || 'In-App Notifications' }}
</a-checkbox>
</a-col>
<a-col :span="8">
@@ -313,14 +313,14 @@
</a-col>
<a-col :span="8">
<a-checkbox value="email">
<a-icon type="mail" /> {{ $t('profile.notifications.email') || '邮件' }}
<a-icon type="mail" /> {{ $t('profile.notifications.email') || 'Email' }}
</a-checkbox>
</a-col>
</a-row>
<a-row :gutter="16" style="margin-top: 8px">
<a-col :span="8">
<a-checkbox value="phone">
<a-icon type="phone" /> {{ $t('profile.notifications.phone') || '短信' }}
<a-icon type="phone" /> {{ $t('profile.notifications.phone') || 'SMS' }}
</a-checkbox>
</a-col>
<a-col :span="8">
@@ -341,14 +341,14 @@
<a-form-item :label="$t('profile.notifications.telegramBotToken') || 'Telegram Bot Token'">
<a-input-password
v-decorator="['telegram_bot_token', { initialValue: notificationSettings.telegram_bot_token }]"
:placeholder="$t('profile.notifications.telegramBotTokenPlaceholder') || '请输入您的 Telegram Bot Token'"
:placeholder="$t('profile.notifications.telegramBotTokenPlaceholder') || 'Enter your Telegram Bot Token'"
>
<a-icon slot="prefix" type="robot" />
</a-input-password>
<div class="field-hint">
<a-icon type="info-circle" />
<span>
{{ $t('profile.notifications.telegramBotTokenHint') || '通过 @BotFather 创建机器人获取 Token' }}
{{ $t('profile.notifications.telegramBotTokenHint') || 'Create a bot with @BotFather to get a token' }}
<a href="https://t.me/BotFather" target="_blank" rel="noopener noreferrer">@BotFather</a>
</span>
</div>
@@ -358,41 +358,41 @@
<a-form-item :label="$t('profile.notifications.telegramChatId') || 'Telegram Chat ID'">
<a-input
v-decorator="['telegram_chat_id', { initialValue: notificationSettings.telegram_chat_id }]"
:placeholder="$t('profile.notifications.telegramPlaceholder') || '请输入您的 Telegram Chat ID(如 123456789'"
:placeholder="$t('profile.notifications.telegramPlaceholder') || 'Enter your Telegram Chat ID (for example, 123456789)'"
>
<a-icon slot="prefix" type="message" />
</a-input>
<div class="field-hint">
<a-icon type="info-circle" />
<span>{{ $t('profile.notifications.telegramHint') || '发送 /start @userinfobot 可获取您的 Chat ID' }}</span>
<span>{{ $t('profile.notifications.telegramHint') || 'Send /start to @userinfobot to get your Chat ID' }}</span>
</div>
</a-form-item>
<!-- Notification Email -->
<a-form-item :label="$t('profile.notifications.notifyEmail') || '通知邮箱'">
<a-form-item :label="$t('profile.notifications.notifyEmail') || 'Notification Email'">
<a-input
v-decorator="['email', { initialValue: notificationSettings.email || profile.email }]"
:placeholder="$t('profile.notifications.emailPlaceholder') || '接收通知的邮箱地址'"
:placeholder="$t('profile.notifications.emailPlaceholder') || 'Email address used to receive notifications'"
>
<a-icon slot="prefix" type="mail" />
</a-input>
<div class="field-hint">
<a-icon type="info-circle" />
<span>{{ $t('profile.notifications.emailHint') || '默认使用账户邮箱,可设置其他邮箱接收通知' }}</span>
<span>{{ $t('profile.notifications.emailHint') || 'Your account email is used by default, but you can choose a different address for notifications' }}</span>
</div>
</a-form-item>
<!-- Phone Number (SMS) -->
<a-form-item :label="$t('profile.notifications.phone') || '手机号(短信通知)'">
<a-form-item :label="$t('profile.notifications.phone') || 'Phone Number (SMS Notifications)'">
<a-input
v-decorator="['phone', { initialValue: notificationSettings.phone }]"
:placeholder="$t('profile.notifications.phonePlaceholder') || '请输入手机号(如 +8613800138000'"
:placeholder="$t('profile.notifications.phonePlaceholder') || 'Enter a phone number (for example, +8613800138000)'"
>
<a-icon slot="prefix" type="phone" />
</a-input>
<div class="field-hint">
<a-icon type="info-circle" />
<span>{{ $t('profile.notifications.phoneHint') || '需要管理员配置 Twilio 服务后才能使用短信通知' }}</span>
<span>{{ $t('profile.notifications.phoneHint') || 'SMS notifications require an administrator to configure the Twilio service first' }}</span>
</div>
</a-form-item>
@@ -406,7 +406,7 @@
</a-input>
<div class="field-hint">
<a-icon type="info-circle" />
<span>{{ $t('profile.notifications.discordHint') || '在 Discord 服务器设置中创建 Webhook' }}</span>
<span>{{ $t('profile.notifications.discordHint') || 'Create a webhook in your Discord server settings' }}</span>
</div>
</a-form-item>
@@ -420,40 +420,40 @@
</a-input>
<div class="field-hint">
<a-icon type="info-circle" />
<span>{{ $t('profile.notifications.webhookHint') || '自定义 Webhook 地址,将以 POST JSON 方式推送通知' }}</span>
<span>{{ $t('profile.notifications.webhookHint') || 'Custom webhook URL. Notifications will be sent as POST JSON requests' }}</span>
</div>
</a-form-item>
<!-- Webhook Token -->
<a-form-item :label="$t('profile.notifications.webhookToken') || 'Webhook Token(可选)'">
<a-form-item :label="$t('profile.notifications.webhookToken') || 'Webhook Token (optional)'">
<a-input-password
v-decorator="['webhook_token', { initialValue: notificationSettings.webhook_token }]"
:placeholder="$t('profile.notifications.webhookTokenPlaceholder') || '用于验证请求的 Bearer Token'"
:placeholder="$t('profile.notifications.webhookTokenPlaceholder') || 'Bearer token used to validate requests'"
>
<a-icon slot="prefix" type="key" />
</a-input-password>
<div class="field-hint">
<a-icon type="info-circle" />
<span>{{ $t('profile.notifications.webhookTokenHint') || '将作为 Authorization: Bearer Token 发送到 Webhook' }}</span>
<span>{{ $t('profile.notifications.webhookTokenHint') || 'It will be sent to the webhook as Authorization: Bearer Token' }}</span>
</div>
</a-form-item>
<a-form-item>
<a-button type="primary" :loading="savingNotifications" @click="handleSaveNotifications">
<a-icon type="save" />
{{ $t('common.save') || '保存' }}
{{ $t('common.save') || 'Save' }}
</a-button>
<a-button style="margin-left: 12px" @click="handleTestNotification" :loading="testingNotification">
<a-icon type="experiment" />
{{ $t('profile.notifications.testBtn') || '发送测试通知' }}
{{ $t('profile.notifications.testBtn') || 'Send Test Notification' }}
</a-button>
</a-form-item>
</a-form>
</div>
</a-tab-pane>
<!-- Referral List Tab (邀请列表) -->
<a-tab-pane key="referrals" :tab="$t('profile.referral.listTab') || '邀请列表'">
<!-- Referral List Tab -->
<a-tab-pane key="referrals" :tab="$t('profile.referral.listTab') || 'Referral List'">
<a-table
:columns="referralColumns"
:dataSource="referralData.list || []"
@@ -481,9 +481,9 @@
</a-table>
<a-empty v-if="!referralLoading && (!referralData.list || referralData.list.length === 0)">
<span slot="description">{{ $t('profile.referral.noReferrals') || '暂无邀请记录' }}</span>
<span slot="description">{{ $t('profile.referral.noReferrals') || 'No referral records yet' }}</span>
<a-button type="primary" @click="copyReferralLink">
{{ $t('profile.referral.shareNow') || '立即分享邀请' }}
{{ $t('profile.referral.shareNow') || 'Share Invitation Now' }}
</a-button>
</a-empty>
</a-tab-pane>
@@ -579,30 +579,30 @@ export default {
creditsLogColumns () {
return [
{
title: this.$t('profile.creditsLog.time') || '时间',
title: this.$t('profile.creditsLog.time') || 'Time',
dataIndex: 'created_at',
width: 160,
scopedSlots: { customRender: 'created_at' }
},
{
title: this.$t('profile.creditsLog.action') || '类型',
title: this.$t('profile.creditsLog.action') || 'Type',
dataIndex: 'action',
width: 100,
scopedSlots: { customRender: 'action' }
},
{
title: this.$t('profile.creditsLog.amount') || '变动',
title: this.$t('profile.creditsLog.amount') || 'Change',
dataIndex: 'amount',
width: 100,
scopedSlots: { customRender: 'amount' }
},
{
title: this.$t('profile.creditsLog.balance') || '余额',
title: this.$t('profile.creditsLog.balance') || 'Balance',
dataIndex: 'balance_after',
width: 100
},
{
title: this.$t('profile.creditsLog.remark') || '备注',
title: this.$t('profile.creditsLog.remark') || 'Note',
dataIndex: 'remark',
ellipsis: true
}
@@ -611,12 +611,12 @@ export default {
referralColumns () {
return [
{
title: this.$t('profile.referral.user') || '用户',
title: this.$t('profile.referral.user') || 'User',
dataIndex: 'username',
scopedSlots: { customRender: 'user' }
},
{
title: this.$t('profile.referral.registerTime') || '注册时间',
title: this.$t('profile.referral.registerTime') || 'Registration Time',
dataIndex: 'created_at',
width: 180,
scopedSlots: { customRender: 'created_at' }
@@ -663,7 +663,7 @@ export default {
const res = await getProfile()
if (res.code === 1) {
this.profile = res.data
//
// Extract billing information
if (res.data.billing) {
this.billing = res.data.billing
// Prefer server-provided public recharge link
@@ -671,7 +671,7 @@ export default {
this.rechargeTelegramUrl = this.billing.recharge_telegram_url
}
}
//
// Extract notification settings
if (res.data.notification_settings) {
this.notificationSettings = {
default_channels: res.data.notification_settings.default_channels || ['browser'],
@@ -701,7 +701,7 @@ export default {
},
async loadRechargeUrl () {
// 使
// Only admins can fetch settings. Regular users use the defaults
if (this.profile.role === 'admin') {
try {
const res = await getSettingsValues()
@@ -709,13 +709,13 @@ export default {
this.rechargeTelegramUrl = res.data.billing.RECHARGE_TELEGRAM_URL || this.rechargeTelegramUrl
}
} catch (e) {
// 使
// Ignore errors and keep the default values
}
}
},
handleRecharge () {
// /
// Jump to the membership / recharge page
this.$router.push('/billing')
},
@@ -930,7 +930,7 @@ export default {
const link = this.referralLink
if (navigator.clipboard) {
navigator.clipboard.writeText(link).then(() => {
this.$message.success(this.$t('profile.referral.linkCopied') || '邀请链接已复制')
this.$message.success(this.$t('profile.referral.linkCopied') || 'Referral link copied')
}).catch(() => {
this.fallbackCopy(link)
})
@@ -946,7 +946,7 @@ export default {
textarea.select()
try {
document.execCommand('copy')
this.$message.success(this.$t('profile.referral.linkCopied') || '邀请链接已复制')
this.$message.success(this.$t('profile.referral.linkCopied') || 'Referral link copied')
} catch (err) {
this.$message.error('Copy failed')
}
@@ -963,7 +963,7 @@ export default {
vip_revoke: 'default',
register_bonus: 'cyan',
referral_bonus: 'purple',
//
// Indicator community related
indicator_purchase: 'volcano',
indicator_sale: 'lime'
}
@@ -972,17 +972,17 @@ export default {
getActionLabel (action) {
const labels = {
consume: this.$t('profile.creditsLog.actionConsume') || '消费',
recharge: this.$t('profile.creditsLog.actionRecharge') || '充值',
admin_adjust: this.$t('profile.creditsLog.actionAdjust') || '调整',
refund: this.$t('profile.creditsLog.actionRefund') || '退款',
vip_grant: this.$t('profile.creditsLog.actionVipGrant') || 'VIP授予',
vip_revoke: this.$t('profile.creditsLog.actionVipRevoke') || 'VIP取消',
register_bonus: this.$t('profile.creditsLog.actionRegisterBonus') || '注册奖励',
referral_bonus: this.$t('profile.creditsLog.actionReferralBonus') || '邀请奖励',
//
indicator_purchase: this.$t('profile.creditsLog.actionIndicatorPurchase') || '购买指标',
indicator_sale: this.$t('profile.creditsLog.actionIndicatorSale') || '出售指标'
consume: this.$t('profile.creditsLog.actionConsume') || 'Consume',
recharge: this.$t('profile.creditsLog.actionRecharge') || 'Recharge',
admin_adjust: this.$t('profile.creditsLog.actionAdjust') || 'Adjust',
refund: this.$t('profile.creditsLog.actionRefund') || 'Refund',
vip_grant: this.$t('profile.creditsLog.actionVipGrant') || 'VIP Grant',
vip_revoke: this.$t('profile.creditsLog.actionVipRevoke') || 'VIP Revoke',
register_bonus: this.$t('profile.creditsLog.actionRegisterBonus') || 'Registration Bonus',
referral_bonus: this.$t('profile.creditsLog.actionReferralBonus') || 'Referral Bonus',
// Indicator community related
indicator_purchase: this.$t('profile.creditsLog.actionIndicatorPurchase') || 'Purchase Indicator',
indicator_sale: this.$t('profile.creditsLog.actionIndicatorSale') || 'Sell Indicator'
}
return labels[action] || action
},
@@ -1038,13 +1038,13 @@ export default {
webhook_token: values.webhook_token || ''
})
if (res.code === 1) {
this.$message.success(this.$t('profile.notifications.saveSuccess') || '通知设置保存成功')
this.$message.success(this.$t('profile.notifications.saveSuccess') || 'Notification settings saved successfully')
this.notificationSettings = res.data || this.notificationSettings
} else {
this.$message.error(res.msg || '保存失败')
this.$message.error(res.msg || 'Save failed')
}
} catch (e) {
this.$message.error('保存失败')
this.$message.error('Save failed')
} finally {
this.savingNotifications = false
}
@@ -1056,23 +1056,23 @@ export default {
const channels = values.default_channels || []
if (channels.length === 0) {
this.$message.warning(this.$t('profile.notifications.selectChannel') || '请至少选择一个通知渠道')
this.$message.warning(this.$t('profile.notifications.selectChannel') || 'Please select at least one notification channel')
return
}
// Check if required fields are filled
if (channels.includes('telegram')) {
if (!values.telegram_bot_token) {
this.$message.warning(this.$t('profile.notifications.fillTelegramToken') || '请填写 Telegram Bot Token')
this.$message.warning(this.$t('profile.notifications.fillTelegramToken') || 'Please enter a Telegram Bot Token')
return
}
if (!values.telegram_chat_id) {
this.$message.warning(this.$t('profile.notifications.fillTelegram') || '请填写 Telegram Chat ID')
this.$message.warning(this.$t('profile.notifications.fillTelegram') || 'Please enter a Telegram Chat ID')
return
}
}
if (channels.includes('email') && !values.email) {
this.$message.warning(this.$t('profile.notifications.fillEmail') || '请填写通知邮箱')
this.$message.warning(this.$t('profile.notifications.fillEmail') || 'Please enter a notification email')
return
}
@@ -1091,15 +1091,15 @@ export default {
})
if (saveRes.code !== 1) {
this.$message.error(saveRes.msg || '保存设置失败')
this.$message.error(saveRes.msg || 'Failed to save settings')
return
}
this.$message.info(this.$t('profile.notifications.testSent') || '测试通知已发送,请检查您的通知渠道')
this.$message.info(this.$t('profile.notifications.testSent') || 'Test notification sent. Please check your notification channels')
// Note: Actual test notification would require a backend endpoint
// For now, we just show a success message after saving
} catch (e) {
this.$message.error('发送测试通知失败')
this.$message.error('Failed to send the test notification')
} finally {
this.testingNotification = false
}
@@ -1307,7 +1307,7 @@ export default {
}
}
// Credits Card
// Credits Card
.credits-card {
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
@@ -1417,7 +1417,7 @@ export default {
}
}
// Referral Card
// Referral Card
.referral-card {
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
+30 -30
View File
@@ -1,6 +1,6 @@
<template>
<div class="settings-page" :class="{ 'theme-dark': isDarkTheme }">
<!-- 重启提示 -->
<!-- Restart notice -->
<a-alert
v-if="showRestartTip"
class="restart-alert"
@@ -36,24 +36,24 @@
</span>
</template>
<!-- AI 组特殊显示 OpenRouter 余额查询卡片 -->
<!-- AI group special case: show the OpenRouter balance card -->
<div v-if="groupKey === 'ai'" class="openrouter-balance-card">
<a-card size="small" :bordered="false">
<div class="balance-header">
<span class="balance-title">
<a-icon type="wallet" style="margin-right: 6px;" />
{{ $t('settings.openrouterBalance') || 'OpenRouter 账户余额' }}
{{ $t('settings.openrouterBalance') || 'OpenRouter Account Balance' }}
</span>
<a-button size="small" type="primary" ghost :loading="balanceLoading" @click="queryOpenRouterBalance">
<a-icon type="sync" />
{{ $t('settings.queryBalance') || '查询余额' }}
{{ $t('settings.queryBalance') || 'Query Balance' }}
</a-button>
</div>
<div v-if="openrouterBalance" class="balance-info">
<a-row :gutter="16">
<a-col :span="8">
<a-statistic
:title="$t('settings.balanceUsage') || '已使用'"
:title="$t('settings.balanceUsage') || 'Used'"
:value="openrouterBalance.usage"
prefix="$"
:precision="4"
@@ -62,7 +62,7 @@
</a-col>
<a-col :span="8">
<a-statistic
:title="$t('settings.balanceRemaining') || '剩余额度'"
:title="$t('settings.balanceRemaining') || 'Remaining'"
:value="openrouterBalance.limit_remaining !== null ? openrouterBalance.limit_remaining : '∞'"
:prefix="openrouterBalance.limit_remaining !== null ? '$' : ''"
:precision="openrouterBalance.limit_remaining !== null ? 4 : 0"
@@ -71,7 +71,7 @@
</a-col>
<a-col :span="8">
<a-statistic
:title="$t('settings.balanceLimit') || '总限额'"
:title="$t('settings.balanceLimit') || 'Total Limit'"
:value="openrouterBalance.limit !== null ? openrouterBalance.limit : '∞'"
:prefix="openrouterBalance.limit !== null ? '$' : ''"
:precision="openrouterBalance.limit !== null ? 2 : 0"
@@ -84,7 +84,7 @@
</div>
<div v-else class="balance-empty">
<a-icon type="info-circle" style="margin-right: 6px;" />
{{ $t('settings.balanceNotQueried') || '点击"查询余额"获取账户信息' }}
{{ $t('settings.balanceNotQueried') || 'Click "Query Balance" to fetch account information' }}
</div>
</a-card>
</div>
@@ -121,7 +121,7 @@
</a>
</span>
</template>
<!-- 文本输入 -->
<!-- Text input -->
<template v-if="item.type === 'text'">
<a-input
v-decorator="[item.key, { initialValue: getFieldValue(groupKey, item.key) }]"
@@ -130,7 +130,7 @@
/>
</template>
<!-- 密码输入 -->
<!-- Password input -->
<template v-else-if="item.type === 'password'">
<div class="password-field">
<a-input
@@ -149,7 +149,7 @@
</div>
</template>
<!-- 数字输入 -->
<!-- Number input -->
<template v-else-if="item.type === 'number'">
<a-input-number
v-decorator="[item.key, { initialValue: getNumberValue(groupKey, item.key, item.default) }]"
@@ -158,14 +158,14 @@
/>
</template>
<!-- 布尔开关 -->
<!-- Boolean switch -->
<template v-else-if="item.type === 'boolean'">
<a-switch
v-decorator="[item.key, { valuePropName: 'checked', initialValue: getBoolValue(groupKey, item.key, item.default) }]"
/>
</template>
<!-- 下拉选择 -->
<!-- Dropdown select -->
<template v-else-if="item.type === 'select'">
<a-select
v-decorator="[item.key, { initialValue: getFieldValue(groupKey, item.key) || item.default }]"
@@ -222,7 +222,7 @@ export default {
activeKeys: ['auth', 'ai', 'trading'],
passwordVisible: {},
showRestartTip: false,
// OpenRouter
// OpenRouter balance
balanceLoading: false,
openrouterBalance: null
}
@@ -231,7 +231,7 @@ export default {
isDarkTheme () {
return this.navTheme === 'dark' || this.navTheme === 'realdark'
},
// order schema
// Schema sorted by order
sortedSchema () {
const entries = Object.entries(this.schema)
entries.sort((a, b) => {
@@ -253,7 +253,7 @@ export default {
this.loadSettings()
},
methods: {
// schema options
// Support both backend schema option formats:
// - string[]: ['openrouter','openai', ...]
// - {value,label}[]: [{value:'openrouter',label:'OpenRouter'}, ...]
getSelectOptions (options) {
@@ -290,19 +290,19 @@ export default {
}
},
// OpenRouter
// Query OpenRouter balance
async queryOpenRouterBalance () {
this.balanceLoading = true
try {
const res = await getOpenRouterBalance()
if (res.code === 1 && res.data) {
this.openrouterBalance = res.data
this.$message.success(this.$t('settings.balanceQuerySuccess') || '余额查询成功')
this.$message.success(this.$t('settings.balanceQuerySuccess') || 'Balance query succeeded')
} else {
this.$message.error(res.msg || this.$t('settings.balanceQueryFailed') || '余额查询失败')
this.$message.error(res.msg || this.$t('settings.balanceQueryFailed') || 'Balance query failed')
}
} catch (error) {
this.$message.error(this.$t('settings.balanceQueryFailed') || '余额查询失败')
this.$message.error(this.$t('settings.balanceQueryFailed') || 'Balance query failed')
} finally {
this.balanceLoading = false
}
@@ -339,19 +339,19 @@ export default {
},
getItemDescription (groupKey, item) {
//
// Try the i18n description first
const key = `settings.desc.${item.key}`
const translated = this.$t(key)
if (translated !== key) {
return translated
}
// 退
// Fall back to the description returned by the backend
return item.description || ''
},
getLinkText (linkText) {
if (!linkText) return this.$t('settings.getApi')
// settings.link.
// Translate it when it is a translation key starting with settings.link.
if (linkText.startsWith('settings.link.')) {
const translated = this.$t(linkText)
return translated !== linkText ? translated : linkText
@@ -406,7 +406,7 @@ export default {
this.saving = true
try {
//
// Group data by section
const data = {}
for (const groupKey of Object.keys(this.schema)) {
data[groupKey] = {}
@@ -414,7 +414,7 @@ export default {
for (const item of group.items) {
if (item.key in formValues) {
let value = formValues[item.key]
//
// Convert booleans to strings
if (item.type === 'boolean') {
value = value ? 'True' : 'False'
}
@@ -426,11 +426,11 @@ export default {
const res = await saveSettings(data)
if (res.code === 1) {
this.$message.success(res.msg || this.$t('settings.saveSuccess'))
//
// Show the restart notice
if (res.data && res.data.requires_restart) {
this.showRestartTip = true
}
//
// Reload configuration
this.loadSettings()
} else {
this.$message.error(res.msg || this.$t('settings.saveFailed'))
@@ -491,7 +491,7 @@ export default {
margin-bottom: 80px;
}
// OpenRouter
// OpenRouter balance card
.openrouter-balance-card {
margin-bottom: 20px;
@@ -699,7 +699,7 @@ export default {
}
}
//
// Dark theme
&.theme-dark {
background: linear-gradient(180deg, #0d1117 0%, #161b22 100%);
@@ -814,7 +814,7 @@ export default {
}
}
//
// Responsive adaptation
@media (max-width: 768px) {
.settings-page {
padding: 16px;
@@ -0,0 +1,915 @@
<template>
<div class="script-strategy-editor" :class="{ 'theme-dark': isDark }">
<div class="editor-toolbar">
<div class="editor-title-group">
<div class="editor-title">{{ tt('trading-assistant.editor.title', 'Script Editor') }}</div>
<div class="editor-subtitle">{{ tt('trading-assistant.editor.subtitle', 'Write Python hooks directly, verify syntax and safety, or start from a template.') }}</div>
</div>
<div class="editor-actions">
<a-button size="small" @click="handleVerifyCode" :loading="verifying">
<a-icon type="check-circle" />
{{ tt('trading-assistant.editor.verify', 'Verify') }}
</a-button>
<a-button size="small" @click="openDocsLink">
<a-icon type="book" />
{{ tt('trading-assistant.editor.docsButton', 'Open Docs') }}
</a-button>
</div>
</div>
<a-alert
type="info"
show-icon
class="editor-alert"
:message="tt('trading-assistant.editor.hintTitle', 'Script strategies run custom Python hooks')"
:description="tt('trading-assistant.editor.hintDesc', 'Define at least on_init(ctx) and on_bar(ctx, bar). Use the docs tab for the helper API and the template tab for starter code.')"
/>
<div class="editor-layout">
<div class="code-pane">
<div ref="codeEditorContainer" class="code-editor-container"></div>
</div>
<div class="side-pane">
<a-tabs v-model="activeTab" size="small" class="editor-tabs">
<a-tab-pane key="template" :tab="tt('trading-assistant.editor.templateTab', 'Templates')">
<div class="side-section">
<div class="section-title">{{ tt('trading-assistant.editor.templateIntroTitle', 'Starter Templates') }}</div>
<div class="section-copy">{{ tt('trading-assistant.editor.templateIntroDesc', 'Choose a strategy scaffold, tune the parameters, then inject the generated code into the editor.') }}</div>
<div class="template-list">
<div
v-for="item in templateOptions"
:key="item.key"
class="template-card"
:class="{ active: selectedTemplateKey === item.key }"
@click="selectTemplate(item.key)">
<div class="template-card-head">
<span class="template-name">{{ item.title }}</span>
<a-tag size="small" :color="selectedTemplateKey === item.key ? 'blue' : 'default'">
{{ item.family }}
</a-tag>
</div>
<div class="template-desc">{{ item.description }}</div>
</div>
</div>
<div v-if="selectedTemplate" class="template-params">
<div class="params-head">
<span>{{ tt('trading-assistant.editor.paramsTab', 'Template Parameters') }}</span>
<span class="params-hint">{{ tt('trading-assistant.editor.paramsHint', 'Apply to overwrite the current editor content with the selected template.') }}</span>
</div>
<div class="param-grid">
<div v-for="param in selectedTemplate.params" :key="param.key" class="param-item">
<label class="param-label">{{ param.label }}</label>
<a-select
v-if="param.type === 'select'"
v-model="templateParamValues[param.key]"
size="small"
:getPopupContainer="getPopupContainer">
<a-select-option v-for="option in param.options" :key="option.value" :value="option.value">
{{ option.label }}
</a-select-option>
</a-select>
<a-input-number
v-else
v-model="templateParamValues[param.key]"
size="small"
:min="param.min"
:max="param.max"
:step="param.step || 1"
:precision="param.precision"
style="width: 100%" />
</div>
</div>
<div class="template-actions">
<a-button size="small" @click="resetTemplateParams">
{{ tt('trading-assistant.editor.resetTemplateParams', 'Reset') }}
</a-button>
<a-button type="primary" size="small" @click="applySelectedTemplateToCode">
{{ tt('trading-assistant.editor.applyTemplateParams', 'Apply Template') }}
</a-button>
</div>
</div>
</div>
</a-tab-pane>
<a-tab-pane key="docs" :tab="tt('trading-assistant.editor.docsTab', 'Docs')">
<div class="side-section docs-section">
<div class="section-title">{{ tt('trading-assistant.editor.docsIntroTitle', 'Strategy API Reference') }}</div>
<div class="section-copy">{{ tt('trading-assistant.editor.docsIntroDesc', 'The private build exposes the same hook model below. Keep the required hooks and use the ctx helpers shown here.') }}</div>
<div class="docs-card">
<div class="docs-subtitle">{{ tt('trading-assistant.editor.requiredHooks', 'Required Hooks') }}</div>
<pre class="docs-code">def on_init(ctx):
pass
def on_bar(ctx, bar):
pass
def on_order_filled(ctx, order):
pass
def on_stop(ctx):
pass</pre>
</div>
<div class="docs-card">
<div class="docs-subtitle">{{ tt('trading-assistant.editor.contextHelpers', 'Context Helpers') }}</div>
<ul class="docs-list">
<li><code>ctx.buy(price, amount)</code></li>
<li><code>ctx.sell(price, amount)</code></li>
<li><code>ctx.close_position()</code></li>
<li><code>ctx.cancel_all_orders()</code></li>
<li><code>ctx.bars(n)</code></li>
<li><code>ctx.param(name, default)</code></li>
<li><code>ctx.position</code>, <code>ctx.balance</code>, <code>ctx.equity</code>, <code>ctx.log(message)</code></li>
</ul>
</div>
<div class="docs-card">
<div class="docs-subtitle">{{ tt('trading-assistant.editor.barFields', 'Bar Fields') }}</div>
<pre class="docs-code">bar.open
bar.high
bar.low
bar.close
bar.volume
bar.timestamp</pre>
</div>
</div>
</a-tab-pane>
<a-tab-pane key="ai" :tab="tt('trading-assistant.editor.aiTab', 'AI')">
<div class="side-section">
<div class="section-title">{{ tt('trading-assistant.editor.aiGenerate', 'Generate with AI') }}</div>
<div class="section-copy">{{ tt('trading-assistant.editor.aiHint', 'Describe your trading idea in plain English. The backend returns Python code only.') }}</div>
<div class="prompt-suggestions">
<a-tag v-for="item in aiPromptSuggestions" :key="item.key" @click="applyPromptSuggestion(item.prompt)">
{{ item.label }}
</a-tag>
</div>
<a-textarea
v-model="aiPrompt"
:rows="9"
:auto-size="{ minRows: 9, maxRows: 14 }"
:placeholder="tt('trading-assistant.editor.aiPromptPlaceholder', 'Example: Build a BTC breakout strategy that buys when price closes above the highest close of the last 20 bars and exits with a 2% stop loss.')"
/>
<a-button
type="primary"
block
size="large"
:loading="aiGenerating"
class="generate-btn"
@click="handleAIGenerate">
<a-icon type="robot" />
{{ tt('trading-assistant.editor.aiGenerateBtn', 'Generate Code') }}
</a-button>
</div>
</a-tab-pane>
</a-tabs>
</div>
</div>
</div>
</template>
<script>
import CodeMirror from 'codemirror'
import 'codemirror/lib/codemirror.css'
import 'codemirror/mode/python/python'
import 'codemirror/theme/monokai.css'
import 'codemirror/theme/eclipse.css'
import 'codemirror/addon/edit/closebrackets'
import 'codemirror/addon/edit/matchbrackets'
import 'codemirror/addon/selection/active-line'
import { aiGenerateStrategyCode, verifyStrategyCode } from '@/api/strategy'
export default {
name: 'ScriptStrategyEditor',
props: {
value: {
type: String,
default: ''
},
active: {
type: Boolean,
default: false
},
isDark: {
type: Boolean,
default: false
},
initialTemplateKey: {
type: String,
default: 'ma_cross'
},
templateApplyNonce: {
type: Number,
default: 0
}
},
data () {
return {
codeEditor: null,
activeTab: 'template',
aiPrompt: '',
aiGenerating: false,
verifying: false,
selectedTemplateKey: 'ma_cross',
templateParamValues: {}
}
},
computed: {
templateOptions () {
return [
{
key: 'ma_cross',
title: 'Moving Average Cross',
family: 'Trend',
description: 'Follow directional momentum with a fast and slow average crossover.',
params: [
{ key: 'fast', label: 'Fast MA', type: 'number', default: 10, min: 2, max: 100, step: 1, precision: 0 },
{ key: 'slow', label: 'Slow MA', type: 'number', default: 30, min: 5, max: 200, step: 1, precision: 0 },
{ key: 'positionSize', label: 'Order Size', type: 'number', default: 1, min: 0.01, max: 100, step: 0.1, precision: 2 }
]
},
{
key: 'rsi_reversion',
title: 'RSI Mean Reversion',
family: 'Mean Reversion',
description: 'Buy oversold conditions and fade overbought moves with RSI thresholds.',
params: [
{ key: 'period', label: 'RSI Period', type: 'number', default: 14, min: 2, max: 100, step: 1, precision: 0 },
{ key: 'oversold', label: 'Oversold', type: 'number', default: 30, min: 1, max: 50, step: 1, precision: 0 },
{ key: 'overbought', label: 'Overbought', type: 'number', default: 70, min: 50, max: 99, step: 1, precision: 0 },
{ key: 'positionSize', label: 'Order Size', type: 'number', default: 1, min: 0.01, max: 100, step: 0.1, precision: 2 }
]
},
{
key: 'breakout',
title: 'Donchian Breakout',
family: 'Breakout',
description: 'Enter on a range breakout and close when price loses the breakout level.',
params: [
{ key: 'lookback', label: 'Lookback Bars', type: 'number', default: 20, min: 5, max: 200, step: 1, precision: 0 },
{ key: 'bufferPct', label: 'Breakout Buffer %', type: 'number', default: 0.2, min: 0, max: 10, step: 0.1, precision: 2 },
{ key: 'positionSize', label: 'Order Size', type: 'number', default: 1, min: 0.01, max: 100, step: 0.1, precision: 2 }
]
}
]
},
selectedTemplate () {
return this.templateOptions.find(item => item.key === this.selectedTemplateKey) || null
},
aiPromptSuggestions () {
return [
{
key: 'improve',
label: this.tt('trading-assistant.editor.aiSuggestionImprove', 'Improve MA crossover'),
prompt: 'Improve a moving-average crossover strategy with volatility filter and safer exits.'
},
{
key: 'risk',
label: this.tt('trading-assistant.editor.aiSuggestionRisk', 'Add risk controls'),
prompt: 'Create a breakout strategy with stop loss, cooldown, and no-trade filter during low volume.'
},
{
key: 'explain',
label: this.tt('trading-assistant.editor.aiSuggestionExplain', 'Explainable reversion'),
prompt: 'Generate a mean-reversion strategy that logs why it enters and exits each position.'
}
]
}
},
watch: {
value (nextValue) {
if (!this.codeEditor) return
const editorValue = this.codeEditor.getValue()
if (nextValue !== editorValue) {
this.codeEditor.setValue(nextValue || this.buildDefaultCode())
this.codeEditor.refresh()
}
},
active (isActive) {
if (isActive && this.codeEditor) {
this.$nextTick(() => this.codeEditor.refresh())
}
},
initialTemplateKey: {
immediate: true,
handler (nextKey) {
this.selectTemplate(nextKey || 'ma_cross')
}
},
templateApplyNonce: {
immediate: true,
handler (nextValue) {
if (nextValue) {
this.selectTemplate(this.initialTemplateKey || 'ma_cross')
this.$nextTick(() => this.applySelectedTemplateToCode())
}
}
}
},
mounted () {
this.$nextTick(() => {
this.initCodeEditor()
})
},
beforeDestroy () {
if (this.codeEditor) {
try {
if (typeof this.codeEditor.toTextArea === 'function') {
this.codeEditor.toTextArea()
} else if (typeof this.codeEditor.getWrapperElement === 'function') {
const wrapper = this.codeEditor.getWrapperElement()
if (wrapper && wrapper.parentNode) {
wrapper.parentNode.removeChild(wrapper)
}
}
} catch (e) {}
this.codeEditor = null
}
},
methods: {
tt (key, fallback) {
const translated = this.$t(key)
return translated !== key ? translated : fallback
},
getPopupContainer (triggerNode) {
return (triggerNode && triggerNode.parentNode) || document.body
},
initCodeEditor () {
if (!this.$refs.codeEditorContainer) return
this.$refs.codeEditorContainer.innerHTML = ''
this.codeEditor = CodeMirror(this.$refs.codeEditorContainer, {
value: this.value || this.buildDefaultCode(),
mode: 'python',
theme: this.isDark ? 'monokai' : 'eclipse',
lineNumbers: true,
lineWrapping: true,
indentUnit: 4,
indentWithTabs: false,
smartIndent: true,
matchBrackets: true,
autoCloseBrackets: true,
styleActiveLine: true,
gutters: ['CodeMirror-linenumbers'],
tabSize: 4,
viewportMargin: Infinity
})
this.codeEditor.on('change', (editor) => {
this.$emit('input', editor.getValue())
})
this.$nextTick(() => {
if (this.codeEditor) {
this.codeEditor.refresh()
}
})
},
cleanMarkdownCodeBlocks (code) {
if (!code || typeof code !== 'string') {
return code
}
let cleanedCode = code.trim()
cleanedCode = cleanedCode.replace(/^```[\w]*\s*\n?/i, '')
cleanedCode = cleanedCode.replace(/\n?```\s*$/g, '')
cleanedCode = cleanedCode.replace(/^\s*```[\w]*\s*$/gm, '')
cleanedCode = cleanedCode.replace(/^\s*```\s*$/gm, '')
cleanedCode = cleanedCode.replace(/\n{3,}/g, '\n\n')
return cleanedCode.trim()
},
applyPromptSuggestion (prompt) {
this.aiPrompt = prompt
this.activeTab = 'ai'
},
selectTemplate (templateKey) {
const found = this.templateOptions.find(item => item.key === templateKey) || this.templateOptions[0]
this.selectedTemplateKey = found.key
const nextValues = {}
found.params.forEach(param => {
nextValues[param.key] = param.default
})
this.templateParamValues = nextValues
},
resetTemplateParams () {
this.selectTemplate(this.selectedTemplateKey)
},
applySelectedTemplateToCode () {
if (!this.selectedTemplate) return
const code = this.buildTemplateCode(this.selectedTemplateKey, this.templateParamValues)
if (this.codeEditor) {
this.codeEditor.setValue(code)
this.codeEditor.refresh()
}
this.$emit('input', code)
this.$message.success(this.tt('trading-assistant.editor.templateApplied', 'Template applied to the editor'))
},
buildDefaultCode () {
return `def on_init(ctx):
ctx.log("strategy initialized")
def on_bar(ctx, bar):
closes = [item.close for item in ctx.bars(20)]
if len(closes) < 20:
return
latest_close = closes[-1]
average_close = sum(closes) / len(closes)
if not ctx.position and latest_close > average_close:
ctx.buy(price=latest_close, amount=1)
elif ctx.position and latest_close < average_close:
ctx.close_position()
def on_order_filled(ctx, order):
ctx.log(f"order filled: {order}")
def on_stop(ctx):
ctx.log("strategy stopped")
`
},
buildTemplateCode (templateKey, params) {
if (templateKey === 'rsi_reversion') {
return `def compute_rsi(closes, period):
gains = []
losses = []
for idx in range(1, len(closes)):
delta = closes[idx] - closes[idx - 1]
gains.append(max(delta, 0))
losses.append(abs(min(delta, 0)))
avg_gain = sum(gains[-period:]) / period
avg_loss = sum(losses[-period:]) / period
if avg_loss == 0:
return 100
rs = avg_gain / avg_loss
return 100 - (100 / (1 + rs))
def on_init(ctx):
ctx.param("period", ${Number(params.period || 14)})
ctx.param("oversold", ${Number(params.oversold || 30)})
ctx.param("overbought", ${Number(params.overbought || 70)})
ctx.param("position_size", ${Number(params.positionSize || 1)})
def on_bar(ctx, bar):
period = int(ctx.param("period", ${Number(params.period || 14)}))
bars = ctx.bars(period + 2)
if len(bars) < period + 1:
return
closes = [item.close for item in bars]
rsi = compute_rsi(closes, period)
latest_price = closes[-1]
if not ctx.position and rsi <= ctx.param("oversold", ${Number(params.oversold || 30)}):
ctx.buy(price=latest_price, amount=ctx.param("position_size", ${Number(params.positionSize || 1)}))
elif ctx.position and rsi >= ctx.param("overbought", ${Number(params.overbought || 70)}):
ctx.close_position()
def on_order_filled(ctx, order):
ctx.log(f"rsi strategy fill: {order}")
def on_stop(ctx):
ctx.log("rsi reversion strategy stopped")
`
}
if (templateKey === 'breakout') {
return `def on_init(ctx):
ctx.param("lookback", ${Number(params.lookback || 20)})
ctx.param("buffer_pct", ${Number(params.bufferPct || 0.2)})
ctx.param("position_size", ${Number(params.positionSize || 1)})
def on_bar(ctx, bar):
lookback = int(ctx.param("lookback", ${Number(params.lookback || 20)}))
bars = ctx.bars(lookback + 1)
if len(bars) < lookback + 1:
return
closes = [item.close for item in bars[:-1]]
highest_close = max(closes)
breakout_level = highest_close * (1 + ctx.param("buffer_pct", ${Number(params.bufferPct || 0.2)}) / 100)
if not ctx.position and bar.close > breakout_level:
ctx.buy(price=bar.close, amount=ctx.param("position_size", ${Number(params.positionSize || 1)}))
elif ctx.position and bar.close < highest_close:
ctx.close_position()
def on_order_filled(ctx, order):
ctx.log(f"breakout fill: {order}")
def on_stop(ctx):
ctx.log("breakout strategy stopped")
`
}
return `def sma(values, period):
return sum(values[-period:]) / period
def on_init(ctx):
ctx.param("fast", ${Number(params.fast || 10)})
ctx.param("slow", ${Number(params.slow || 30)})
ctx.param("position_size", ${Number(params.positionSize || 1)})
def on_bar(ctx, bar):
slow = int(ctx.param("slow", ${Number(params.slow || 30)}))
fast = int(ctx.param("fast", ${Number(params.fast || 10)}))
bars = ctx.bars(slow + 2)
if len(bars) < slow + 1:
return
closes = [item.close for item in bars]
fast_now = sma(closes, fast)
slow_now = sma(closes, slow)
fast_prev = sma(closes[:-1], fast)
slow_prev = sma(closes[:-1], slow)
crossed_up = fast_prev <= slow_prev and fast_now > slow_now
crossed_down = fast_prev >= slow_prev and fast_now < slow_now
if not ctx.position and crossed_up:
ctx.buy(price=bar.close, amount=ctx.param("position_size", ${Number(params.positionSize || 1)}))
elif ctx.position and crossed_down:
ctx.close_position()
def on_order_filled(ctx, order):
ctx.log(f"ma cross fill: {order}")
def on_stop(ctx):
ctx.log("moving average strategy stopped")
`
},
async handleVerifyCode () {
const code = this.codeEditor ? this.codeEditor.getValue() : this.value
if (!code || !code.trim()) {
this.$message.warning(this.tt('trading-assistant.editor.codeHint', 'Please add strategy code before continuing'))
return
}
this.verifying = true
try {
const res = await verifyStrategyCode(code)
if (res && res.success) {
this.$message.success(res.message || this.tt('trading-assistant.editor.verifySuccess', 'Code verification passed'))
} else {
this.$message.error((res && res.message) || this.tt('trading-assistant.editor.verifyFailed', 'Code verification failed'))
}
} catch (error) {
this.$message.error(error.message || this.tt('trading-assistant.editor.verifyFailed', 'Code verification failed'))
} finally {
this.verifying = false
}
},
async handleAIGenerate () {
if (!this.aiPrompt || !this.aiPrompt.trim()) {
this.$message.warning(this.tt('trading-assistant.editor.aiPromptRequired', 'Please enter your idea before generating code'))
return
}
this.aiGenerating = true
try {
const res = await aiGenerateStrategyCode(this.aiPrompt.trim())
const generatedCode = this.cleanMarkdownCodeBlocks(res && res.code ? res.code : '')
if (!generatedCode) {
this.$message.error((res && res.msg) || this.tt('trading-assistant.editor.aiGenerateFailed', 'AI code generation failed'))
return
}
if (this.codeEditor) {
this.codeEditor.setValue(generatedCode)
this.codeEditor.refresh()
}
this.$emit('input', generatedCode)
this.$message.success(this.tt('trading-assistant.editor.aiGenerateSuccess', 'Code generated successfully'))
} catch (error) {
this.$message.error(error.message || this.tt('trading-assistant.editor.aiGenerateFailed', 'AI code generation failed'))
} finally {
this.aiGenerating = false
}
},
openDocsLink () {
window.open('https://github.com/brokermr810/QuantDinger/blob/main/docs/STRATEGY_DEV_GUIDE.md', '_blank')
}
}
}
</script>
<style lang="less" scoped>
.script-strategy-editor {
.editor-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.editor-title {
font-size: 18px;
font-weight: 600;
color: #1f1f1f;
}
.editor-subtitle {
margin-top: 4px;
font-size: 13px;
line-height: 1.6;
color: #8c8c8c;
}
.editor-actions {
display: flex;
gap: 8px;
}
.editor-alert {
margin-bottom: 16px;
}
.editor-layout {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.85fr);
gap: 16px;
align-items: stretch;
}
.code-pane,
.side-pane {
min-width: 0;
}
.code-editor-container {
min-height: 520px;
border: 1px solid #d9d9d9;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
/deep/ .CodeMirror {
height: 520px;
font-size: 13px;
}
.editor-tabs {
height: 100%;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fff;
}
.side-section {
padding: 4px 4px 0;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #1f1f1f;
}
.section-copy {
margin-top: 6px;
margin-bottom: 14px;
font-size: 13px;
line-height: 1.6;
color: #8c8c8c;
}
.template-list {
display: grid;
gap: 10px;
}
.template-card {
padding: 12px;
border: 1px solid #e8e8e8;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: #91d5ff;
box-shadow: 0 6px 18px rgba(24, 144, 255, 0.08);
}
&.active {
border-color: #1890ff;
background: #f0f7ff;
}
}
.template-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.template-name {
font-size: 14px;
font-weight: 600;
}
.template-desc {
font-size: 12px;
line-height: 1.6;
color: #666;
}
.template-params {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #f0f0f0;
}
.params-head {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.params-hint {
font-size: 12px;
color: #8c8c8c;
}
.param-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.param-item {
min-width: 0;
}
.param-label {
display: block;
margin-bottom: 6px;
font-size: 12px;
font-weight: 600;
color: #4a4a4a;
}
.template-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.docs-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.docs-card {
padding: 12px;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fafafa;
}
.docs-subtitle {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
}
.docs-code {
margin: 0;
padding: 10px;
overflow: auto;
border-radius: 6px;
background: #111827;
color: #dbeafe;
font-size: 12px;
line-height: 1.6;
}
.docs-list {
margin: 0;
padding-left: 18px;
font-size: 12px;
line-height: 1.8;
color: #4a4a4a;
}
.prompt-suggestions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
.ant-tag {
cursor: pointer;
user-select: none;
}
}
.generate-btn {
margin-top: 12px;
}
&.theme-dark {
.editor-title,
.section-title,
.template-name,
.docs-subtitle,
.param-label {
color: #f5f5f5;
}
.editor-subtitle,
.section-copy,
.template-desc,
.params-hint,
.docs-list {
color: rgba(255, 255, 255, 0.72);
}
.code-editor-container,
.editor-tabs {
border-color: rgba(255, 255, 255, 0.12);
background: #111827;
}
.template-card {
border-color: rgba(255, 255, 255, 0.12);
background: #111827;
&.active {
border-color: #177ddc;
background: rgba(23, 125, 220, 0.12);
}
}
.template-params {
border-top-color: rgba(255, 255, 255, 0.12);
}
.docs-card {
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
}
}
@media (max-width: 992px) {
.editor-layout {
grid-template-columns: 1fr;
}
.code-editor-container {
min-height: 420px;
}
/deep/ .CodeMirror {
height: 420px;
}
}
@media (max-width: 640px) {
.editor-toolbar {
flex-direction: column;
}
.editor-actions {
width: 100%;
}
.editor-actions .ant-btn {
flex: 1;
}
.param-grid {
grid-template-columns: 1fr;
}
}
}
</style>
@@ -0,0 +1,61 @@
<template>
<div class="strategy-type-selector" :class="{ 'theme-dark': isDark }">
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<div
class="strategy-type-card"
:class="{ selected: selected === 'indicator' }"
@click="$emit('select', 'indicator')">
<div class="strategy-type-content">
<div class="strategy-type-icon">
<a-icon type="line-chart" />
</div>
<h3>{{ tt('trading-assistant.strategyMode.indicator', 'Indicator Strategy') }}</h3>
<p>{{ tt('trading-assistant.strategyMode.indicatorDesc', 'Build a strategy from your custom or purchased indicators, then add execution and risk settings.') }}</p>
</div>
</div>
</a-col>
<a-col :xs="24" :md="12">
<div
class="strategy-type-card"
:class="{ selected: selected === 'script' }"
@click="$emit('select', 'script')">
<div class="strategy-type-content">
<div class="strategy-type-icon">
<a-icon type="code" />
</div>
<h3>{{ tt('trading-assistant.strategyMode.script', 'Script Strategy') }}</h3>
<p>{{ tt('trading-assistant.strategyMode.scriptDesc', 'Write Python hooks directly, use built-in starter templates, and verify the strategy before launch.') }}</p>
<a-button type="primary" size="small" @click.stop="$emit('use-template', { mode: 'script', templateKey: 'ma_cross' })">
<a-icon type="copy" />
{{ tt('trading-assistant.strategyMode.useTemplate', 'Use Starter Template') }}
</a-button>
</div>
</div>
</a-col>
</a-row>
</div>
</template>
<script>
export default {
name: 'StrategyTypeSelector',
props: {
selected: {
type: String,
default: 'indicator'
},
isDark: {
type: Boolean,
default: false
}
},
methods: {
tt (key, fallback) {
const translated = this.$t(key)
return translated !== key ? translated : fallback
}
}
}
</script>
+417 -261
View File
@@ -1,6 +1,6 @@
<template>
<div class="trading-assistant" :class="{ 'theme-dark': isDarkTheme }">
<div v-if="showAssistantGuide" class="assistant-guide-bar">
<div class="assistant-guide-bar">
<div class="assistant-guide-copy">
<div class="assistant-guide-eyebrow">{{ tt('trading-assistant.guide.eyebrow', 'Trading Workflow') }}</div>
<div class="assistant-guide-title">{{ tt('trading-assistant.guide.title', 'Build, launch, and monitor strategies from one workspace') }}</div>
@@ -38,16 +38,13 @@
<a-icon type="plus" />
{{ tt('trading-assistant.guide.primary', 'Create Strategy') }}
</a-button>
<a-button class="assistant-guide-close" @click="dismissAssistantGuide">
<a-icon type="close" />
</a-button>
</div>
</div>
<a-tabs v-model="topTab" class="top-level-tabs" :animated="false">
<a-tab-pane key="overview">
<span slot="tab">{{ tt('trading-assistant.tabs.overview', 'Overview') }}</span>
<dashboard-overview v-if="topTab === 'overview'" hide-setup-guide />
<dashboard-overview v-if="topTab === 'overview'" />
</a-tab-pane>
<a-tab-pane key="strategy">
@@ -146,27 +143,17 @@
:key="item.id"
:class="[
'strategy-list-item',
{ active: selectedStrategy && selectedStrategy.id === item.id },
{ 'strategy-list-item--strategy-group': groupByMode === 'strategy' }
{ active: selectedStrategy && selectedStrategy.id === item.id }
]"
@click="handleSelectStrategy(item)">
<div class="strategy-item-content">
<div class="strategy-item-header">
<div :class="['strategy-name-wrapper', { 'strategy-name-wrapper--grouped': groupByMode === 'symbol' }]">
<div class="strategy-name-wrapper">
<template v-if="groupByMode === 'strategy'">
<span class="strategy-name">{{ item.strategy_name }}</span>
<a-tag
v-if="item.strategy_type === 'PromptBasedStrategy'"
color="purple"
size="small"
class="strategy-type-tag">
<a-icon type="robot" style="margin-right: 2px;" />
AI
</a-tag>
<a-tag v-if="item.strategy_mode === 'script'" size="small" color="green">
<a-icon type="code" style="margin-right: 2px;" />
{{ tt('trading-assistant.strategyMode.script', 'Script Strategy') }}
</a-tag>
<span class="info-item" v-if="item.trading_config && item.trading_config.symbol">
<a-icon type="dollar" />
{{ item.trading_config.symbol }}
</span>
</template>
<template v-else>
<span class="info-item strategy-name-text">
@@ -181,37 +168,21 @@
<a-icon type="line-chart" style="margin-right: 2px;" />
{{ item.displayInfo.indicatorName }}
</a-tag>
<a-tag v-if="item.strategy_mode === 'script'" size="small" color="green">
<a-icon type="code" style="margin-right: 2px;" />
{{ tt('trading-assistant.strategyMode.script', 'Script Strategy') }}
</a-tag>
</template>
<a-tag v-if="item.strategy_mode === 'script'" size="small" color="green" style="margin-left: 4px;">
<a-icon type="code" style="margin-right: 2px;" />
{{ tt('trading-assistant.strategyMode.script', 'Script Strategy') }}
</a-tag>
<span
class="status-label"
:class="[
item.status ? `status-${item.status}` : '',
{ 'status-stopped': item.status === 'stopped' }
]">
{{ getStatusText(item.status) }}
</span>
</div>
</div>
<div class="strategy-item-info">
<template v-if="groupByMode === 'strategy'">
<span class="info-item" v-if="item.trading_config && item.trading_config.symbol">
<a-icon type="dollar" />
{{ item.trading_config.symbol }}
</span>
<span class="info-item" v-if="item.exchange_config && item.exchange_config.exchange_id">
<a-icon type="bank" />
{{ getExchangeDisplayName(item.exchange_config.exchange_id) }}
</span>
<span class="info-item" v-if="item.trading_config && item.trading_config.timeframe">
<a-icon type="clock-circle" />
{{ item.trading_config.timeframe }}
</span>
</template>
<span
class="status-label"
:class="[
item.status ? `status-${item.status}` : '',
{ 'status-stopped': item.status === 'stopped' }
]">
{{ getStatusText(item.status) }}
</span>
</div>
</div>
<div class="strategy-item-actions" @click.stop>
<a-dropdown :getPopupContainer="getDropdownContainer" :trigger="['click']">
@@ -272,10 +243,6 @@
<a-icon type="robot" style="margin-right: 2px;" />
AI
</a-tag>
<a-tag v-if="item.strategy_mode === 'script'" size="small" color="green">
<a-icon type="code" style="margin-right: 2px;" />
{{ tt('trading-assistant.strategyMode.script', 'Script Strategy') }}
</a-tag>
</div>
</div>
<div class="strategy-item-info">
@@ -336,24 +303,8 @@
:lg="16"
:xl="16"
class="strategy-detail-col">
<div v-if="!selectedStrategy" class="strategy-empty-detail">
<div class="strategy-empty-detail-card">
<div class="strategy-empty-detail-icon">
<a-icon type="deployment-unit" />
</div>
<h3 class="strategy-empty-detail-title">
{{ tt('trading-assistant.emptyDetail.title', 'Select a strategy to inspect runtime details') }}
</h3>
<p class="strategy-empty-detail-hint">
{{ tt('trading-assistant.emptyDetail.hint', 'Open a strategy from the left list to review positions, trades, performance, and logs in one place.') }}
</p>
<div class="strategy-empty-detail-actions">
<a-button type="primary" @click="handleCreateStrategy">
<a-icon type="plus" />
{{ $t('trading-assistant.createStrategy') }}
</a-button>
</div>
</div>
<div v-if="!selectedStrategy" class="empty-detail">
<a-empty :description="$t('trading-assistant.selectStrategy')" />
</div>
<div v-else class="strategy-detail-panel">
@@ -379,8 +330,9 @@
</div>
<div class="stat-content">
<div class="stat-label">{{ $t('trading-assistant.detail.totalInvestment') }}</div>
<div class="stat-value">${{ ((selectedStrategy.initial_capital ||
selectedStrategy.trading_config?.initial_capital) || 0).toLocaleString() }}</div>
<div class="stat-value">
${{ ((selectedStrategy.initial_capital || selectedStrategy.trading_config?.initial_capital) || 0).toLocaleString() }}
</div>
</div>
</div>
<div class="stat-card" v-if="currentEquity !== null">
@@ -497,20 +449,40 @@
</a-tab-pane>
</a-tabs>
<a-modal
class="mode-selector-modal"
:visible="showModeSelector"
:title="tt('trading-assistant.selectMode', 'Select Strategy Mode')"
:width="isMobile ? '95%' : 700"
:footer="null"
:maskClosable="true"
:bodyStyle="{ padding: '16px 24px' }"
@cancel="showModeSelector = false">
<strategy-type-selector
:selected="strategyMode"
:is-dark="isDarkTheme"
@select="handleModeSelect"
@use-template="handleUseTemplate" />
</a-modal>
<!-- Create/edit strategy modal -->
<a-modal
:visible="showFormModal"
:title="editingStrategy ? $t('trading-assistant.editStrategy') : $t('trading-assistant.createStrategy')"
:width="isMobile ? '95%' : 1100"
:title="editingStrategy ? $t('trading-assistant.editStrategy') : `${$t('trading-assistant.createStrategy')}${strategyMode === 'script' ? ` - ${tt('trading-assistant.strategyMode.script', 'Script Strategy')}` : ''}`"
:width="isMobile ? '95%' : 1120"
:confirmLoading="saving"
@ok="handleSubmit"
@cancel="handleCloseModal"
:maskClosable="false"
:wrapClassName="isMobile ? 'mobile-modal' : ''"
:bodyStyle="{ maxHeight: '70vh', overflowY: 'auto' }">
:wrapClassName="strategyFormWrapClass"
:bodyStyle="{ maxHeight: '76vh', overflowY: 'auto', paddingBottom: '8px' }">
<a-spin :spinning="loadingIndicators">
<!-- Simple / Advanced mode toggle -->
<div class="creation-mode-toggle" v-if="!editingStrategy">
<div class="creation-mode-toggle" v-if="!editingStrategy && strategyMode !== 'script'">
<div class="mode-meta">
<div class="mode-title">{{ tt('trading-assistant.selectMode', 'Select Strategy Mode') }}</div>
<div class="mode-hint">{{ isSimpleMode ? $t('trading-assistant.form.simpleModeHint') : $t('trading-assistant.form.advancedModeHint') }}</div>
</div>
<a-radio-group v-model="creationMode" size="small" button-style="solid">
<a-radio-button value="simple">
<a-icon type="rocket" /> {{ $t('trading-assistant.form.simpleMode') }}
@@ -519,20 +491,135 @@
<a-icon type="setting" /> {{ $t('trading-assistant.form.advancedMode') }}
</a-radio-button>
</a-radio-group>
<span class="mode-hint">{{ isSimpleMode ? $t('trading-assistant.form.simpleModeHint') : $t('trading-assistant.form.advancedModeHint') }}</span>
</div>
<a-steps :current="displayCurrentStep" class="steps-container">
<a-step :title="isSimpleMode && !editingStrategy ? $t('trading-assistant.form.simpleStep1') : $t('trading-assistant.form.step1')" />
<a-step v-if="isAdvancedMode || editingStrategy" :title="$t('trading-assistant.form.step2Params')" />
<a-step :title="isSimpleMode && !editingStrategy ? $t('trading-assistant.form.simpleStep2') : $t('trading-assistant.form.step3Signal')" />
<template v-if="strategyMode === 'script'">
<a-step :title="$t('trading-assistant.form.simpleStep1')" />
<a-step :title="tt('trading-assistant.editor.title', 'Script Editor')" />
<a-step :title="$t('trading-assistant.form.step3Signal')" />
</template>
<template v-else>
<a-step :title="isSimpleMode && !editingStrategy ? $t('trading-assistant.form.simpleStep1') : $t('trading-assistant.form.step1')" />
<a-step v-if="isAdvancedMode || editingStrategy" :title="$t('trading-assistant.form.step2Params')" />
<a-step :title="isSimpleMode && !editingStrategy ? $t('trading-assistant.form.simpleStep2') : $t('trading-assistant.form.step3Signal')" />
</template>
</a-steps>
<div class="form-container">
<!-- Step 1: indicator selection or base strategy configuration -->
<div v-show="currentStep === 0" class="step-content">
<div v-if="strategyMode === 'script'">
<a-form :form="form" layout="vertical">
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<a-form-item :label="$t('trading-assistant.form.strategyName')">
<a-input
v-decorator="['strategy_name', { rules: [{ required: true, message: $t('trading-assistant.validation.strategyNameRequired') }] }]"
:placeholder="$t('trading-assistant.placeholders.inputStrategyName')" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item :label="$t('trading-assistant.form.symbol')">
<a-select
v-decorator="['symbol', { rules: [{ required: true, message: $t('trading-assistant.validation.symbolRequired') }] }]"
:placeholder="$t('trading-assistant.placeholders.selectSymbol')"
show-search
:filter-option="filterWatchlistOption"
:loading="loadingWatchlist"
@change="handleWatchlistSymbolChange"
:getPopupContainer="(triggerNode) => triggerNode.parentNode">
<a-select-option
v-for="item in watchlist"
:key="`${item.market}:${item.symbol}`"
:value="`${item.market}:${item.symbol}`">
<div class="symbol-option">
<a-tag :color="getMarketColor(item.market)" style="margin-right: 8px; margin-bottom: 0;">
{{ item.market }}
</a-tag>
<span class="symbol-name">{{ item.symbol }}</span>
<span v-if="item.name" class="symbol-name-extra">{{ item.name }}</span>
</div>
</a-select-option>
<a-select-option key="__add_symbol_option__" value="__add_symbol_option__" class="add-symbol-option">
<div style="width: 100%; text-align: center; padding: 4px 0; color: #1890ff; cursor: pointer;">
<a-icon type="plus" style="margin-right: 4px;" />
<span>{{ $t('trading-assistant.form.addSymbol') }}</span>
</div>
</a-select-option>
</a-select>
<div class="form-item-hint">
{{ tt('trading-assistant.editor.symbolHint', 'Script strategies currently run on a single watchlist symbol.') }}
</div>
</a-form-item>
</a-col>
</a-row>
<a-alert
type="info"
show-icon
style="margin-bottom: 16px;"
:message="tt('trading-assistant.editor.scriptModeTitle', 'Direct Python strategy mode')"
:description="tt('trading-assistant.editor.scriptModeDesc', 'The next step opens a full code editor with starter templates, API reference, and backend verification.')"
/>
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<a-form-item :label="$t('trading-assistant.form.initialCapital')">
<a-input-number
v-decorator="['initial_capital', { rules: [{ required: true, message: $t('trading-assistant.validation.initialCapitalRequired') }], initialValue: 1000 }]"
:min="10"
:step="100"
:precision="2"
style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item :label="$t('trading-assistant.form.klinePeriod')">
<a-select
v-decorator="['timeframe', { initialValue: '15m', rules: [{ required: true }] }]"
:placeholder="$t('trading-assistant.placeholders.selectKlinePeriod')"
:getPopupContainer="(triggerNode) => triggerNode.parentNode">
<a-select-option value="1m">{{ $t('trading-assistant.form.timeframe1m') }}</a-select-option>
<a-select-option value="5m">{{ $t('trading-assistant.form.timeframe5m') }}</a-select-option>
<a-select-option value="15m">{{ $t('trading-assistant.form.timeframe15m') }}</a-select-option>
<a-select-option value="30m">{{ $t('trading-assistant.form.timeframe30m') }}</a-select-option>
<a-select-option value="1H">{{ $t('trading-assistant.form.timeframe1H') }}</a-select-option>
<a-select-option value="4H">{{ $t('trading-assistant.form.timeframe4H') }}</a-select-option>
<a-select-option value="1D">{{ $t('trading-assistant.form.timeframe1D') }}</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<a-form-item :label="$t('trading-assistant.form.marketType')">
<a-radio-group
v-decorator="['market_type', { initialValue: 'swap' }]"
@change="handleMarketTypeChange">
<a-radio value="swap">{{ $t('trading-assistant.form.marketTypeFutures') }}</a-radio>
<a-radio value="spot">{{ $t('trading-assistant.form.marketTypeSpot') }}</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item :label="`${$t('trading-assistant.form.leverage')} (x)`">
<a-input-number
v-decorator="['leverage', { initialValue: 1 }]"
:min="1"
:max="form.getFieldValue('market_type') === 'spot' ? 1 : 125"
:step="1"
style="width: 100%"
:disabled="form.getFieldValue('market_type') === 'spot'" />
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
<!-- Indicator strategy: choose the technical indicator -->
<div v-if="strategyType === 'indicator'">
<div v-else-if="strategyType === 'indicator'">
<a-form :form="form" layout="vertical">
<a-form-item :label="$t('trading-assistant.form.indicator')">
<a-select
@@ -940,8 +1027,16 @@
<!-- Step 2: params (backtest-like / trading params) -->
<div v-show="currentStep === 1 || (isSimpleMode && currentStep === 0 && showAdvancedSettings)" class="step-content">
<script-strategy-editor
v-if="strategyMode === 'script' && currentStep === 1"
v-model="strategyCode"
:active="currentStep === 1"
:is-dark="isDarkTheme"
:initial-template-key="scriptInitialTemplateKey"
:template-apply-nonce="scriptTemplateApplyNonce" />
<!-- Indicator strategy: strategy parameters -->
<div v-if="strategyType === 'indicator'">
<div v-else-if="strategyType === 'indicator'">
<a-form :form="form" layout="vertical">
<!-- Backtest-like configuration (aligned with indicator-analysis BacktestModal) -->
<a-collapse v-model="backtestCollapseKeys" :bordered="false" style="background: #fafafa;">
@@ -1719,6 +1814,8 @@ import TradingRecords from './components/TradingRecords.vue'
import PositionRecords from './components/PositionRecords.vue'
import PerformanceAnalysis from './components/PerformanceAnalysis.vue'
import StrategyLogs from './components/StrategyLogs.vue'
import StrategyTypeSelector from './components/StrategyTypeSelector.vue'
import ScriptStrategyEditor from './components/ScriptStrategyEditor.vue'
// Common crypto trading pairs
const CRYPTO_SYMBOLS = [
@@ -1767,30 +1864,29 @@ export default {
TradingRecords,
PositionRecords,
PerformanceAnalysis,
StrategyLogs
StrategyLogs,
StrategyTypeSelector,
ScriptStrategyEditor
},
computed: {
showAssistantGuide () {
return !this.assistantGuideDismissed
},
assistantGuideStorageKey () {
const userId = (this.$store.getters.userInfo && this.$store.getters.userInfo.id) || 'guest'
return `trading-assistant-guide-dismissed:${userId}`
},
isAdvancedMode () {
return this.creationMode === 'advanced'
},
isSimpleMode () {
return this.creationMode === 'simple'
},
// Map the internal step index to the displayed step in simple mode.
displayCurrentStep () {
if (this.strategyMode === 'script') {
return this.currentStep
}
if (this.isSimpleMode && !this.editingStrategy) {
// Simple mode maps step 0 -> 0 and step 2 -> 1 because step 1 is skipped.
return this.currentStep === 0 ? 0 : 1
}
return this.currentStep
},
strategyFormWrapClass () {
return this.isMobile ? 'mobile-modal strategy-form-modal' : 'strategy-form-modal'
},
isDarkTheme () {
return this.navTheme === 'dark' || this.navTheme === 'realdark'
},
@@ -2093,15 +2189,18 @@ export default {
loadingRecords: false,
strategies: [],
selectedStrategy: null,
showModeSelector: false,
showFormModal: false,
// Simple / Advanced creation mode
creationMode: 'simple', // 'simple' or 'advanced'
showAdvancedSettings: false,
// Only indicator strategy in local mode
strategyType: 'indicator',
strategyMode: 'indicator',
selectedMarketCategory: 'Crypto', // USStock / Crypto / Forex / Futures
currentStep: 0,
saving: false,
strategyCode: '',
scriptInitialTemplateKey: 'ma_cross',
scriptTemplateApplyNonce: 0,
loadingIndicators: false,
availableIndicators: [],
selectedIndicator: null,
@@ -2173,8 +2272,7 @@ export default {
hotSymbols: [],
loadingHotSymbols: false,
searchTimer: null,
lastAutoStrategyName: '',
assistantGuideDismissed: false
lastAutoStrategyName: ''
// Market category is inferred from Step 1 watchlist symbol ("Market:SYMBOL").
}
},
@@ -2182,7 +2280,6 @@ export default {
this.form = this.$form.createForm(this)
},
mounted () {
this.restoreAssistantGuidePreference()
if (
this.$route &&
(
@@ -2205,19 +2302,6 @@ export default {
const translated = this.$t(key, params)
return translated !== key ? translated : fallback
},
restoreAssistantGuidePreference () {
try {
this.assistantGuideDismissed = window.localStorage.getItem(this.assistantGuideStorageKey) === '1'
} catch (e) {
this.assistantGuideDismissed = false
}
},
dismissAssistantGuide () {
this.assistantGuideDismissed = true
try {
window.localStorage.setItem(this.assistantGuideStorageKey, '1')
} catch (e) {}
},
goToStrategyTab () {
this.topTab = 'strategy'
},
@@ -2227,6 +2311,77 @@ export default {
this.handleCreateStrategy()
})
},
openStrategyForm (mode = 'indicator', options = {}) {
this.isEditMode = false
this.editingStrategy = null
this.strategyType = 'indicator'
this.strategyMode = mode
this.creationMode = mode === 'script' ? 'advanced' : 'simple'
this.currentStep = 0
this.currentExchangeId = ''
this.currentBrokerId = 'ibkr'
this.selectedIndicator = null
this.testResult = null
this.connectionTestResult = null
this.executionModeUi = 'signal'
this.notifyChannelsUi = ['browser']
this.saveCredentialUi = false
this.backtestCollapseKeys = ['risk']
this.trailingEnabledUi = false
this.entryPctMaxUi = 100
this.aiFilterEnabledUi = false
this.selectedMarketCategory = 'Crypto'
this.selectedSymbols = []
this.crossSectionalSymbols = []
this.showAdvancedSettings = false
this.showModeSelector = false
this.strategyCode = ''
this.scriptInitialTemplateKey = options.templateKey || 'ma_cross'
this.scriptTemplateApplyNonce = options.autoApplyTemplate ? Date.now() : 0
const defaultName = mode === 'script'
? this.tt('trading-assistant.editor.defaultStrategyName', 'Script Strategy')
: this.buildStrategyDefaultName()
this.lastAutoStrategyName = defaultName
this.form.resetFields()
this.form.setFieldsValue({
strategy_name: defaultName,
execution_mode: 'signal',
notify_channels: ['browser'],
save_credential: false,
live_disclaimer_ack: false,
initial_capital: 1000,
market_type: 'swap',
leverage: mode === 'script' ? 1 : 5,
trade_direction: 'long',
timeframe: '15m',
cs_strategy_type: 'single'
})
this.liveDisclaimerAckUi = false
this.showFormModal = true
this.$nextTick(() => {
this.loadWatchlist()
this.loadExchangeCredentials()
if (mode !== 'script') {
this.loadIndicators()
}
if (options.symbol) {
this.form.setFieldsValue({ symbol: options.symbol })
this.handleWatchlistSymbolChange(options.symbol)
}
})
},
handleModeSelect (mode) {
this.openStrategyForm(mode || 'indicator')
},
handleUseTemplate (payload = {}) {
this.openStrategyForm(payload.mode || 'script', {
templateKey: payload.templateKey || 'ma_cross',
autoApplyTemplate: true
})
},
goToIndicatorAnalysisCreate () {
this.$router.push('/indicator-analysis')
},
@@ -2780,51 +2935,8 @@ export default {
}
},
handleCreateStrategy () {
// Local mode: open indicator strategy modal directly
this.isEditMode = false
this.editingStrategy = null
this.strategyType = 'indicator'
this.currentStep = 0
this.currentExchangeId = ''
this.currentBrokerId = 'ibkr'
this.selectedIndicator = null
this.testResult = null
this.connectionTestResult = null
this.executionModeUi = 'signal'
this.notifyChannelsUi = ['browser']
this.saveCredentialUi = false
this.backtestCollapseKeys = ['risk']
this.trailingEnabledUi = false
this.entryPctMaxUi = 100
this.aiFilterEnabledUi = false
this.selectedMarketCategory = 'Crypto'
this.selectedSymbols = []
this.showAdvancedSettings = false
const defaultName = this.buildStrategyDefaultName()
this.lastAutoStrategyName = defaultName
this.form.resetFields()
this.form.setFieldsValue({
strategy_name: defaultName,
execution_mode: 'signal',
notify_channels: ['browser'],
save_credential: false,
live_disclaimer_ack: false,
initial_capital: 1000,
market_type: 'swap',
leverage: 5,
trade_direction: 'long',
timeframe: '15m',
cs_strategy_type: 'single'
})
this.liveDisclaimerAckUi = false
this.showFormModal = true
this.$nextTick(() => {
this.loadWatchlist()
this.loadIndicators()
this.loadExchangeCredentials()
})
this.strategyMode = 'indicator'
this.showModeSelector = true
},
async handleRouteCreateStrategyPrefill () {
const query = (this.$route && this.$route.query) || {}
@@ -2836,7 +2948,7 @@ export default {
}
this.topTab = 'strategy'
this.handleCreateStrategy()
this.openStrategyForm('indicator')
await this.$nextTick()
await Promise.all([
this.loadIndicators(),
@@ -2885,8 +2997,10 @@ export default {
return
}
// Local mode: indicator strategy only
const isScriptStrategy = strategy.strategy_mode === 'script' || strategy.strategy_type === 'ScriptStrategy'
this.strategyType = 'indicator'
this.strategyMode = isScriptStrategy ? 'script' : (strategy.strategy_mode || 'indicator')
this.creationMode = 'advanced'
this.isEditMode = true
this.editingStrategy = strategy
@@ -2900,6 +3014,14 @@ export default {
this.trailingEnabledUi = false
this.entryPctMaxUi = 100
this.aiFilterEnabledUi = false
this.showAdvancedSettings = false
this.executionModeUi = 'signal'
this.notifyChannelsUi = ['browser']
this.saveCredentialUi = false
this.liveDisclaimerAckUi = false
this.strategyCode = strategy.strategy_code || ''
this.scriptInitialTemplateKey = 'ma_cross'
this.scriptTemplateApplyNonce = 0
// IMPORTANT:
// Ensure modal is visible BEFORE filling form values, otherwise some fields are not registered yet
@@ -2910,14 +3032,16 @@ export default {
this.$nextTick(async () => {
// Keep data sources in sync (same as create flow)
this.loadWatchlist()
this.loadIndicators()
if (!isScriptStrategy) {
this.loadIndicators()
}
this.loadExchangeCredentials()
await this.loadStrategyDataToForm(strategy)
})
},
async loadStrategyDataToForm (strategy) {
// Ensure the indicator catalog is available before filling the form.
if (!this.indicatorsLoaded) {
if (this.strategyMode !== 'script' && !this.indicatorsLoaded) {
await this.loadIndicators()
}
@@ -3003,8 +3127,12 @@ export default {
notify_webhook: strategy.notification_config?.targets?.webhook || ''
})
if (this.strategyMode === 'script') {
this.strategyCode = strategy.strategy_code || ''
}
// Restore indicator selection and saved parameter values.
if (strategy.indicator_config && strategy.indicator_config.indicator_id) {
if (this.strategyMode !== 'script' && strategy.indicator_config && strategy.indicator_config.indicator_id) {
// Match by stringified id to avoid number/string mismatch issues.
const targetId = strategy.indicator_config.indicator_id
const indicator = this.availableIndicators.find(ind => {
@@ -3243,10 +3371,12 @@ export default {
}
},
handleCloseModal () {
this.showModeSelector = false
this.showFormModal = false
this.editingStrategy = null
this.isEditMode = false
this.strategyType = 'indicator'
this.strategyMode = 'indicator'
this.currentStep = 0
this.currentExchangeId = ''
this.selectedIndicator = null
@@ -3262,6 +3392,9 @@ export default {
this.executionModeUi = 'signal'
this.liveDisclaimerAckUi = false
this.lastAutoStrategyName = ''
this.strategyCode = ''
this.scriptInitialTemplateKey = 'ma_cross'
this.scriptTemplateApplyNonce = 0
this.form.resetFields()
},
@@ -4018,42 +4151,55 @@ export default {
},
// Form step control
handleNext () {
if (this.strategyMode === 'script') {
if (this.currentStep === 0) {
this.form.validateFields(['strategy_name', 'symbol', 'initial_capital', 'timeframe'], (err) => {
if (!err) {
this.currentStep = 1
}
})
} else if (this.currentStep === 1) {
if (!this.strategyCode || this.strategyCode.trim().length < 20) {
this.$message.warning(this.tt('trading-assistant.editor.codeHint', 'Please add a valid strategy before continuing'))
return
}
try {
const execMode = this.form.getFieldValue('execution_mode') || 'signal'
this.executionModeUi = execMode
const chans = this.form.getFieldValue('notify_channels') || ['browser']
this.notifyChannelsUi = Array.isArray(chans) ? chans : ['browser']
} catch (e) { }
this.currentStep = 2
}
return
}
if (this.currentStep === 0) {
// Step 1: basic config
// In simple mode, only validate indicator_id, strategy_name, and symbols
// In advanced mode, also validate capital/leverage/market_type/direction/timeframe
const fieldsToValidate = ['indicator_id', 'strategy_name']
if (this.isAdvancedMode || this.editingStrategy) {
fieldsToValidate.push('initial_capital', 'market_type', 'leverage', 'trade_direction', 'timeframe')
}
// Edit mode still needs to validate the symbol field.
if (this.isEditMode) {
fieldsToValidate.push('symbol')
}
this.form.validateFields(fieldsToValidate, (err, values) => {
if (err) return
// In create mode, validate the selected symbol collection.
if (!this.isEditMode) {
const strategyType = this.form.getFieldValue('cs_strategy_type') || 'single'
if (strategyType === 'cross_sectional') {
// Cross-sectional strategies require a non-empty basket.
if (!this.crossSectionalSymbols || this.crossSectionalSymbols.length === 0) {
this.$message.warning(this.$t('trading-assistant.validation.symbolsRequired'))
return
}
} else {
// Single-symbol batch creation still requires at least one symbol.
if (!this.selectedSymbols || this.selectedSymbols.length === 0) {
this.$message.warning(this.$t('trading-assistant.validation.symbolsRequired'))
return
}
} else if (!this.selectedSymbols || this.selectedSymbols.length === 0) {
this.$message.warning(this.$t('trading-assistant.validation.symbolsRequired'))
return
}
}
// Enforce spot limitations
try {
const marketType = (values && values.market_type) || this.form.getFieldValue('market_type')
if (marketType === 'spot') {
@@ -4061,7 +4207,6 @@ export default {
}
} catch (e) { }
// Init backtest-like UI states for Step 2 (Ant Form is not reactive).
this.backtestCollapseKeys = ['risk']
try {
this.trailingEnabledUi = !!this.form.getFieldValue('trailing_enabled')
@@ -4073,7 +4218,6 @@ export default {
this.normalizeEntryPct()
})
// In simple mode: skip step 1 (params) and jump directly to step 2 (execution)
if (this.isSimpleMode && !this.editingStrategy) {
this.currentStep = 2
} else {
@@ -4081,8 +4225,6 @@ export default {
}
})
} else if (this.currentStep === 1) {
// Step 2: backtest-like configs are optional; proceed directly.
// Sync UI states from form (Ant Form values are not always reactive)
try {
const execMode = this.form.getFieldValue('execution_mode') || 'signal'
this.executionModeUi = execMode
@@ -4094,7 +4236,10 @@ export default {
},
handlePrev () {
if (this.currentStep > 0) {
// In simple mode: from step 2 (execution) go back to step 0 (basic), skipping step 1 (params)
if (this.strategyMode === 'script') {
this.currentStep--
return
}
if (this.isSimpleMode && !this.editingStrategy && this.currentStep === 2) {
this.currentStep = 0
} else {
@@ -4148,7 +4293,82 @@ export default {
return
}
// Indicator strategy submit logic (local mode)
if (this.strategyMode === 'script') {
const rawSymbolValue = values.symbol || ''
let marketCategory = this.selectedMarketCategory || 'Crypto'
let symbol = rawSymbolValue
if (typeof rawSymbolValue === 'string' && rawSymbolValue.includes(':')) {
const separatorIndex = rawSymbolValue.indexOf(':')
marketCategory = rawSymbolValue.slice(0, separatorIndex) || marketCategory
symbol = rawSymbolValue.slice(separatorIndex + 1)
}
const marketType = (values.market_type === 'futures' ? 'swap' : (values.market_type || 'swap'))
const leverage = marketType === 'spot' ? 1 : (values.leverage || 1)
const exchangeConfig = isLive
? (this.isIBKRMarket
? {
exchange_id: values.broker_id || this.currentBrokerId || 'ibkr',
ibkr_host: values.ibkr_host || '127.0.0.1',
ibkr_port: values.ibkr_port || 7497,
ibkr_client_id: values.ibkr_client_id || 1,
ibkr_account: values.ibkr_account || ''
}
: this.isMT5Market
? {
exchange_id: values.forex_broker_id || this.currentBrokerId || 'mt5',
mt5_server: values.mt5_server || '',
mt5_login: values.mt5_login || '',
mt5_password: values.mt5_password || '',
mt5_terminal_path: values.mt5_terminal_path || ''
}
: {
exchange_id: values.exchange_id,
credential_id: values.credential_id,
api_key: values.api_key,
secret_key: values.secret_key,
enableDemoTrading: !!values.enable_demo_trading,
passphrase: this.needsPassphrase ? values.passphrase : undefined
})
: undefined
const payload = {
strategy_name: values.strategy_name,
strategy_type: 'ScriptStrategy',
strategy_mode: 'script',
strategy_code: this.strategyCode,
market_category: marketCategory,
execution_mode: values.execution_mode || 'signal',
notification_config: notificationConfig,
exchange_config: exchangeConfig,
trading_config: {
initial_capital: values.initial_capital || 1000,
leverage: leverage,
timeframe: values.timeframe || '15m',
market_type: marketType,
symbol: symbol
}
}
let res
if (this.editingStrategy) {
res = await updateStrategy(this.editingStrategy.id, payload)
} else {
payload.user_id = 1
res = await createStrategy(payload)
}
if (res.code === 1) {
this.$message.success(this.$t(this.editingStrategy ? 'trading-assistant.messages.updateSuccess' : 'trading-assistant.messages.createSuccess'))
this.handleRefresh()
} else {
this.$message.error(res.msg || this.$t(this.editingStrategy ? 'trading-assistant.messages.updateFailed' : 'trading-assistant.messages.createFailed'))
}
this.saving = false
return
}
const indicatorIdStr = String(values.indicator_id)
const indicator = this.availableIndicators.find(ind => String(ind.id) === indicatorIdStr)
if (!indicator) {
@@ -4517,63 +4737,6 @@ export default {
font-weight: 600;
}
.strategy-empty-detail {
display: flex;
align-items: center;
justify-content: center;
min-height: 100%;
padding: 24px;
}
.strategy-empty-detail-card {
width: 100%;
max-width: 520px;
padding: 36px 32px;
text-align: center;
border-radius: 26px;
border: 1px solid #dce7f3;
background:
radial-gradient(circle at top, rgba(59, 130, 246, 0.1), transparent 42%),
linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.08);
}
.strategy-empty-detail-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
margin: 0 auto 18px;
border-radius: 22px;
background: linear-gradient(135deg, #2563eb 0%, #10b981 100%);
color: #fff;
font-size: 30px;
box-shadow: 0 14px 24px rgba(37, 99, 235, 0.22);
}
.strategy-empty-detail-title {
margin: 0;
color: #0f172a;
font-size: 24px;
font-weight: 700;
line-height: 1.3;
}
.strategy-empty-detail-hint {
max-width: 420px;
margin: 12px auto 0;
color: #64748b;
font-size: 14px;
line-height: 1.7;
}
.strategy-empty-detail-actions {
display: flex;
justify-content: center;
margin-top: 22px;
}
// Mobile layout adjustments
@media (max-width: 768px) {
min-height: auto;
@@ -5126,9 +5289,6 @@ export default {
flex-shrink: 0;
}
&.strategy-list-item--strategy-group {
margin-left: 14px;
}
}
}
}
@@ -5188,10 +5348,6 @@ export default {
transition: color 0.2s ease;
}
&.strategy-name-wrapper--grouped {
flex-wrap: wrap;
}
.exchange-tag {
flex-shrink: 0;
display: inline-flex;
@@ -5788,6 +5944,10 @@ export default {
background: rgba(24, 144, 255, 0.08);
border-color: rgba(24, 144, 255, 0.2);
.mode-title {
color: rgba(255, 255, 255, 0.88);
}
.mode-hint {
color: rgba(255, 255, 255, 0.45);
}
@@ -5866,22 +6026,6 @@ export default {
// Right strategy detail card
.strategy-detail-col {
.strategy-empty-detail-card {
border-color: rgba(59, 130, 246, 0.18);
background:
radial-gradient(circle at top, rgba(37, 99, 235, 0.16), transparent 42%),
linear-gradient(180deg, #161b22 0%, #111827 100%);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.24);
}
.strategy-empty-detail-title {
color: #f8fafc;
}
.strategy-empty-detail-hint {
color: #94a3b8;
}
.strategy-header-card {
background: linear-gradient(135deg, #1e222d 0%, #1a1e28 100%);
border: 1px solid rgba(255, 255, 255, 0.06);
@@ -6069,6 +6213,7 @@ export default {
.creation-mode-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
padding: 8px 12px;
@@ -6076,9 +6221,20 @@ export default {
border-radius: 8px;
border: 1px solid rgba(24, 144, 255, 0.12);
.mode-meta {
min-width: 0;
}
.mode-title {
font-size: 13px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
.mode-hint {
color: rgba(0, 0, 0, 0.45);
font-size: 12px;
margin-top: 2px;
}
}
+3 -3
View File
@@ -55,7 +55,7 @@ const vueConfig = {
})
]
// en_US: `if prod, add externals`
// zh_CN: `这里是用来控制编译忽略外部依赖的,与 config.plugin('html') 配合可以编译时引入外部CDN文件依赖`
// zh_CN: used to control ignored external dependencies during compilation, together with config.plugin('html') to inject external CDN files
// externals: isProd ? assetsCDN.externals : {}
},
@@ -95,7 +95,7 @@ const vueConfig = {
.end()
// en_US: If prod is on assets require on cdn
// zh_CN: 如果是 prod 模式,则引入 CDN 依赖文件,有需要减少包大小请自行解除依赖
// zh_CN: in prod mode, inject CDN dependencies. Remove them if you need to reduce bundle size
//
// if (isProd) {
// config.plugin('html').tap(args => {
@@ -147,5 +147,5 @@ vueConfig.configureWebpack.plugins.push(createThemeColorReplacerPlugin())
module.exports = vueConfig
// vue.config.js
// module.exports = {
// lintOnSave: false // 禁用 ESLint
// lintOnSave: false // Disable ESLint
// }