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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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
@@ -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()"
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* feat:新增功能
|
||||
* fix:bug 修复
|
||||
* docs:文档更新
|
||||
* style:不影响程序逻辑的代码修改(修改空白字符,格式缩进,补全缺失的分号等,没有改变代码逻辑)
|
||||
* refactor:重构代码(既没有新增功能,也没有修复 bug)
|
||||
* perf:性能, 体验优化
|
||||
* test:新增测试用例或是更新现有测试
|
||||
* build:主要目的是修改项目构建系统(例如 glup,webpack,rollup 的配置等)的提交
|
||||
* ci:主要目的是修改项目继续集成流程(例如 Travis,Jenkins,GitLab CI,Circle等)的提交
|
||||
* 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 = {
|
||||
|
||||
@@ -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':
|
||||
|
||||
Vendored
+37
-37
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {*}
|
||||
*/
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}]
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// 标题
|
||||
// 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)
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+2
-2
@@ -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)
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
// 动态计算footer高度,并设置drawer body的padding
|
||||
// Compute the footer height dynamically and apply drawer body padding
|
||||
const footerHeight = menuFooter.offsetHeight || 280
|
||||
const drawerBody = drawer.querySelector('.ant-drawer-body')
|
||||
if (drawerBody) {
|
||||
// 设置CSS变量,供CSS使用
|
||||
// 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 body的padding
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 success,please 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': 'Account:registered 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. 【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.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/># 【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.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).'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
// 更新进度 - 大约12秒走完95%
|
||||
// Advance toward 95% over roughly 12 seconds, then wait for the real completion event.
|
||||
if (this.progress < 75) {
|
||||
// 前75%:每100ms增加1% (约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%:每100ms增加0.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%:每100ms增加0.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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
// 如果值为0且是开仓信号(open_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-8),视为0
|
||||
// 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 = () => {
|
||||
// 获取所有默认指标的id列表(包括smaGroup、emaGroup)
|
||||
// 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
// }
|
||||
|
||||
Reference in New Issue
Block a user