V2.2.1: Membership & Billing, USDT TRC20 payment, VIP free indicators, AI Trading Radar, simplified strategy creation, system settings simplification, bug fixes and UI improvements

This commit is contained in:
TIANHE
2026-02-27 01:57:04 +08:00
parent ae82cc0d4e
commit ffdd2ffbae
71 changed files with 4067 additions and 6073 deletions
+39
View File
@@ -0,0 +1,39 @@
import request from '@/utils/request'
const billingApi = {
Plans: '/api/billing/plans',
Purchase: '/api/billing/purchase',
UsdtCreate: '/api/billing/usdt/create',
UsdtOrder: (id) => `/api/billing/usdt/order/${id}`
}
export function getMembershipPlans () {
return request({
url: billingApi.Plans,
method: 'get'
})
}
export function purchaseMembership (plan) {
return request({
url: billingApi.Purchase,
method: 'post',
data: { plan }
})
}
export function createUsdtOrder (plan) {
return request({
url: billingApi.UsdtCreate,
method: 'post',
data: { plan }
})
}
export function getUsdtOrder (orderId, refresh = true) {
return request({
url: billingApi.UsdtOrder(orderId),
method: 'get',
params: { refresh: refresh ? 1 : 0 }
})
}
+3 -2
View File
@@ -61,10 +61,11 @@ export function getMarketSentiment () {
/**
* Get trading opportunities based on technical analysis
*/
export function getTradingOpportunities () {
export function getTradingOpportunities (params) {
return request({
url: `${BASE_URL}/opportunities`,
method: 'get'
method: 'get',
params
})
}
@@ -21,8 +21,16 @@ export const asyncRouterMap = [
path: '/ai-analysis/:pageNo([1-9]\\d*)?',
name: 'Analysis',
component: () => import('@/views/ai-analysis'),
hidden: true,
meta: { title: 'menu.dashboard.analysis', keepAlive: false, icon: 'thunderbolt', permission: ['dashboard'] }
},
// AI资产分析(统一入口)
{
path: '/ai-asset-analysis',
name: 'AIAssetAnalysis',
component: () => import('@/views/ai-asset-analysis'),
meta: { title: 'menu.dashboard.aiAssetAnalysis', keepAlive: false, icon: 'appstore', permission: ['dashboard'] }
},
// 指标分析
{
path: '/indicator-analysis',
@@ -49,6 +57,7 @@ export const asyncRouterMap = [
path: '/portfolio',
name: 'Portfolio',
component: () => import('@/views/portfolio'),
hidden: true,
meta: { title: 'menu.dashboard.portfolio', keepAlive: true, icon: 'fund', permission: ['dashboard'] }
},
// 用户管理 (admin only)
@@ -65,6 +74,13 @@ export const asyncRouterMap = [
component: () => import('@/views/profile'),
meta: { title: 'menu.myProfile', keepAlive: false, icon: 'user', permission: ['dashboard'] }
},
// 会员/充值
{
path: '/billing',
name: 'Billing',
component: () => import('@/views/billing'),
meta: { title: 'menu.billing', keepAlive: false, icon: 'wallet', permission: ['dashboard'] }
},
// 系统设置 (admin only) - 放在最后
{
path: '/settings',
+1 -1
View File
@@ -119,7 +119,7 @@
</div>
<!-- 版本号 -->
<div class="footer-section version">
V2.1.3
V2.2.1
</div>
</div>
</div>
@@ -420,9 +420,7 @@ const locale = {
'dashboard.analysis.modal.addStock.willAutoFetchName': 'سيحصل النظام تلقائيًا على الاسم',
'dashboard.analysis.modal.addStock.addDirectly': 'أضف مباشرة',
'dashboard.analysis.modal.addStock.nameWillBeFetched': 'سيتم التقاط الاسم تلقائيًا عند إضافته',
'dashboard.analysis.market.AShare': 'أسهم',
'dashboard.analysis.market.USStock': 'الأسهم الأمريكية',
'dashboard.analysis.market.HShare': 'أسهم هونج كونج',
'dashboard.analysis.market.Crypto': 'عملة مشفرة',
'dashboard.analysis.market.Forex': 'الفوركس',
'dashboard.analysis.market.Futures': 'العقود الآجلة',
@@ -576,9 +574,7 @@ const locale = {
'dashboard.indicator.drawing.priceChannel': 'قناة السعر',
'dashboard.indicator.drawing.fibonacciLine': 'خطوط فيبوناتشي',
'dashboard.indicator.drawing.clearAll': 'مسح كافة الرسومات الخطية',
'dashboard.indicator.market.AShare': 'أسهم',
'dashboard.indicator.market.USStock': 'الأسهم الأمريكية',
'dashboard.indicator.market.HShare': 'أسهم هونج كونج',
'dashboard.indicator.market.Crypto': 'عملة مشفرة',
'dashboard.indicator.market.Forex': 'الفوركس',
'dashboard.indicator.market.Futures': 'العقود الآجلة',
@@ -421,9 +421,7 @@ const locale = {
'dashboard.analysis.modal.addStock.willAutoFetchName': 'Das System erhält den Namen automatisch',
'dashboard.analysis.modal.addStock.addDirectly': 'Direkt hinzufügen',
'dashboard.analysis.modal.addStock.nameWillBeFetched': 'Der Name wird beim Hinzufügen automatisch übernommen',
'dashboard.analysis.market.AShare': 'A-Aktien',
'dashboard.analysis.market.USStock': 'US-Aktien',
'dashboard.analysis.market.HShare': 'Aktien aus Hongkong',
'dashboard.analysis.market.Crypto': 'Kryptowährung',
'dashboard.analysis.market.Forex': 'Forex',
'dashboard.analysis.market.Futures': 'Futures',
@@ -577,9 +575,7 @@ const locale = {
'dashboard.indicator.drawing.priceChannel': 'Preiskanal',
'dashboard.indicator.drawing.fibonacciLine': 'Fibonacci-Linien',
'dashboard.indicator.drawing.clearAll': 'Löschen Sie alle Strichzeichnungen',
'dashboard.indicator.market.AShare': 'A-Aktien',
'dashboard.indicator.market.USStock': 'US-Aktien',
'dashboard.indicator.market.HShare': 'Aktien aus Hongkong',
'dashboard.indicator.market.Crypto': 'Kryptowährung',
'dashboard.indicator.market.Forex': 'Forex',
'dashboard.indicator.market.Futures': 'Futures',
@@ -1022,7 +1018,6 @@ const locale = {
'trading-assistant.form.step3Signal': 'Signalzustellung',
'trading-assistant.form.marketCategory': 'Marktkategorie',
'trading-assistant.form.marketCategoryHint': 'Wählen Sie zuerst den Markt. Nur Krypto kann Live-Trading aktivieren; andere Märkte sind nur Signal.',
'trading-assistant.market.AShare': 'A-Aktien',
'trading-assistant.market.USStock': 'US-Aktien',
'trading-assistant.market.Crypto': 'Krypto',
'trading-assistant.market.Forex': 'Forex',
+172 -12
View File
@@ -17,6 +17,8 @@ const locale = {
'common.add': 'Add',
'common.close': 'Close',
'common.ok': 'OK',
'common.loading': 'Loading...',
'common.noData': 'No data',
'submit': 'Submit',
'save': 'Save',
'submit.ok': 'Submit successfully',
@@ -25,6 +27,7 @@ const locale = {
'menu.home': 'Home',
'menu.dashboard': 'Dashboard',
'menu.dashboard.analysis': 'AI Analysis',
'menu.dashboard.aiAssetAnalysis': 'AI Asset Analysis',
'menu.dashboard.aiQuant': 'AI Quant',
'menu.dashboard.indicator': 'Indicator Analysis',
'menu.dashboard.community': 'Indicator Market',
@@ -36,6 +39,76 @@ const locale = {
'menu.dashboard.signalRobot': 'Signal Robot',
'menu.dashboard.monitor': 'Monitor',
'menu.dashboard.workplace': 'Workplace',
'aiAssetAnalysis.title': 'AI Asset Analysis',
'aiAssetAnalysis.subtitle': 'Unify portfolio monitoring, instant analysis, and scheduled tasks into one smooth workflow.',
'aiAssetAnalysis.actions.quickAnalysis': 'Start Analysis Now',
'aiAssetAnalysis.actions.monitorTasks': 'Manage Monitor Tasks',
'aiAssetAnalysis.actions.openStandalone': 'Open Standalone Page',
'aiAssetAnalysis.quickBar.title': 'Quick Analyze',
'aiAssetAnalysis.quickBar.placeholder': 'Select a symbol from asset pool',
'aiAssetAnalysis.quickBar.useInQuick': 'Use in Instant Analysis',
'aiAssetAnalysis.quickBar.runNow': 'Analyze Now',
'aiAssetAnalysis.history.title': 'Recent Analysis',
'aiAssetAnalysis.history.empty': 'No analysis history yet',
'aiAssetAnalysis.actions.enterQuick': 'Go to Instant Analysis',
'aiAssetAnalysis.actions.enterMonitor': 'Go to Asset Pool & Tasks',
'aiAssetAnalysis.flow.poolTitle': 'Build Asset Pool',
'aiAssetAnalysis.flow.poolDesc': 'Add positions and watch targets first to build one unified analysis pool.',
'aiAssetAnalysis.flow.poolAction': 'Manage Asset Pool',
'aiAssetAnalysis.flow.quickTitle': 'Instant Analysis',
'aiAssetAnalysis.flow.quickDesc': 'Run AI analysis for any symbol in one click and get suggestions quickly.',
'aiAssetAnalysis.flow.quickAction': 'Run Analysis',
'aiAssetAnalysis.flow.autoTitle': 'Scheduled Monitoring',
'aiAssetAnalysis.flow.autoDesc': 'Set recurring AI analysis tasks and deliver reports through notification channels.',
'aiAssetAnalysis.flow.autoAction': 'Configure Tasks',
'aiAssetAnalysis.tabs.quick': 'Instant Analysis',
'aiAssetAnalysis.tabs.monitor': 'Asset Pool & Scheduled Tasks',
'aiAssetAnalysis.tabLead.quick': 'Best for ad-hoc decisions: select a symbol and analyze immediately.',
'aiAssetAnalysis.tabLead.monitor': 'Best for continuous tracking: maintain positions and set monitor ranges/frequency.',
// AI Asset Analysis - Performance Stats
'aiAssetAnalysis.stats.totalAnalyses': 'Total Analyses',
'aiAssetAnalysis.stats.accuracy': 'Accuracy',
'aiAssetAnalysis.stats.avgReturn': 'Avg Return',
'aiAssetAnalysis.stats.satisfaction': 'Satisfaction',
'aiAssetAnalysis.stats.decisions': 'Decision Dist.',
// AI Asset Analysis - Trading Opportunities
'aiAssetAnalysis.opportunities.title': 'AI Opportunity Radar',
'aiAssetAnalysis.opportunities.empty': 'No opportunities found',
'aiAssetAnalysis.opportunities.analyze': 'Analyze',
'aiAssetAnalysis.opportunities.updateHint': 'Updates hourly',
'aiAssetAnalysis.opportunities.signal.overbought': 'Overbought',
'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.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',
'aiAssetAnalysis.opportunities.reason.crypto.bearish_momentum': '24h -{change}%, clear bearish trend',
'aiAssetAnalysis.opportunities.reason.usstock.overbought': 'Day +{change}%, large gain, watch for pullback',
'aiAssetAnalysis.opportunities.reason.usstock.bullish_momentum': 'Day +{change}%, strong bullish momentum',
'aiAssetAnalysis.opportunities.reason.usstock.oversold': 'Day -{change}%, possible oversold bounce',
'aiAssetAnalysis.opportunities.reason.usstock.bearish_momentum': 'Day -{change}%, clear bearish trend',
'aiAssetAnalysis.opportunities.reason.forex.overbought': 'Day +{change}%, volatile, watch for reversal',
'aiAssetAnalysis.opportunities.reason.forex.bullish_momentum': 'Day +{change}%, bullish momentum',
'aiAssetAnalysis.opportunities.reason.forex.oversold': 'Day -{change}%, volatile, possible bounce',
'aiAssetAnalysis.opportunities.reason.forex.bearish_momentum': 'Day -{change}%, clear bearish trend',
// AI Asset Analysis - Portfolio Checkup
'aiAssetAnalysis.checkup.title': 'Portfolio Checkup',
'aiAssetAnalysis.checkup.btn': 'Checkup',
'aiAssetAnalysis.checkup.desc': 'Run AI analysis on all positions in your portfolio to quickly understand the current status and get recommendations for each asset.',
'aiAssetAnalysis.checkup.start': 'Start Checkup',
'aiAssetAnalysis.checkup.progress': 'Analyzing {current}/{total}...',
'aiAssetAnalysis.checkup.noPositions': 'No positions in your portfolio. Please add assets first.',
'aiAssetAnalysis.checkup.complete': 'Checkup Complete',
'aiAssetAnalysis.checkup.failed': 'Failed',
'aiAssetAnalysis.checkup.rerun': 'Run Again',
// AI Asset Analysis - Global Search
'aiAssetAnalysis.search.hint': 'Search',
'aiAssetAnalysis.search.placeholder': 'Search any symbol... (Ctrl+K)',
'aiAssetAnalysis.search.noResults': 'No results found. Try a different keyword.',
'menu.form': 'Form',
'menu.form.basic-form': 'Basic Form',
'menu.form.step-form': 'Step Form',
@@ -558,9 +631,7 @@ const locale = {
'dashboard.analysis.modal.addStock.willAutoFetchName': 'Name will be fetched automatically',
'dashboard.analysis.modal.addStock.addDirectly': 'Add Directly',
'dashboard.analysis.modal.addStock.nameWillBeFetched': 'Name will be fetched automatically when adding',
'dashboard.analysis.market.AShare': 'A-Share',
'dashboard.analysis.market.USStock': 'US Stock',
'dashboard.analysis.market.HShare': 'H-Share',
'dashboard.analysis.market.Crypto': 'Cryptocurrency',
'dashboard.analysis.market.Forex': 'Forex',
'dashboard.analysis.market.Futures': 'Futures',
@@ -735,9 +806,7 @@ const locale = {
'dashboard.indicator.drawing.priceChannel': 'Price Channel',
'dashboard.indicator.drawing.fibonacciLine': 'Fibonacci Line',
'dashboard.indicator.drawing.clearAll': 'Clear All Drawings',
'dashboard.indicator.market.AShare': 'A-Share',
'dashboard.indicator.market.USStock': 'US Stock',
'dashboard.indicator.market.HShare': 'H-Share',
'dashboard.indicator.market.Crypto': 'Cryptocurrency',
'dashboard.indicator.market.Forex': 'Forex',
'dashboard.indicator.market.Futures': 'Futures',
@@ -1301,9 +1370,17 @@ const locale = {
'trading-assistant.form.step3': 'Strategy Parameters',
'trading-assistant.form.step2Params': 'Parameters',
'trading-assistant.form.step3Signal': 'Signal Delivery',
'trading-assistant.form.simpleMode': 'Simple',
'trading-assistant.form.advancedMode': 'Advanced',
'trading-assistant.form.simpleModeHint': 'Quick setup with recommended defaults',
'trading-assistant.form.advancedModeHint': 'Customize all parameters for pros',
'trading-assistant.form.simpleStep1': 'Select Indicator & Pair',
'trading-assistant.form.simpleStep2': 'Launch Mode',
'trading-assistant.form.simpleDefaultsHint': 'Defaults (expand to customize)',
'trading-assistant.form.showAdvancedSettings': 'Show Advanced Settings',
'trading-assistant.form.hideAdvancedSettings': 'Hide Advanced Settings',
'trading-assistant.form.marketCategory': 'Market Category',
'trading-assistant.form.marketCategoryHint': 'Select a market first. Only Crypto can enable live trading; other markets are signal-only.',
'trading-assistant.market.AShare': 'A-Share',
'trading-assistant.market.USStock': 'US Stock',
'trading-assistant.market.Crypto': 'Crypto',
'trading-assistant.market.Forex': 'Forex',
@@ -2232,7 +2309,6 @@ const locale = {
'settings.field.CCXT_DEFAULT_EXCHANGE': 'CCXT Default Exchange',
'settings.field.CCXT_TIMEOUT': 'CCXT Timeout (ms)',
'settings.field.CCXT_PROXY': 'CCXT Proxy',
'settings.field.AKSHARE_TIMEOUT': 'Akshare Timeout (sec)',
'settings.field.YFINANCE_TIMEOUT': 'YFinance Timeout (sec)',
'settings.field.TIINGO_API_KEY': 'Tiingo API Key',
'settings.field.TIINGO_TIMEOUT': 'Tiingo Timeout (sec)',
@@ -2249,9 +2325,9 @@ const locale = {
// Settings descriptions (config item descriptions)
// Note: These are optional since backend already provides English descriptions
'settings.desc.SEARCH_PROVIDER': 'Web search provider for AI research. Bocha recommended for A-share news',
'settings.desc.SEARCH_PROVIDER': 'Web search provider for AI research',
'settings.desc.TAVILY_API_KEYS': 'Tavily Search API keys, comma-separated for rotation. Free 1000 requests/month',
'settings.desc.BOCHA_API_KEYS': 'Bocha Search API keys, comma-separated for rotation. Best for A-share news',
'settings.desc.BOCHA_API_KEYS': 'Bocha Search API keys, comma-separated for rotation',
'settings.desc.SERPAPI_KEYS': 'SerpAPI keys for Google/Bing search, comma-separated for rotation',
'settings.desc.ORDER_MODE': 'maker: Limit order first (lower fees), market: Market order (instant fill)',
'settings.desc.MAKER_WAIT_SEC': 'Wait time for limit order fill before switching to market order',
@@ -2473,7 +2549,7 @@ const locale = {
'profile.credits.vipExpires': 'VIP expires on',
'profile.credits.vipExpired': 'VIP expired',
'profile.credits.noVip': 'Not a VIP',
'profile.credits.hint': 'AI analysis and other features consume credits. VIP users get free access.',
'profile.credits.hint': 'AI analysis/backtest/monitoring will consume credits; VIP only makes VIP-free indicators free to use.',
// Profile - Credits Log
'profile.creditsLog': 'Credits History',
@@ -2591,11 +2667,13 @@ const locale = {
'systemOverview.realized': 'Real',
'systemOverview.unrealized': 'Unreal',
'systemOverview.symbols': 'symbols',
'systemOverview.live': 'Live',
'systemOverview.signal': 'Signal Only',
// Settings - Billing
'settings.group.billing': 'Billing & Credits',
'settings.field.BILLING_ENABLED': 'Enable Billing',
'settings.field.BILLING_VIP_BYPASS': 'VIP Free Access',
'settings.field.BILLING_VIP_BYPASS': 'VIP Bypass (Legacy)',
'settings.field.BILLING_COST_AI_ANALYSIS': 'AI Analysis Cost',
'settings.field.BILLING_COST_STRATEGY_RUN': 'Strategy Run Cost',
'settings.field.BILLING_COST_BACKTEST': 'Backtest Cost',
@@ -2603,8 +2681,22 @@ const locale = {
'settings.field.CREDITS_REGISTER_BONUS': 'Register Bonus',
'settings.field.CREDITS_REFERRAL_BONUS': 'Referral Bonus',
'settings.field.RECHARGE_TELEGRAM_URL': 'Recharge Telegram URL',
'settings.field.MEMBERSHIP_MONTHLY_PRICE_USD': 'Monthly Membership Price (USD)',
'settings.field.MEMBERSHIP_MONTHLY_CREDITS': 'Monthly Membership Bonus Credits',
'settings.field.MEMBERSHIP_YEARLY_PRICE_USD': 'Yearly Membership Price (USD)',
'settings.field.MEMBERSHIP_YEARLY_CREDITS': 'Yearly Membership Bonus Credits',
'settings.field.MEMBERSHIP_LIFETIME_PRICE_USD': 'Lifetime Membership Price (USD)',
'settings.field.MEMBERSHIP_LIFETIME_MONTHLY_CREDITS': 'Lifetime Monthly Credits',
'settings.field.USDT_PAY_ENABLED': 'Enable USDT Pay',
'settings.field.USDT_PAY_CHAIN': 'USDT Chain',
'settings.field.USDT_TRC20_XPUB': 'TRC20 XPUB (Watch-only)',
'settings.field.USDT_TRC20_CONTRACT': 'USDT TRC20 Contract',
'settings.field.TRONGRID_BASE_URL': 'TronGrid Base URL',
'settings.field.TRONGRID_API_KEY': 'TronGrid API Key',
'settings.field.USDT_PAY_CONFIRM_SECONDS': 'Confirm Delay (sec)',
'settings.field.USDT_PAY_EXPIRE_MINUTES': 'Order Expire (min)',
'settings.desc.BILLING_ENABLED': 'Enable billing system. Users need credits to use certain features when enabled',
'settings.desc.BILLING_VIP_BYPASS': 'VIP users can use all paid features for free during VIP period',
'settings.desc.BILLING_VIP_BYPASS': 'Legacy switch. If enabled, VIP users bypass ALL feature credit costs. Recommended OFF: VIP should only unlock VIP-free indicators.',
'settings.desc.BILLING_COST_AI_ANALYSIS': 'Credits consumed per AI analysis request',
'settings.desc.BILLING_COST_STRATEGY_RUN': 'Credits consumed when starting a strategy',
'settings.desc.BILLING_COST_BACKTEST': 'Credits consumed per backtest run',
@@ -2614,6 +2706,20 @@ const locale = {
'settings.desc.VERIFICATION_CODE_MAX_ATTEMPTS': 'Maximum attempts to verify a code before lockout',
'settings.desc.VERIFICATION_CODE_LOCK_MINUTES': 'Lockout duration after exceeding max attempts',
'settings.desc.RECHARGE_TELEGRAM_URL': 'Telegram customer service URL for recharge inquiries',
'settings.desc.MEMBERSHIP_MONTHLY_PRICE_USD': 'Monthly membership price in USD (mock payment in current version).',
'settings.desc.MEMBERSHIP_MONTHLY_CREDITS': 'Credits granted immediately after purchasing monthly membership.',
'settings.desc.MEMBERSHIP_YEARLY_PRICE_USD': 'Yearly membership price in USD (mock payment in current version).',
'settings.desc.MEMBERSHIP_YEARLY_CREDITS': 'Credits granted immediately after purchasing yearly membership.',
'settings.desc.MEMBERSHIP_LIFETIME_PRICE_USD': 'Lifetime membership price in USD (mock payment in current version).',
'settings.desc.MEMBERSHIP_LIFETIME_MONTHLY_CREDITS': 'Credits granted every 30 days for lifetime members (first grant happens immediately on purchase).',
'settings.desc.USDT_PAY_ENABLED': 'Enable USDT scan-to-pay flow (per-order unique address + auto reconciliation).',
'settings.desc.USDT_PAY_CHAIN': 'Currently only TRC20 is supported.',
'settings.desc.USDT_TRC20_XPUB': 'Watch-only xpub used to derive per-order deposit addresses. Do NOT paste private key/seed.',
'settings.desc.USDT_TRC20_CONTRACT': 'USDT contract address on TRON (default prefilled).',
'settings.desc.TRONGRID_BASE_URL': 'TronGrid API base URL (default is fine).',
'settings.desc.TRONGRID_API_KEY': 'Optional. For higher TronGrid rate limits and stability.',
'settings.desc.USDT_PAY_CONFIRM_SECONDS': 'After a payment is detected, wait N seconds before marking it confirmed.',
'settings.desc.USDT_PAY_EXPIRE_MINUTES': 'Order expiration time; users need to regenerate an order after it expires.',
// Global Market
'globalMarket.title': 'Global Market Dashboard',
@@ -2990,7 +3096,61 @@ const locale = {
'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!'
'aiQuant.hint.liveWarning': 'Live mode will trade with REAL money! Make sure you have configured exchange API and understand the risks!',
// Trading Assistant - Live Trading Disclaimer
'trading-assistant.liveDisclaimer.title': 'Live Trading Disclaimer',
'trading-assistant.liveDisclaimer.content': 'Live trading involves significant risk and may result in partial or total loss of funds. The platform does not guarantee returns or profits. You are responsible for your own decisions and outcomes.',
'trading-assistant.liveDisclaimer.agree': 'I have read and understood the disclaimer and still want to enable live trading',
'trading-assistant.liveDisclaimer.required': 'Please accept the disclaimer before enabling live trading',
'trading-assistant.liveDisclaimer.blockTitle': 'Please accept the disclaimer first',
'trading-assistant.liveDisclaimer.blockDesc': 'You must accept the disclaimer to configure live trading connection and order settings.',
// Billing / Membership
'menu.billing': 'Membership',
'billing.title': 'Membership / Credits',
'billing.desc': 'Choose a plan to activate VIP and receive bonus credits.',
'billing.snapshot.credits': 'Current Credits',
'billing.snapshot.vip': 'VIP Status',
'billing.snapshot.notVip': 'Not VIP',
'billing.snapshot.expires': 'Expires',
'billing.vipRule.title': 'VIP Benefit',
'billing.vipRule.desc': 'VIP has only one special permission: VIP-free indicators can be used without credits deduction. Other paid features/indicators still consume credits.',
'billing.plan.monthly': 'Monthly',
'billing.plan.yearly': 'Yearly',
'billing.plan.lifetime': 'Lifetime',
'billing.perMonth': 'month',
'billing.perYear': 'year',
'billing.once': 'one-time',
'billing.credits': 'Credits',
'billing.lifetimeMonthly': 'Monthly bonus',
'billing.buyNow': 'Buy Now',
'billing.purchaseSuccess': 'Purchase successful',
'billing.purchaseFailed': 'Purchase failed',
'billing.usdt.title': 'USDT Scan to Pay',
'billing.usdt.hintTitle': 'Scan with your wallet and send USDT',
'billing.usdt.hintDesc': 'Make sure the network and amount are correct (TRC20 only for now). Membership will be activated automatically after payment is confirmed.',
'billing.usdt.chain': 'Chain',
'billing.usdt.amount': 'Amount',
'billing.usdt.address': 'Deposit Address',
'billing.usdt.copyAddress': 'Copy Address',
'billing.usdt.copyAmount': 'Copy Amount',
'billing.usdt.refresh': 'Refresh',
'billing.usdt.expires': 'Expires',
'billing.usdt.paidSuccess': 'Payment confirmed. Membership activated.',
'billing.usdt.status.pending': 'Waiting for payment',
'billing.usdt.status.paid': 'Payment detected',
'billing.usdt.status.confirmed': 'Confirmed',
'billing.usdt.status.expired': 'Expired',
'billing.usdt.status.cancelled': 'Cancelled',
'billing.usdt.status.failed': 'Failed',
// Community
'community.vipFree': 'VIP Free',
// Indicator publish
'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).'
}
export default {
@@ -421,9 +421,7 @@ const locale = {
'dashboard.analysis.modal.addStock.willAutoFetchName': 'Le système obtiendra automatiquement le nom',
'dashboard.analysis.modal.addStock.addDirectly': 'Ajouter directement',
'dashboard.analysis.modal.addStock.nameWillBeFetched': 'Le nom sera récupéré automatiquement une fois ajouté',
'dashboard.analysis.market.AShare': 'Un partage',
'dashboard.analysis.market.USStock': 'Actions américaines',
'dashboard.analysis.market.HShare': 'Actions de Hong Kong',
'dashboard.analysis.market.Crypto': 'crypto-monnaie',
'dashboard.analysis.market.Forex': 'Forex',
'dashboard.analysis.market.Futures': 'Contrats à terme',
@@ -577,9 +575,7 @@ const locale = {
'dashboard.indicator.drawing.priceChannel': 'canal de prix',
'dashboard.indicator.drawing.fibonacciLine': 'Lignes de Fibonacci',
'dashboard.indicator.drawing.clearAll': 'Effacer tous les dessins au trait',
'dashboard.indicator.market.AShare': 'Un partage',
'dashboard.indicator.market.USStock': 'Actions américaines',
'dashboard.indicator.market.HShare': 'Actions de Hong Kong',
'dashboard.indicator.market.Crypto': 'crypto-monnaie',
'dashboard.indicator.market.Forex': 'Forex',
'dashboard.indicator.market.Futures': 'Contrats à terme',
@@ -421,9 +421,7 @@ const locale = {
'dashboard.analysis.modal.addStock.willAutoFetchName': 'システムが自動的に名前を取得します',
'dashboard.analysis.modal.addStock.addDirectly': '直接追加',
'dashboard.analysis.modal.addStock.nameWillBeFetched': '名前は追加時に自動的に取得されます',
'dashboard.analysis.market.AShare': 'A株',
'dashboard.analysis.market.USStock': '米国株',
'dashboard.analysis.market.HShare': '香港株',
'dashboard.analysis.market.Crypto': '暗号通貨',
'dashboard.analysis.market.Forex': '外国為替',
'dashboard.analysis.market.Futures': '先物',
@@ -577,9 +575,7 @@ const locale = {
'dashboard.indicator.drawing.priceChannel': '価格チャネル',
'dashboard.indicator.drawing.fibonacciLine': 'フィボナッチライン',
'dashboard.indicator.drawing.clearAll': '線画をすべてクリアする',
'dashboard.indicator.market.AShare': 'A株',
'dashboard.indicator.market.USStock': '米国株',
'dashboard.indicator.market.HShare': '香港株',
'dashboard.indicator.market.Crypto': '暗号通貨',
'dashboard.indicator.market.Forex': '外国為替',
'dashboard.indicator.market.Futures': '先物',
@@ -420,9 +420,7 @@ const locale = {
'dashboard.analysis.modal.addStock.willAutoFetchName': '시스템에서 자동으로 이름을 가져옵니다.',
'dashboard.analysis.modal.addStock.addDirectly': '직접 추가',
'dashboard.analysis.modal.addStock.nameWillBeFetched': '추가되면 이름이 자동으로 선택됩니다.',
'dashboard.analysis.market.AShare': 'A주',
'dashboard.analysis.market.USStock': '미국 주식',
'dashboard.analysis.market.HShare': '홍콩 주식',
'dashboard.analysis.market.Crypto': '암호화폐',
'dashboard.analysis.market.Forex': '외환',
'dashboard.analysis.market.Futures': '선물',
@@ -576,9 +574,7 @@ const locale = {
'dashboard.indicator.drawing.priceChannel': '가격 채널',
'dashboard.indicator.drawing.fibonacciLine': '피보나치 라인',
'dashboard.indicator.drawing.clearAll': '모든 선화 지우기',
'dashboard.indicator.market.AShare': 'A주',
'dashboard.indicator.market.USStock': '미국 주식',
'dashboard.indicator.market.HShare': '홍콩 주식',
'dashboard.indicator.market.Crypto': '암호화폐',
'dashboard.indicator.market.Forex': '외환',
'dashboard.indicator.market.Futures': '선물',
@@ -421,9 +421,7 @@ const locale = {
'dashboard.analysis.modal.addStock.willAutoFetchName': 'ระบบจะรับชื่อให้อัตโนมัติ',
'dashboard.analysis.modal.addStock.addDirectly': 'เพิ่มโดยตรง',
'dashboard.analysis.modal.addStock.nameWillBeFetched': 'ชื่อจะถูกหยิบขึ้นมาโดยอัตโนมัติเมื่อเพิ่ม',
'dashboard.analysis.market.AShare': 'มีหุ้น',
'dashboard.analysis.market.USStock': 'หุ้นสหรัฐ',
'dashboard.analysis.market.HShare': 'หุ้นฮ่องกง',
'dashboard.analysis.market.Crypto': 'สกุลเงินดิจิทัล',
'dashboard.analysis.market.Forex': 'ฟอเร็กซ์',
'dashboard.analysis.market.Futures': 'ฟิวเจอร์ส',
@@ -577,9 +575,7 @@ const locale = {
'dashboard.indicator.drawing.priceChannel': 'ช่องราคา',
'dashboard.indicator.drawing.fibonacciLine': 'เส้นฟีโบนักชี',
'dashboard.indicator.drawing.clearAll': 'ล้างภาพวาดเส้นทั้งหมด',
'dashboard.indicator.market.AShare': 'มีหุ้น',
'dashboard.indicator.market.USStock': 'หุ้นสหรัฐ',
'dashboard.indicator.market.HShare': 'หุ้นฮ่องกง',
'dashboard.indicator.market.Crypto': 'สกุลเงินดิจิทัล',
'dashboard.indicator.market.Forex': 'ฟอเร็กซ์',
'dashboard.indicator.market.Futures': 'ฟิวเจอร์ส',
@@ -830,12 +830,8 @@ const locale = {
'dashboard.analysis.modal.addStock.nameWillBeFetched': 'Tên sẽ được tự động lấy khi thêm',
'dashboard.analysis.market.AShare': 'Cổ phiếu loại A',
'dashboard.analysis.market.USStock': 'Cổ phiếu Mỹ',
'dashboard.analysis.market.HShare': 'Cổ phiếu Hồng Kông cổ phiếu',
'dashboard.analysis.market.Crypto': 'Tiền điện tử',
'dashboard.analysis.market.Forex': 'Ngoại hối',
@@ -1138,12 +1134,8 @@ const locale = {
'dashboard.indicator.drawing.clearAll': 'xóa tất cả các đường đã vẽ',
'dashboard.indicator.market.AShare': 'cổ phiếu A',
'dashboard.indicator.market.USStock': 'cổ phiếu Mỹ',
'dashboard.indicator.market.HShare': 'cổ phiếu Hồng Kông',
'dashboard.indicator.market.Crypto': 'tiền điện tử',
'dashboard.indicator.market.Forex': 'ngoại hối',
+170 -8
View File
@@ -17,6 +17,8 @@ const locale = {
'common.add': '添加',
'common.close': '关闭',
'common.ok': '确定',
'common.loading': '加载中...',
'common.noData': '暂无数据',
'submit': '提交',
'save': '保存',
'submit.ok': '提交成功',
@@ -27,6 +29,7 @@ const locale = {
'menu.dashboard.indicator': '指标分析',
'menu.dashboard.community': '指标市场',
'menu.dashboard.analysis': 'AI 分析',
'menu.dashboard.aiAssetAnalysis': 'AI资产分析',
'menu.dashboard.aiQuant': 'AI 量化',
'menu.dashboard.tradingAssistant': '交易助手',
'menu.dashboard.portfolio': '资产监测',
@@ -36,6 +39,76 @@ const locale = {
'menu.dashboard.signalRobot': '信号机器人',
'menu.dashboard.monitor': '监控页',
'menu.dashboard.workplace': '工作台',
'aiAssetAnalysis.title': 'AI资产分析',
'aiAssetAnalysis.subtitle': '把资产监测、即时分析、定时任务放到一个工作流里,操作更直观、反馈更及时。',
'aiAssetAnalysis.actions.quickAnalysis': '立即开始分析',
'aiAssetAnalysis.actions.monitorTasks': '管理监控任务',
'aiAssetAnalysis.actions.openStandalone': '在独立页面打开',
'aiAssetAnalysis.quickBar.title': '快捷分析',
'aiAssetAnalysis.quickBar.placeholder': '从资产池选择标的',
'aiAssetAnalysis.quickBar.useInQuick': '带入即时分析',
'aiAssetAnalysis.quickBar.runNow': '一键分析',
'aiAssetAnalysis.history.title': '最近分析',
'aiAssetAnalysis.history.empty': '暂无分析记录',
'aiAssetAnalysis.actions.enterQuick': '进入即时分析',
'aiAssetAnalysis.actions.enterMonitor': '进入定时任务与资产池',
'aiAssetAnalysis.flow.poolTitle': '构建资产池',
'aiAssetAnalysis.flow.poolDesc': '先添加持仓与关注标的,形成统一分析对象池。',
'aiAssetAnalysis.flow.poolAction': '去管理资产池',
'aiAssetAnalysis.flow.quickTitle': '即时分析',
'aiAssetAnalysis.flow.quickDesc': '对任意标的一键发起 AI 分析,快速拿到决策建议。',
'aiAssetAnalysis.flow.quickAction': '去即时分析',
'aiAssetAnalysis.flow.autoTitle': '定时监控',
'aiAssetAnalysis.flow.autoDesc': '配置定时任务自动运行 AI 分析,并推送报告到通知渠道。',
'aiAssetAnalysis.flow.autoAction': '去配置任务',
'aiAssetAnalysis.tabs.quick': '即时分析',
'aiAssetAnalysis.tabs.monitor': '资产池与定时任务',
'aiAssetAnalysis.tabLead.quick': '适合临时判断:选择标的后立刻分析并查看历史结果。',
'aiAssetAnalysis.tabLead.monitor': '适合持续跟踪:维护持仓、设置监控范围与执行频率。',
// AI资产分析 - 准确率看板
'aiAssetAnalysis.stats.totalAnalyses': '分析总数',
'aiAssetAnalysis.stats.accuracy': '准确率',
'aiAssetAnalysis.stats.avgReturn': '平均回报',
'aiAssetAnalysis.stats.satisfaction': '用户满意度',
'aiAssetAnalysis.stats.decisions': '决策分布',
// AI资产分析 - 交易机会
'aiAssetAnalysis.opportunities.title': 'AI 交易机会雷达',
'aiAssetAnalysis.opportunities.empty': '暂无交易机会',
'aiAssetAnalysis.opportunities.analyze': '分析',
'aiAssetAnalysis.opportunities.updateHint': '每小时更新',
'aiAssetAnalysis.opportunities.signal.overbought': '超买',
'aiAssetAnalysis.opportunities.signal.oversold': '超卖',
'aiAssetAnalysis.opportunities.signal.bullish_momentum': '看涨',
'aiAssetAnalysis.opportunities.signal.bearish_momentum': '看跌',
'aiAssetAnalysis.opportunities.market.Crypto': '🪙 加密',
'aiAssetAnalysis.opportunities.market.USStock': '📈 美股',
'aiAssetAnalysis.opportunities.market.Forex': '💱 外汇',
'aiAssetAnalysis.opportunities.reason.crypto.overbought': '24h涨幅{change}%7日涨幅{change7d}%,短期超买风险',
'aiAssetAnalysis.opportunities.reason.crypto.bullish_momentum': '24h涨幅{change}%,上涨动能强劲',
'aiAssetAnalysis.opportunities.reason.crypto.oversold': '24h跌幅{change}%,可能超卖反弹',
'aiAssetAnalysis.opportunities.reason.crypto.bearish_momentum': '24h跌幅{change}%,下跌趋势明显',
'aiAssetAnalysis.opportunities.reason.usstock.overbought': '日涨幅{change}%,短期涨幅较大,注意回调风险',
'aiAssetAnalysis.opportunities.reason.usstock.bullish_momentum': '日涨幅{change}%,上涨动能强劲',
'aiAssetAnalysis.opportunities.reason.usstock.oversold': '日跌幅{change}%,可能超卖反弹',
'aiAssetAnalysis.opportunities.reason.usstock.bearish_momentum': '日跌幅{change}%,下跌趋势明显',
'aiAssetAnalysis.opportunities.reason.forex.overbought': '日涨幅{change}%,汇率波动剧烈,注意回调',
'aiAssetAnalysis.opportunities.reason.forex.bullish_momentum': '日涨幅{change}%,上涨动能较强',
'aiAssetAnalysis.opportunities.reason.forex.oversold': '日跌幅{change}%,汇率波动剧烈,可能反弹',
'aiAssetAnalysis.opportunities.reason.forex.bearish_momentum': '日跌幅{change}%,下跌趋势明显',
// AI资产分析 - 一键组合体检
'aiAssetAnalysis.checkup.title': '组合体检',
'aiAssetAnalysis.checkup.btn': '组合体检',
'aiAssetAnalysis.checkup.desc': '一键对您资产池中的所有持仓进行 AI 分析,快速了解每个标的的当前状态和操作建议。',
'aiAssetAnalysis.checkup.start': '开始体检',
'aiAssetAnalysis.checkup.progress': '正在分析 {current}/{total}...',
'aiAssetAnalysis.checkup.noPositions': '资产池中暂无持仓,请先添加资产。',
'aiAssetAnalysis.checkup.complete': '体检完成',
'aiAssetAnalysis.checkup.failed': '分析失败',
'aiAssetAnalysis.checkup.rerun': '重新体检',
// AI资产分析 - 全局搜索
'aiAssetAnalysis.search.hint': '搜索',
'aiAssetAnalysis.search.placeholder': '搜索任意标的... (Ctrl+K)',
'aiAssetAnalysis.search.noResults': '未找到匹配结果,请尝试其他关键词',
'menu.form': '表单页',
'menu.form.basic-form': '基础表单',
'menu.form.step-form': '分步表单',
@@ -558,9 +631,7 @@ const locale = {
'dashboard.analysis.modal.addStock.willAutoFetchName': '系统将自动获取名称',
'dashboard.analysis.modal.addStock.addDirectly': '直接添加',
'dashboard.analysis.modal.addStock.nameWillBeFetched': '名称将在添加时自动获取',
'dashboard.analysis.market.AShare': 'A股',
'dashboard.analysis.market.USStock': '美股',
'dashboard.analysis.market.HShare': '港股',
'dashboard.analysis.market.Crypto': '加密货币',
'dashboard.analysis.market.Forex': '外汇',
'dashboard.analysis.market.Futures': '期货',
@@ -735,9 +806,7 @@ const locale = {
'dashboard.indicator.drawing.priceChannel': '价格通道',
'dashboard.indicator.drawing.fibonacciLine': '斐波那契线',
'dashboard.indicator.drawing.clearAll': '清除所有画线',
'dashboard.indicator.market.AShare': 'A股',
'dashboard.indicator.market.USStock': '美股',
'dashboard.indicator.market.HShare': '港股',
'dashboard.indicator.market.Crypto': '加密货币',
'dashboard.indicator.market.Forex': '外汇',
'dashboard.indicator.market.Futures': '期货',
@@ -1210,6 +1279,15 @@ const locale = {
'trading-assistant.form.step3': '策略参数',
'trading-assistant.form.step2Params': '策略参数',
'trading-assistant.form.step3Signal': '信号通知',
'trading-assistant.form.simpleMode': '简易模式',
'trading-assistant.form.advancedMode': '高级模式',
'trading-assistant.form.simpleModeHint': '快速创建策略,使用推荐默认参数',
'trading-assistant.form.advancedModeHint': '自定义全部参数,适合专业用户',
'trading-assistant.form.simpleStep1': '选择指标 & 交易对',
'trading-assistant.form.simpleStep2': '启动方式',
'trading-assistant.form.simpleDefaultsHint': '默认参数(可展开修改)',
'trading-assistant.form.showAdvancedSettings': '展开高级设置',
'trading-assistant.form.hideAdvancedSettings': '收起高级设置',
'trading-assistant.form.indicator': '选择指标',
'trading-assistant.form.indicatorHint': '只能选择您已购买或创建的技术指标',
'trading-assistant.form.qdtCostHints': '使用策略将消耗QDT,请确保账户有足够的QDT余额',
@@ -2282,7 +2360,7 @@ const locale = {
'profile.credits.vipExpires': 'VIP有效期至',
'profile.credits.vipExpired': 'VIP已过期',
'profile.credits.noVip': '非VIP用户',
'profile.credits.hint': '使用AI分析等功能会消耗积分VIP用户免费',
'profile.credits.hint': '使用AI分析/回测/监控等功能会消耗积分VIP仅可免费使用VIP免费指标。',
// Profile - Credits Log (消费记录)
'profile.creditsLog': '消费记录',
@@ -2400,11 +2478,13 @@ const locale = {
'systemOverview.realized': '已实现',
'systemOverview.unrealized': '未实现',
'systemOverview.symbols': '个交易对',
'systemOverview.live': '实盘',
'systemOverview.signal': '仅通知',
// Settings - Billing
'settings.group.billing': '计费配置',
'settings.field.BILLING_ENABLED': '启用计费',
'settings.field.BILLING_VIP_BYPASS': 'VIP免费',
'settings.field.BILLING_VIP_BYPASS': 'VIP旁路(旧)',
'settings.field.BILLING_COST_AI_ANALYSIS': 'AI分析消耗',
'settings.field.BILLING_COST_STRATEGY_RUN': '策略运行消耗',
'settings.field.BILLING_COST_BACKTEST': '回测消耗',
@@ -2412,8 +2492,22 @@ const locale = {
'settings.field.CREDITS_REGISTER_BONUS': '注册奖励',
'settings.field.CREDITS_REFERRAL_BONUS': '邀请奖励',
'settings.field.RECHARGE_TELEGRAM_URL': '充值Telegram链接',
'settings.field.MEMBERSHIP_MONTHLY_PRICE_USD': '包月会员价格(USD',
'settings.field.MEMBERSHIP_MONTHLY_CREDITS': '包月会员赠送积分',
'settings.field.MEMBERSHIP_YEARLY_PRICE_USD': '包年会员价格(USD',
'settings.field.MEMBERSHIP_YEARLY_CREDITS': '包年会员赠送积分',
'settings.field.MEMBERSHIP_LIFETIME_PRICE_USD': '永久会员价格(USD',
'settings.field.MEMBERSHIP_LIFETIME_MONTHLY_CREDITS': '永久会员每月赠送积分',
'settings.field.USDT_PAY_ENABLED': '启用USDT收款',
'settings.field.USDT_PAY_CHAIN': 'USDT网络',
'settings.field.USDT_TRC20_XPUB': 'TRC20 XPUB(仅观察)',
'settings.field.USDT_TRC20_CONTRACT': 'USDT TRC20 合约地址',
'settings.field.TRONGRID_BASE_URL': 'TronGrid Base URL',
'settings.field.TRONGRID_API_KEY': 'TronGrid API Key',
'settings.field.USDT_PAY_CONFIRM_SECONDS': '确认延迟(秒)',
'settings.field.USDT_PAY_EXPIRE_MINUTES': '订单过期(分钟)',
'settings.desc.BILLING_ENABLED': '启用计费系统。启用后,用户使用某些功能需要消耗积分',
'settings.desc.BILLING_VIP_BYPASS': 'VIP用户在有效期内可免费使用所有付费功能',
'settings.desc.BILLING_VIP_BYPASS': '旧开关:开启后VIP将旁路所有功能扣积分(不推荐)。建议关闭,VIP仅用于“VIP免费指标”。',
'settings.desc.BILLING_COST_AI_ANALYSIS': '每次AI分析消耗的积分数',
'settings.desc.BILLING_COST_STRATEGY_RUN': '启动策略时消耗的积分数',
'settings.desc.BILLING_COST_BACKTEST': '每次回测消耗的积分数',
@@ -2423,6 +2517,20 @@ const locale = {
'settings.desc.VERIFICATION_CODE_MAX_ATTEMPTS': '验证码验证失败的最大尝试次数,超过后将锁定',
'settings.desc.VERIFICATION_CODE_LOCK_MINUTES': '验证码验证失败次数超过限制后的锁定时长(分钟)',
'settings.desc.RECHARGE_TELEGRAM_URL': '用户点击充值时跳转的Telegram客服链接',
'settings.desc.MEMBERSHIP_MONTHLY_PRICE_USD': '包月会员的价格(USD)。当前为模拟支付,后续可接入真实支付网关。',
'settings.desc.MEMBERSHIP_MONTHLY_CREDITS': '购买包月会员后立即赠送到账号的积分数量。',
'settings.desc.MEMBERSHIP_YEARLY_PRICE_USD': '包年会员的价格(USD)。当前为模拟支付,后续可接入真实支付网关。',
'settings.desc.MEMBERSHIP_YEARLY_CREDITS': '购买包年会员后立即赠送到账号的积分数量。',
'settings.desc.MEMBERSHIP_LIFETIME_PRICE_USD': '永久会员的价格(USD)。当前为模拟支付,后续可接入真实支付网关。',
'settings.desc.MEMBERSHIP_LIFETIME_MONTHLY_CREDITS': '永久会员每30天自动发放一次的积分数量(首次购买会立刻发放一次)。',
'settings.desc.USDT_PAY_ENABLED': '开启后,会员购买页会提供 USDT 扫码支付(每单独立地址 + 自动对账)。',
'settings.desc.USDT_PAY_CHAIN': '当前版本仅支持 TRC20。',
'settings.desc.USDT_TRC20_XPUB': '仅观察 xpub,用于派生每个订单的收款地址。不要填写私钥/助记词。',
'settings.desc.USDT_TRC20_CONTRACT': 'TRON 链上 USDT 合约地址(默认已填写)。',
'settings.desc.TRONGRID_BASE_URL': 'TronGrid API 基础地址(默认即可)。',
'settings.desc.TRONGRID_API_KEY': '可选。用于提高 TronGrid 调用额度/稳定性。',
'settings.desc.USDT_PAY_CONFIRM_SECONDS': '检测到到账后,等待指定秒数再标记“确认”,降低极端回滚风险。',
'settings.desc.USDT_PAY_EXPIRE_MINUTES': '用户扫码支付的订单有效期,过期后需重新生成订单。',
// Global Market
'globalMarket.title': '全球金融面板',
@@ -2799,7 +2907,61 @@ const locale = {
'aiQuant.template.news': '📰 新闻驱动',
'aiQuant.template.custom': '✏️ 自定义',
'aiQuant.hint.dataProvided': '系统自动提供:实时价格、技术指标(RSI/MACD/均线)、最近新闻、宏观数据。AI将基于这些数据和您的提示词判断方向。',
'aiQuant.hint.liveWarning': '实盘模式将使用真实资金交易,请确保已配置交易所API并充分了解风险!'
'aiQuant.hint.liveWarning': '实盘模式将使用真实资金交易,请确保已配置交易所API并充分了解风险!',
// 交易助手 - 实盘免责声明
'trading-assistant.liveDisclaimer.title': '实盘交易免责声明',
'trading-assistant.liveDisclaimer.content': '实盘交易存在较高风险,可能导致部分或全部资金损失。平台不保证收益、不承诺盈利。你需自行评估风险并对交易结果承担全部责任。',
'trading-assistant.liveDisclaimer.agree': '我已阅读并理解上述免责声明,仍选择开启实盘交易',
'trading-assistant.liveDisclaimer.required': '开启实盘前请先勾选免责声明确认',
'trading-assistant.liveDisclaimer.blockTitle': '请先确认免责声明',
'trading-assistant.liveDisclaimer.blockDesc': '勾选免责声明后才可继续配置实盘连接与下单参数。',
// Billing / Membership
'menu.billing': '会员充值',
'billing.title': '开通会员 / 充值积分',
'billing.desc': '选择适合你的会员套餐,开通后会赠送积分。',
'billing.snapshot.credits': '当前积分',
'billing.snapshot.vip': 'VIP 状态',
'billing.snapshot.notVip': '非VIP',
'billing.snapshot.expires': '到期时间',
'billing.vipRule.title': 'VIP 权益说明',
'billing.vipRule.desc': 'VIP 仅拥有一个特殊权限:可免费使用「VIP免费指标」。其它付费功能/指标仍会正常扣积分。',
'billing.plan.monthly': '包月会员',
'billing.plan.yearly': '包年会员',
'billing.plan.lifetime': '永久会员',
'billing.perMonth': '月',
'billing.perYear': '年',
'billing.once': '一次性',
'billing.credits': '积分',
'billing.lifetimeMonthly': '每月赠送',
'billing.buyNow': '立即购买',
'billing.purchaseSuccess': '购买成功',
'billing.purchaseFailed': '购买失败',
'billing.usdt.title': 'USDT 扫码支付',
'billing.usdt.hintTitle': '请使用钱包扫码并完成转账',
'billing.usdt.hintDesc': '请确保网络与金额正确(当前仅支持 TRC20)。支付到账后系统会自动开通会员并发放积分。',
'billing.usdt.chain': '网络',
'billing.usdt.amount': '金额',
'billing.usdt.address': '收款地址',
'billing.usdt.copyAddress': '复制地址',
'billing.usdt.copyAmount': '复制金额',
'billing.usdt.refresh': '刷新状态',
'billing.usdt.expires': '过期时间',
'billing.usdt.paidSuccess': '支付确认成功,已开通会员',
'billing.usdt.status.pending': '等待支付',
'billing.usdt.status.paid': '已检测到账',
'billing.usdt.status.confirmed': '已确认',
'billing.usdt.status.expired': '已过期',
'billing.usdt.status.cancelled': '已取消',
'billing.usdt.status.failed': '失败',
// Community
'community.vipFree': 'VIP免费',
// Indicator publish
'dashboard.indicator.publish.vipFree': 'VIP免费',
'dashboard.indicator.publish.vipFreeHint': '开启后:VIP用户可免费使用该指标(非VIP仍需购买)。'
}
export default {
@@ -464,9 +464,7 @@ const locale = {
'dashboard.analysis.modal.addStock.willAutoFetchName': '系統將自動獲取名稱',
'dashboard.analysis.modal.addStock.addDirectly': '直接添加',
'dashboard.analysis.modal.addStock.nameWillBeFetched': '名稱將在添加時自動獲取',
'dashboard.analysis.market.AShare': 'A股',
'dashboard.analysis.market.USStock': '美股',
'dashboard.analysis.market.HShare': '港股',
'dashboard.analysis.market.Crypto': '加密貨幣',
'dashboard.analysis.market.Forex': '外匯',
'dashboard.analysis.market.Futures': '期貨',
@@ -621,9 +619,7 @@ const locale = {
'dashboard.indicator.drawing.priceChannel': '價格通道',
'dashboard.indicator.drawing.fibonacciLine': '斐波那契線',
'dashboard.indicator.drawing.clearAll': '清除所有畫線',
'dashboard.indicator.market.AShare': 'A股',
'dashboard.indicator.market.USStock': '美股',
'dashboard.indicator.market.HShare': '港股',
'dashboard.indicator.market.Crypto': '加密貨幣',
'dashboard.indicator.market.Forex': '外匯',
'dashboard.indicator.market.Futures': '期貨',
+47 -12
View File
@@ -1,5 +1,5 @@
<template>
<div class="ai-analysis-container" :class="{ 'theme-dark': isDarkTheme }" :style="{ '--primary-color': primaryColor }">
<div class="ai-analysis-container" :class="{ 'theme-dark': isDarkTheme, embedded: embedded }" :style="{ '--primary-color': primaryColor }">
<!-- 全宽主内容区域 -->
<div class="main-content-full">
<!-- 顶部指数条 -->
@@ -462,6 +462,20 @@ import FastAnalysisReport from './components/FastAnalysisReport.vue'
export default {
name: 'Analysis',
props: {
embedded: {
type: Boolean,
default: false
},
presetSymbol: {
type: String,
default: ''
},
autoAnalyzeSignal: {
type: Number,
default: 0
}
},
components: {
FastAnalysisReport
},
@@ -741,17 +755,15 @@ export default {
'USStock': 'green',
'Crypto': 'purple',
'Forex': 'gold',
'Futures': 'cyan',
'AShare': 'blue',
'HShare': 'orange'
'Futures': 'cyan'
}
return colors[market] || 'default'
},
getCurrencySymbol (market) {
const dollarMarkets = ['USStock', 'Crypto', 'Forex', 'Futures']
return dollarMarkets.includes(market) ? '$' : '¥'
return '$'
},
async startFastAnalysis () {
if (this.analyzing) return
if (!this.selectedSymbol) {
this.$message.warning(this.$t('dashboard.analysis.message.selectSymbol'))
return
@@ -1201,9 +1213,7 @@ export default {
{ value: 'USStock', i18nKey: 'dashboard.analysis.market.USStock' },
{ value: 'Crypto', i18nKey: 'dashboard.analysis.market.Crypto' },
{ value: 'Forex', i18nKey: 'dashboard.analysis.market.Forex' },
{ value: 'Futures', i18nKey: 'dashboard.analysis.market.Futures' },
{ value: 'HShare', i18nKey: 'dashboard.analysis.market.HShare' },
{ value: 'AShare', i18nKey: 'dashboard.analysis.market.AShare' }
{ value: 'Futures', i18nKey: 'dashboard.analysis.market.Futures' }
]
}
} catch (error) {
@@ -1211,9 +1221,7 @@ export default {
{ value: 'USStock', i18nKey: 'dashboard.analysis.market.USStock' },
{ value: 'Crypto', i18nKey: 'dashboard.analysis.market.Crypto' },
{ value: 'Forex', i18nKey: 'dashboard.analysis.market.Forex' },
{ value: 'Futures', i18nKey: 'dashboard.analysis.market.Futures' },
{ value: 'HShare', i18nKey: 'dashboard.analysis.market.HShare' },
{ value: 'AShare', i18nKey: 'dashboard.analysis.market.AShare' }
{ value: 'Futures', i18nKey: 'dashboard.analysis.market.Futures' }
]
}
@@ -1223,6 +1231,22 @@ export default {
}
},
watch: {
presetSymbol (newVal) {
if (newVal && newVal !== this.selectedSymbol) {
this.selectedSymbol = newVal
this.analysisResult = null
this.analysisError = null
}
},
autoAnalyzeSignal (newVal) {
if (!newVal) return
if (this.presetSymbol && this.presetSymbol !== this.selectedSymbol) {
this.selectedSymbol = this.presetSymbol
}
this.$nextTick(() => {
this.startFastAnalysis()
})
},
showAddStockModal (newVal) {
if (newVal) {
if (this.marketTypes.length > 0 && !this.selectedMarketTab) {
@@ -1256,6 +1280,17 @@ export default {
box-sizing: border-box;
}
.ai-analysis-container.embedded {
height: auto;
min-height: 700px;
background: transparent;
}
.ai-analysis-container.embedded .main-content-full {
box-shadow: none;
border-radius: 0;
}
//
.main-content-full {
flex: 1;
@@ -0,0 +1,491 @@
<template>
<div class="ai-asset-analysis-page" :class="{ 'theme-dark': isDarkTheme }">
<!-- ======== Trading Opportunities (Carousel) ======== -->
<div class="opp-section" v-if="opportunities.length > 0">
<div class="opp-header">
<span class="opp-title"><a-icon type="radar-chart" /> {{ $t('aiAssetAnalysis.opportunities.title') }}</span>
<span class="opp-header-right">
<span class="opp-update-hint">
<a-icon type="clock-circle" /> {{ $t('aiAssetAnalysis.opportunities.updateHint') }}
</span>
<a-button type="link" size="small" icon="reload" :loading="oppLoading" @click="loadOpportunities(true)">
{{ $t('common.refresh') || '刷新' }}
</a-button>
</span>
</div>
<div
class="opp-carousel-wrapper"
@mouseenter="oppHover = true"
@mouseleave="oppHover = false"
>
<div class="opp-track" :class="{ paused: oppHover }" :style="oppTrackStyle">
<div
class="opp-card"
v-for="(opp, idx) in carouselItems"
:key="'opp-' + idx"
:class="[opp.impact, 'market-' + (opp.market || '').toLowerCase()]"
@click="analyzeOpportunity(opp)"
>
<div class="opp-top">
<span class="opp-symbol">{{ opp.symbol }}</span>
<a-tag :color="getMarketTagColor(opp.market)" size="small" class="opp-market-tag">
{{ getMarketLabel(opp.market) }}
</a-tag>
</div>
<div class="opp-price">${{ formatOppPrice(opp.price) }}</div>
<div class="opp-change" :class="opp.change_24h >= 0 ? 'up' : 'down'">
{{ opp.change_24h >= 0 ? '+' : '' }}{{ (opp.change_24h || 0).toFixed(1) }}%
</div>
<div class="opp-signal">
<a-tag :color="getSignalColor(opp.signal)" size="small">{{ getSignalLabel(opp.signal) }}</a-tag>
</div>
<div class="opp-reason">{{ getReasonText(opp) }}</div>
<div class="opp-action">
<a-icon type="thunderbolt" /> {{ $t('aiAssetAnalysis.opportunities.analyze') }}
</div>
</div>
</div>
</div>
</div>
<!-- ======== Main Workspace Card with Tabs ======== -->
<a-card :bordered="false" class="workspace-card">
<a-tabs v-model="activeTab" class="workspace-tabs" size="large">
<a-tab-pane key="quick">
<span slot="tab">
<a-icon type="thunderbolt" />
{{ $t('aiAssetAnalysis.tabs.quick') }}
</span>
<div class="tab-body">
<AnalysisView
v-if="activeTab === 'quick'"
:embedded="true"
:preset-symbol="presetSymbol"
:auto-analyze-signal="autoAnalyzeSignal"
/>
</div>
</a-tab-pane>
<a-tab-pane key="monitor">
<span slot="tab">
<a-icon type="eye" />
{{ $t('aiAssetAnalysis.tabs.monitor') }}
</span>
<div class="tab-body">
<PortfolioView
v-if="activeTab === 'monitor'"
:embedded="true"
/>
</div>
</a-tab-pane>
</a-tabs>
</a-card>
</div>
</template>
<script>
import { mapState } from 'vuex'
import AnalysisView from '@/views/ai-analysis'
import PortfolioView from '@/views/portfolio'
import { getTradingOpportunities } from '@/api/global-market'
export default {
name: 'AIAssetAnalysis',
components: {
AnalysisView,
PortfolioView
},
data () {
return {
activeTab: 'quick',
// Opportunities (Carousel)
opportunities: [],
oppLoading: false,
oppHover: false,
// Props passed to AnalysisView
presetSymbol: '',
autoAnalyzeSignal: 0
}
},
computed: {
...mapState({
navTheme: state => state.app.theme
}),
isDarkTheme () {
return this.navTheme === 'dark' || this.navTheme === 'realdark'
},
// Duplicate items for seamless infinite carousel
carouselItems () {
if (this.opportunities.length === 0) return []
// Duplicate the array so the CSS animation can loop seamlessly
return [...this.opportunities, ...this.opportunities]
},
oppTrackStyle () {
// Animation duration proportional to number of items (3s per card)
const duration = this.opportunities.length * 3
return {
animationDuration: duration + 's'
}
}
},
created () {
this.loadOpportunities()
},
methods: {
// ==================== Trading Opportunities (Carousel) ====================
async loadOpportunities (force = false) {
this.oppLoading = true
try {
const params = force ? { force: true } : {}
const res = await getTradingOpportunities(params)
if (res && res.code === 1 && Array.isArray(res.data)) {
this.opportunities = res.data.slice(0, 20)
}
} catch (e) {
console.warn('Load opportunities failed:', e)
} finally {
this.oppLoading = false
}
},
getSignalColor (signal) {
const map = {
overbought: 'volcano',
oversold: 'green',
bullish_momentum: 'cyan',
bearish_momentum: 'red'
}
return map[signal] || 'default'
},
getSignalLabel (signal) {
const key = `aiAssetAnalysis.opportunities.signal.${signal}`
const translated = this.$t(key)
// If i18n returns the key itself, fall back to the raw signal name
return translated !== key ? translated : signal
},
getMarketTagColor (market) {
const colors = {
Crypto: 'purple',
USStock: 'green',
Forex: 'gold'
}
return colors[market] || 'default'
},
getMarketLabel (market) {
const key = `aiAssetAnalysis.opportunities.market.${market}`
const translated = this.$t(key)
return translated !== key ? translated : market
},
getReasonText (opp) {
const market = (opp.market || 'Crypto').toLowerCase()
const signal = opp.signal || ''
const key = `aiAssetAnalysis.opportunities.reason.${market}.${signal}`
const translated = this.$t(key)
if (translated === key) {
// i18n key not found, fall back to backend reason
return opp.reason || ''
}
const change = Math.abs(opp.change_24h || 0).toFixed(1)
const change7d = Math.abs(opp.change_7d || 0).toFixed(1)
return translated.replace('{change}', change).replace('{change7d}', change7d)
},
formatOppPrice (price) {
if (!price) return '--'
if (price >= 10000) return (price / 1000).toFixed(1) + 'K'
if (price >= 1) return price.toFixed(2)
return price.toFixed(4)
},
analyzeOpportunity (opp) {
this.activeTab = 'quick'
const market = opp.market || 'Crypto'
this.presetSymbol = `${market}:${opp.symbol}`
this.$nextTick(() => {
this.autoAnalyzeSignal++
})
}
}
}
</script>
<style lang="less" scoped>
.ai-asset-analysis-page {
padding: 16px;
min-height: calc(100vh - 120px);
background: #f0f2f5;
/* ==================== Trading Opportunities (Carousel) ==================== */
.opp-section {
margin-bottom: 12px;
.opp-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
padding: 0 4px;
.opp-title {
font-size: 14px;
font-weight: 600;
color: #1a1a2e;
}
.opp-header-right {
display: flex;
align-items: center;
gap: 8px;
}
.opp-update-hint {
font-size: 12px;
color: #8c8c8c;
}
}
.opp-carousel-wrapper {
overflow: hidden;
position: relative;
border-radius: 10px;
// Fade masks on left and right edges
&::before,
&::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 40px;
z-index: 2;
pointer-events: none;
}
&::before {
left: 0;
background: linear-gradient(to right, #f0f2f5, transparent);
}
&::after {
right: 0;
background: linear-gradient(to left, #f0f2f5, transparent);
}
}
.opp-track {
display: flex;
gap: 10px;
animation: opp-scroll-left 60s linear infinite;
width: max-content;
padding: 4px 0;
&.paused {
animation-play-state: paused;
}
}
@keyframes opp-scroll-left {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
.opp-card {
width: 190px;
background: #fff;
border-radius: 10px;
padding: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
cursor: pointer;
transition: all 0.2s;
border-left: 3px solid #d9d9d9;
flex-shrink: 0;
&.bullish {
border-left-color: #52c41a;
}
&.bearish {
border-left-color: #ff4d4f;
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.opp-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.opp-symbol {
font-weight: 700;
font-size: 14px;
color: #1a1a2e;
}
.opp-market-tag {
font-size: 11px;
}
.opp-price {
font-size: 13px;
color: #595959;
}
.opp-change {
font-size: 15px;
font-weight: 600;
&.up {
color: #52c41a;
}
&.down {
color: #ff4d4f;
}
}
.opp-signal {
margin-top: 4px;
}
.opp-reason {
font-size: 11px;
color: #8c8c8c;
margin-top: 4px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.opp-action {
margin-top: 6px;
font-size: 12px;
color: #1890ff;
font-weight: 500;
}
}
}
/* ==================== Workspace Card ==================== */
.workspace-card {
border-radius: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
::v-deep .ant-card-body {
padding: 0;
}
.workspace-tabs {
::v-deep .ant-tabs-bar {
margin-bottom: 0;
padding: 0 16px;
border-bottom: 1px solid #f0f0f0;
}
::v-deep .ant-tabs-tab {
font-size: 15px;
padding: 14px 16px;
}
}
.tab-body {
::v-deep .ai-analysis-container.embedded {
border-radius: 0;
overflow: hidden;
}
::v-deep .portfolio-container.embedded {
border-radius: 0;
overflow: hidden;
}
}
}
/* ==================== Dark Theme ==================== */
&.theme-dark {
background: #0d1117;
.opp-section {
.opp-header .opp-title {
color: #e6edf3;
}
.opp-header .opp-update-hint {
color: #8b949e;
}
.opp-carousel-wrapper {
&::before {
background: linear-gradient(to right, #0d1117, transparent);
}
&::after {
background: linear-gradient(to left, #0d1117, transparent);
}
}
.opp-card {
background: #161b22;
border-color: #30363d;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
.opp-symbol {
color: #e6edf3;
}
.opp-price {
color: #8b949e;
}
.opp-reason {
color: #8b949e;
}
.opp-action {
color: #58a6ff;
}
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
}
}
.workspace-card {
background: #161b22;
border-color: #30363d;
.workspace-tabs {
::v-deep .ant-tabs-bar {
border-bottom-color: #30363d;
}
::v-deep .ant-tabs-tab {
color: #8b949e;
&:hover {
color: #c9d1d9;
}
}
::v-deep .ant-tabs-tab-active {
color: #58a6ff;
}
::v-deep .ant-tabs-ink-bar {
background-color: #58a6ff;
}
}
}
}
}
</style>
+768
View File
@@ -0,0 +1,768 @@
<template>
<div class="billing-page" :class="{ 'theme-dark': isDarkTheme }">
<div class="page-header">
<h2 class="page-title">
<a-icon type="wallet" />
<span>{{ $t('billing.title') }}</span>
</h2>
<p class="page-desc">{{ $t('billing.desc') }}</p>
</div>
<a-card :bordered="false" class="snapshot-card">
<div class="snapshot-row">
<div class="snap-item">
<div class="snap-label">{{ $t('billing.snapshot.credits') }}</div>
<div class="snap-value">{{ formatCredits(billing.credits) }}</div>
</div>
<div class="snap-item">
<div class="snap-label">{{ $t('billing.snapshot.vip') }}</div>
<div class="snap-value">
<a-tag v-if="billing.is_vip" color="gold">
<a-icon type="crown" /> VIP
</a-tag>
<a-tag v-else>{{ $t('billing.snapshot.notVip') }}</a-tag>
<span v-if="billing.vip_expires_at" class="vip-exp">
{{ $t('billing.snapshot.expires') }}: {{ formatDate(billing.vip_expires_at) }}
</span>
</div>
</div>
</div>
<a-alert
style="margin-top: 12px;"
type="info"
show-icon
:message="$t('billing.vipRule.title')"
:description="$t('billing.vipRule.desc')"
/>
</a-card>
<a-row :gutter="16" style="margin-top: 16px;">
<a-col :xs="24" :md="8">
<a-card :bordered="false" class="plan-card">
<div class="plan-title">{{ $t('billing.plan.monthly') }}</div>
<div class="plan-price">
${{ plans.monthly.price_usd }}
<span class="plan-unit">/ {{ $t('billing.perMonth') }}</span>
</div>
<div class="plan-benefit">
+{{ plans.monthly.credits_once }} {{ $t('billing.credits') }}
</div>
<a-button type="primary" block :loading="purchasing === 'monthly'" @click="buy('monthly')">
{{ $t('billing.buyNow') }}
</a-button>
</a-card>
</a-col>
<a-col :xs="24" :md="8">
<a-card :bordered="false" class="plan-card highlight">
<div class="plan-title">{{ $t('billing.plan.yearly') }}</div>
<div class="plan-price">
${{ plans.yearly.price_usd }}
<span class="plan-unit">/ {{ $t('billing.perYear') }}</span>
</div>
<div class="plan-benefit">
+{{ plans.yearly.credits_once }} {{ $t('billing.credits') }}
</div>
<a-button type="primary" block :loading="purchasing === 'yearly'" @click="buy('yearly')">
{{ $t('billing.buyNow') }}
</a-button>
</a-card>
</a-col>
<a-col :xs="24" :md="8">
<a-card :bordered="false" class="plan-card">
<div class="plan-title">{{ $t('billing.plan.lifetime') }}</div>
<div class="plan-price">
${{ plans.lifetime.price_usd }}
<span class="plan-unit">{{ $t('billing.once') }}</span>
</div>
<div class="plan-benefit">
{{ $t('billing.lifetimeMonthly') }} +{{ plans.lifetime.credits_monthly }} {{ $t('billing.credits') }}
</div>
<a-button type="primary" block :loading="purchasing === 'lifetime'" @click="buy('lifetime')">
{{ $t('billing.buyNow') }}
</a-button>
</a-card>
</a-col>
</a-row>
<!-- USDT Pay Modal -->
<a-modal
v-model="usdtModalVisible"
:title="null"
:footer="null"
:maskClosable="false"
:closable="false"
wrapClassName="usdt-pay-modal-wrap"
width="600px"
@cancel="closeUsdtModal"
>
<div v-if="usdtOrder" class="usdt-checkout">
<!-- Custom Header -->
<div class="checkout-header">
<div class="header-left">
<div class="usdt-logo">
<svg viewBox="0 0 32 32" width="28" height="28"><circle cx="16" cy="16" r="16" fill="#26A17B"/><path d="M17.9 17.9v-.003c-.1.008-.6.04-1.8.04-1 0-1.5-.028-1.7-.04v.004c-3.4-.15-5.9-.74-5.9-1.44 0-.7 2.5-1.29 5.9-1.44v2.3c.2.013.7.05 1.7.05 1.2 0 1.6-.04 1.8-.05v-2.3c3.4.15 5.9.74 5.9 1.44 0 .7-2.5 1.29-5.9 1.44zm0-3.12V12.7h5v-2.4H9.2v2.4h5v2.08c-3.8.18-6.7.93-6.7 1.83s2.9 1.65 6.7 1.83v6.56h3.6v-6.56c3.8-.18 6.7-.93 6.7-1.83s-2.8-1.65-6.6-1.83z" fill="#fff"/></svg>
</div>
<div class="header-text">
<div class="header-title">{{ $t('billing.usdt.title') }}</div>
<div class="header-desc">{{ $t('billing.usdt.hintDesc') }}</div>
</div>
</div>
<a-button type="link" class="close-btn" @click="closeUsdtModal">
<a-icon type="close" />
</a-button>
</div>
<!-- Status Steps -->
<div class="checkout-steps">
<div
v-for="(step, idx) in stepItems"
:key="idx"
class="step-item"
:class="{ active: idx <= usdtStepCurrent, current: idx === usdtStepCurrent }"
>
<div class="step-dot">
<span class="dot-inner" />
<span v-if="idx === usdtStepCurrent && usdtOrder.status === 'pending'" class="dot-pulse" />
</div>
<span class="step-label">{{ step }}</span>
<div v-if="idx < stepItems.length - 1" class="step-line" :class="{ filled: idx < usdtStepCurrent }" />
</div>
</div>
<!-- Main Content -->
<div class="checkout-body">
<!-- QR Section -->
<div class="qr-section">
<div class="qr-frame" :class="{ 'qr-confirmed': usdtOrder.status === 'confirmed' }">
<img :src="usdtQrUrl" alt="USDT QR" />
</div>
<div class="qr-amount">
<span class="amt-number">{{ usdtOrder.amount_usdt }}</span>
<span class="amt-currency">USDT</span>
</div>
<div class="qr-chain">
<a-tag color="green">{{ usdtOrder.chain }}</a-tag>
</div>
</div>
<!-- Info Section -->
<div class="info-section">
<!-- Address -->
<div class="info-block">
<div class="info-label">
<a-icon type="environment" />
<span>{{ $t('billing.usdt.address') }}</span>
</div>
<div class="addr-box">
<code class="addr-text">{{ usdtOrder.address }}</code>
<a-tooltip :title="$t('billing.usdt.copyAddress')">
<a-button size="small" class="copy-btn" @click="copyText(usdtOrder.address)">
<a-icon type="copy" />
</a-button>
</a-tooltip>
</div>
</div>
<!-- Amount -->
<div class="info-block">
<div class="info-label">
<a-icon type="dollar" />
<span>{{ $t('billing.usdt.amount') }}</span>
</div>
<div class="amt-box">
<code class="amt-text">{{ usdtOrder.amount_usdt }} USDT</code>
<a-tooltip :title="$t('billing.usdt.copyAmount')">
<a-button size="small" class="copy-btn" @click="copyText(usdtOrder.amount_usdt)">
<a-icon type="copy" />
</a-button>
</a-tooltip>
</div>
</div>
<!-- Warning -->
<div class="warn-strip">
<a-icon type="exclamation-circle" />
<span>{{ $t('billing.usdt.hintTitle') }}</span>
</div>
<!-- Expiry & Status -->
<div class="meta-row">
<div class="meta-status">
<a-tag :color="statusColor">{{ statusText }}</a-tag>
</div>
<div v-if="usdtOrder.expires_at" class="meta-expire">
<a-icon type="clock-circle" />
<span>{{ formatDateTime(usdtOrder.expires_at) }}</span>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="checkout-footer">
<a-button size="small" :loading="usdtRefreshing" @click="refreshUsdtOrder">
<a-icon type="reload" />
{{ $t('billing.usdt.refresh') }}
</a-button>
<a-button @click="closeUsdtModal">{{ $t('common.close') || 'Close' }}</a-button>
</div>
</div>
</a-modal>
</div>
</template>
<script>
import { mapState } from 'vuex'
import { getMembershipPlans, createUsdtOrder, getUsdtOrder } from '@/api/billing'
export default {
name: 'BillingPage',
computed: {
...mapState({
navTheme: state => state.app.theme
}),
isDarkTheme () {
return this.navTheme === 'dark' || this.navTheme === 'realdark'
},
usdtQrUrl () {
const text = this.getUsdtQrText()
if (!text) return ''
// External QR generator (MVP). Replace with local generator later if needed.
return `https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(text)}`
},
statusText () {
const s = (this.usdtOrder && this.usdtOrder.status) ? String(this.usdtOrder.status) : ''
const map = {
pending: this.$t('billing.usdt.status.pending'),
paid: this.$t('billing.usdt.status.paid'),
confirmed: this.$t('billing.usdt.status.confirmed'),
expired: this.$t('billing.usdt.status.expired'),
cancelled: this.$t('billing.usdt.status.cancelled'),
failed: this.$t('billing.usdt.status.failed')
}
return map[s] || s || '--'
},
statusColor () {
const s = (this.usdtOrder && this.usdtOrder.status) ? String(this.usdtOrder.status) : ''
if (s === 'confirmed') return 'green'
if (s === 'paid') return 'blue'
if (s === 'expired' || s === 'failed' || s === 'cancelled') return 'red'
return 'orange'
},
usdtStepCurrent () {
const s = (this.usdtOrder && this.usdtOrder.status) ? String(this.usdtOrder.status) : ''
if (s === 'confirmed') return 2
if (s === 'paid') return 1
return 0
},
stepItems () {
return [
this.$t('billing.usdt.status.pending'),
this.$t('billing.usdt.status.paid'),
this.$t('billing.usdt.status.confirmed')
]
}
},
data () {
return {
loading: false,
purchasing: '',
usdtModalVisible: false,
usdtOrder: null,
usdtPollingTimer: null,
usdtRefreshing: false,
billing: {
credits: 0,
is_vip: false,
vip_expires_at: null
},
plans: {
monthly: { price_usd: 19.9, credits_once: 500 },
yearly: { price_usd: 199, credits_once: 8000 },
lifetime: { price_usd: 499, credits_monthly: 800 }
}
}
},
created () {
this.load()
},
beforeDestroy () {
this.stopUsdtPolling()
},
methods: {
async load () {
this.loading = true
try {
const res = await getMembershipPlans()
if (res && res.code === 1 && res.data) {
this.plans = res.data.plans || this.plans
this.billing = res.data.billing || this.billing
} else {
this.$message.error(res?.msg || 'Load failed')
}
} catch (e) {
this.$message.error(e?.response?.data?.msg || 'Load failed')
} finally {
this.loading = false
}
},
formatCredits (v) {
if (!v && v !== 0) return '0'
return Number(v).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })
},
formatDate (dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleDateString()
},
async buy (plan) {
this.purchasing = plan
try {
// Prefer USDT scan-to-pay flow (B)
const res = await createUsdtOrder(plan)
if (res && res.code === 1 && res.data) {
this.usdtOrder = res.data
this.usdtModalVisible = true
this.startUsdtPolling()
} else {
this.$message.error(res?.msg || this.$t('billing.purchaseFailed'))
}
} catch (e) {
this.$message.error(e?.response?.data?.msg || this.$t('billing.purchaseFailed'))
} finally {
this.purchasing = ''
}
},
getUsdtQrText () {
// Keep it simple: QR encodes address only to avoid wallet URI incompatibilities.
if (!this.usdtOrder) return ''
return this.usdtOrder.address || ''
},
copyText (txt) {
try {
const t = String(txt || '')
if (!t) return
this.$copyText(t)
this.$message.success(this.$t('common.copySuccess') || 'Copied')
} catch (e) {
this.$message.error(this.$t('common.copyFailed') || 'Copy failed')
}
},
formatDateTime (dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleString()
},
async refreshUsdtOrder () {
if (!this.usdtOrder || !this.usdtOrder.order_id) return
this.usdtRefreshing = true
try {
const res = await getUsdtOrder(this.usdtOrder.order_id, true)
if (res && res.code === 1 && res.data) {
this.usdtOrder = res.data
if (this.usdtOrder.status === 'confirmed') {
this.$message.success(this.$t('billing.usdt.paidSuccess'))
await this.load()
this.stopUsdtPolling()
}
}
} catch (e) {
} finally {
this.usdtRefreshing = false
}
},
startUsdtPolling () {
this.stopUsdtPolling()
this.usdtPollingTimer = setInterval(() => {
this.refreshUsdtOrder()
}, 5000)
},
stopUsdtPolling () {
if (this.usdtPollingTimer) {
clearInterval(this.usdtPollingTimer)
this.usdtPollingTimer = null
}
},
closeUsdtModal () {
this.usdtModalVisible = false
this.stopUsdtPolling()
}
}
}
</script>
<style lang="less" scoped>
/* ===== Page Styles ===== */
.billing-page {
padding: 18px;
.page-header {
margin-bottom: 14px;
.page-title {
margin: 0;
font-size: 18px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.page-desc {
margin: 6px 0 0;
color: rgba(0, 0, 0, 0.45);
}
}
.snapshot-card {
.snapshot-row {
display: flex;
gap: 18px;
flex-wrap: wrap;
}
.snap-item {
min-width: 220px;
.snap-label { color: rgba(0, 0, 0, 0.45); font-size: 12px; }
.snap-value { font-size: 18px; font-weight: 700; margin-top: 2px; }
.vip-exp { margin-left: 8px; color: rgba(0, 0, 0, 0.55); font-size: 12px; }
}
}
.plan-card {
border-radius: 10px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
margin-top: 12px;
&.highlight { border: 1px solid rgba(24, 144, 255, 0.35); }
.plan-title { font-weight: 700; font-size: 16px; }
.plan-price {
margin-top: 10px; font-size: 28px; font-weight: 800;
.plan-unit { font-size: 12px; font-weight: 500; color: rgba(0, 0, 0, 0.45); margin-left: 6px; }
}
.plan-benefit { margin: 10px 0 16px; color: rgba(0, 0, 0, 0.65); font-weight: 600; }
}
&.theme-dark {
.page-header .page-desc { color: rgba(255, 255, 255, 0.55); }
.snapshot-card {
background: #161b22;
.snap-item {
.snap-label { color: rgba(255, 255, 255, 0.55); }
.snap-value { color: rgba(255, 255, 255, 0.9); }
.vip-exp { color: rgba(255, 255, 255, 0.6); }
}
}
.plan-card {
background: #161b22;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
.plan-price .plan-unit { color: rgba(255, 255, 255, 0.55); }
.plan-benefit { color: rgba(255, 255, 255, 0.7); }
}
}
}
/* ===== USDT Checkout Modal (unscoped for modal portal) ===== */
</style>
<style lang="less">
/* ===== Modal Wrapper ===== */
.usdt-pay-modal-wrap {
.ant-modal-header { display: none; }
.ant-modal-body { padding: 0 !important; }
.ant-modal-content { border-radius: 16px; overflow: hidden; }
}
/* ===== Checkout UI ===== */
.usdt-checkout {
/* -- Header -- */
.checkout-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: linear-gradient(135deg, #26A17B 0%, #1b8a6b 100%);
.header-left {
display: flex;
align-items: center;
gap: 12px;
.usdt-logo {
width: 40px; height: 40px; border-radius: 50%;
background: rgba(255,255,255,0.18);
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.header-text {
.header-title { font-size: 16px; font-weight: 800; color: #fff; }
.header-desc { font-size: 12px; color: rgba(255,255,255,0.75); margin-top: 2px; line-height: 1.4; }
}
}
.close-btn {
color: rgba(255,255,255,0.8) !important;
font-size: 18px;
&:hover { color: #fff !important; }
}
}
/* -- Steps -- */
.checkout-steps {
display: flex;
align-items: center;
padding: 16px 24px 12px;
.step-item {
display: flex;
align-items: center;
gap: 6px;
.step-dot {
position: relative;
width: 18px; height: 18px;
display: flex; align-items: center; justify-content: center;
.dot-inner {
width: 10px; height: 10px; border-radius: 50%;
background: #d9d9d9;
transition: all 0.3s;
}
.dot-pulse {
position: absolute;
width: 18px; height: 18px; border-radius: 50%;
background: rgba(38, 161, 123, 0.35);
animation: pulse-ring 1.5s ease-out infinite;
}
}
.step-label {
font-size: 12px;
color: rgba(0,0,0,0.35);
font-weight: 500;
white-space: nowrap;
transition: all 0.3s;
}
.step-line {
flex: 1;
height: 2px;
min-width: 30px;
background: #e8e8e8;
margin: 0 6px;
border-radius: 1px;
transition: all 0.3s;
&.filled { background: #26A17B; }
}
&.active {
.dot-inner { background: #26A17B; }
.step-label { color: rgba(0,0,0,0.85); font-weight: 700; }
}
&.current {
.dot-inner { background: #26A17B; box-shadow: 0 0 0 3px rgba(38, 161, 123, 0.2); }
}
}
}
@keyframes pulse-ring {
0% { transform: scale(0.8); opacity: 1; }
100% { transform: scale(1.8); opacity: 0; }
}
/* -- Body -- */
.checkout-body {
display: flex;
gap: 20px;
padding: 4px 24px 16px;
.qr-section {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
.qr-frame {
width: 180px; height: 180px;
padding: 8px;
border-radius: 14px;
background: #fff;
border: 2px solid #e8e8e8;
display: flex; align-items: center; justify-content: center;
position: relative;
transition: border-color 0.4s;
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
img { width: 100%; height: 100%; border-radius: 8px; }
&.qr-confirmed {
border-color: #26A17B;
&::after {
content: '\2713';
position: absolute;
top: -10px; right: -10px;
width: 28px; height: 28px;
background: #26A17B;
color: #fff;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 16px; font-weight: 700;
box-shadow: 0 2px 8px rgba(38,161,123,0.4);
}
}
}
.qr-amount {
margin-top: 12px;
display: flex;
align-items: baseline;
gap: 4px;
.amt-number {
font-size: 22px;
font-weight: 900;
color: rgba(0,0,0,0.88);
letter-spacing: -0.5px;
}
.amt-currency {
font-size: 12px;
font-weight: 700;
color: rgba(0,0,0,0.45);
}
}
.qr-chain {
margin-top: 4px;
}
}
.info-section {
flex: 1;
min-width: 0;
.info-block {
margin-bottom: 14px;
.info-label {
display: flex; align-items: center; gap: 6px;
font-size: 12px; color: rgba(0,0,0,0.45); font-weight: 600;
margin-bottom: 6px;
}
}
.addr-box, .amt-box {
display: flex;
align-items: center;
gap: 6px;
background: #f6f8fa;
border: 1px solid #e8e8e8;
border-radius: 8px;
padding: 8px 10px;
code {
flex: 1;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: 12px;
color: rgba(0,0,0,0.78);
word-break: break-all;
line-height: 1.5;
background: none;
border: none;
padding: 0;
}
.copy-btn {
flex-shrink: 0;
border: none;
background: rgba(0,0,0,0.04);
color: rgba(0,0,0,0.55);
border-radius: 6px;
&:hover {
background: rgba(24, 144, 255, 0.08);
color: #1890ff;
}
}
}
.warn-strip {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
background: rgba(250, 173, 20, 0.08);
border: 1px solid rgba(250, 173, 20, 0.2);
font-size: 12px;
font-weight: 600;
color: #d48806;
margin-bottom: 14px;
}
.meta-row {
display: flex;
align-items: center;
justify-content: space-between;
.meta-expire {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: rgba(0,0,0,0.45);
}
}
}
}
/* -- Footer -- */
.checkout-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px 16px;
border-top: 1px solid rgba(0,0,0,0.06);
}
}
/* ===== Mobile ===== */
@media (max-width: 560px) {
.usdt-checkout .checkout-body {
flex-direction: column;
align-items: center;
.info-section { width: 100%; }
}
}
/* ===== Dark Theme (applied via parent .theme-dark) ===== */
.theme-dark .usdt-checkout,
body.realdark .usdt-checkout {
.checkout-header {
background: linear-gradient(135deg, #1a7a5a 0%, #145c45 100%);
}
.checkout-steps {
.step-item {
.step-label { color: rgba(255,255,255,0.35); }
.step-line { background: rgba(255,255,255,0.12); &.filled { background: #26A17B; } }
&.active .step-label { color: rgba(255,255,255,0.9); }
.dot-inner { background: rgba(255,255,255,0.2); }
&.active .dot-inner { background: #26A17B; }
}
}
.checkout-body {
.qr-section {
.qr-frame { background: #1a1f28; border-color: rgba(255,255,255,0.1); box-shadow: 0 4px 20px rgba(0,0,0,0.3); }
.qr-amount .amt-number { color: rgba(255,255,255,0.9); }
.qr-amount .amt-currency { color: rgba(255,255,255,0.5); }
}
.info-section {
.info-block .info-label { color: rgba(255,255,255,0.5); }
.addr-box, .amt-box {
background: rgba(255,255,255,0.06);
border-color: rgba(255,255,255,0.1);
code { color: rgba(255,255,255,0.8); }
.copy-btn {
background: rgba(255,255,255,0.08);
color: rgba(255,255,255,0.6);
&:hover { background: rgba(24, 144, 255, 0.15); color: #40a9ff; }
}
}
.warn-strip {
background: rgba(250, 173, 20, 0.1);
border-color: rgba(250, 173, 20, 0.2);
color: #faad14;
}
.meta-row .meta-expire { color: rgba(255,255,255,0.45); }
}
}
.checkout-footer { border-top-color: rgba(255,255,255,0.08); }
}
</style>
@@ -498,6 +498,12 @@
/>
<span style="margin-left: 8px;">{{ $t('community.credits') }}</span>
</a-form-model-item>
<a-form-model-item v-if="publishPricingType === 'paid'" :label="$t('dashboard.indicator.publish.vipFree')">
<a-switch v-model="publishVipFree" />
<div style="margin-top: 6px; color: rgba(0,0,0,0.45); font-size: 12px;">
{{ $t('dashboard.indicator.publish.vipFreeHint') }}
</div>
</a-form-model-item>
<a-form-model-item :label="$t('dashboard.indicator.publish.description')" prop="description">
<a-textarea
v-model="publishDescription"
@@ -777,6 +783,7 @@ export default {
const publishPricingType = ref('free')
const publishPrice = ref(10)
const publishDescription = ref('')
const publishVipFree = ref(false)
const publishRules = {
price: [
{ required: true, message: '请输入价格', trigger: 'blur', type: 'number' }
@@ -975,14 +982,12 @@ export default {
i18nKey: `dashboard.analysis.market.${key}`
}))
} else {
// Order: USStock > Crypto > Forex > Futures > HShare > AShare
// Order: USStock > Crypto > Forex > Futures
marketTypes.value = [
{ value: 'USStock', i18nKey: 'dashboard.analysis.market.USStock' },
{ value: 'Crypto', i18nKey: 'dashboard.analysis.market.Crypto' },
{ value: 'Forex', i18nKey: 'dashboard.analysis.market.Forex' },
{ value: 'Futures', i18nKey: 'dashboard.analysis.market.Futures' },
{ value: 'HShare', i18nKey: 'dashboard.analysis.market.HShare' },
{ value: 'AShare', i18nKey: 'dashboard.analysis.market.AShare' }
{ value: 'Futures', i18nKey: 'dashboard.analysis.market.Futures' }
]
}
@@ -991,14 +996,12 @@ export default {
selectedMarketTab.value = marketTypes.value[0].value
}
} catch (error) {
// Order: USStock > Crypto > Forex > Futures > HShare > AShare
// Order: USStock > Crypto > Forex > Futures
marketTypes.value = [
{ value: 'USStock', i18nKey: 'dashboard.analysis.market.USStock' },
{ value: 'Crypto', i18nKey: 'dashboard.analysis.market.Crypto' },
{ value: 'Forex', i18nKey: 'dashboard.analysis.market.Forex' },
{ value: 'Futures', i18nKey: 'dashboard.analysis.market.Futures' },
{ value: 'HShare', i18nKey: 'dashboard.analysis.market.HShare' },
{ value: 'AShare', i18nKey: 'dashboard.analysis.market.AShare' }
{ value: 'Futures', i18nKey: 'dashboard.analysis.market.Futures' }
]
}
}
@@ -1656,6 +1659,7 @@ export default {
publishPricingType.value = indicator.pricing_type || 'free'
publishPrice.value = indicator.price || 10
publishDescription.value = indicator.description || ''
publishVipFree.value = !!indicator.vip_free
showPublishModal.value = true
}
@@ -1679,7 +1683,8 @@ export default {
description: publishDescription.value,
publishToCommunity: true,
pricingType: publishPricingType.value,
price: publishPricingType.value === 'paid' ? publishPrice.value : 0
price: publishPricingType.value === 'paid' ? publishPrice.value : 0,
vipFree: publishPricingType.value === 'paid' ? publishVipFree.value : false
}
})
@@ -1715,7 +1720,8 @@ export default {
description: publishIndicator.value.description,
publishToCommunity: false,
pricingType: 'free',
price: 0
price: 0,
vipFree: false
}
})
@@ -1845,9 +1851,7 @@ export default {
//
const getMarketName = (market) => {
const marketMap = {
'AShare': 'dashboard.indicator.market.AShare',
'USStock': 'dashboard.indicator.market.USStock',
'HShare': 'dashboard.indicator.market.HShare',
'Crypto': 'dashboard.indicator.market.Crypto',
'Forex': 'dashboard.indicator.market.Forex',
'Futures': 'dashboard.indicator.market.Futures'
@@ -1862,9 +1866,7 @@ export default {
'USStock': 'green',
'Crypto': 'purple',
'Forex': 'gold',
'Futures': 'cyan',
'AShare': 'blue',
'HShare': 'orange'
'Futures': 'cyan'
}
return colors[market] || 'default'
}
@@ -2048,6 +2050,7 @@ getMarketColor,
publishPricingType,
publishPrice,
publishDescription,
publishVipFree,
publishRules,
handlePublishIndicator,
handleConfirmPublish,
@@ -21,6 +21,9 @@
<div class="price-tag" :class="isPaid ? 'paid' : 'free'">
{{ isPaid ? `${indicator.price} ${$t('community.credits')}` : $t('community.free') }}
</div>
<div v-if="indicator.vip_free" class="vip-free-tag">
{{ $t('community.vipFree') }}
</div>
<div v-if="indicator.is_own" class="own-tag">
{{ $t('community.myIndicator') }}
</div>
@@ -250,6 +253,18 @@ export default {
}
}
.vip-free-tag {
position: absolute;
top: 8px;
left: 8px;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
background: rgba(250, 173, 20, 0.92);
color: #1f1f1f;
}
.card-content {
flex: 1;
display: flex;
@@ -105,6 +105,9 @@
<!-- 底部操作区域 -->
<div class="detail-footer">
<div class="price-info">
<a-tag v-if="detail.vip_free" color="gold" style="margin-right: 8px;">
{{ $t('community.vipFree') }}
</a-tag>
<span v-if="detail.pricing_type === 'free' || detail.price <= 0" class="free-badge">
{{ $t('community.free') }}
</span>
+23 -7
View File
@@ -1,5 +1,5 @@
<template>
<div class="portfolio-container" :class="{ 'theme-dark': isDarkTheme }">
<div class="portfolio-container" :class="{ 'theme-dark': isDarkTheme, embedded: embedded }">
<!-- 资产总览 - 第一行核心数据 -->
<div class="summary-section">
<div class="summary-card total-value">
@@ -385,7 +385,7 @@
</div>
<!-- 监控任务 -->
<div class="monitors-section">
<div class="monitors-section" ref="monitorsSection">
<div class="section-header">
<h3>
<a-icon type="eye" />
@@ -896,6 +896,12 @@ import { getNotificationSettings } from '@/api/user'
export default {
name: 'Portfolio',
props: {
embedded: {
type: Boolean,
default: false
}
},
data () {
return {
simpleImage: Empty.PRESENTED_IMAGE_SIMPLE,
@@ -1165,8 +1171,6 @@ export default {
this.marketTypes = [
{ value: 'USStock', i18nKey: 'dashboard.analysis.market.USStock' },
{ value: 'Crypto', i18nKey: 'dashboard.analysis.market.Crypto' },
{ value: 'AShare', i18nKey: 'dashboard.analysis.market.AShare' },
{ value: 'HShare', i18nKey: 'dashboard.analysis.market.HShare' },
{ value: 'Forex', i18nKey: 'dashboard.analysis.market.Forex' },
{ value: 'Futures', i18nKey: 'dashboard.analysis.market.Futures' }
]
@@ -1503,6 +1507,14 @@ export default {
}
},
// Monitor methods
focusMonitorsSection () {
this.$nextTick(() => {
const node = this.$refs.monitorsSection
if (node && typeof node.scrollIntoView === 'function') {
node.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
})
},
openAddMonitorModal () {
// Initialize with user's default notification settings
this.editingMonitor = null
@@ -1728,9 +1740,7 @@ export default {
'USStock': 'green',
'Crypto': 'purple',
'Forex': 'gold',
'Futures': 'cyan',
'AShare': 'blue',
'HShare': 'orange'
'Futures': 'cyan'
}
return colors[market] || 'default'
},
@@ -1932,6 +1942,12 @@ export default {
}
}
.portfolio-container.embedded {
padding: 0;
background: transparent;
min-height: auto;
}
.summary-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+3 -3
View File
@@ -84,7 +84,7 @@
</div>
<div class="credits-hint" v-if="billing.billing_enabled">
<a-icon type="info-circle" />
<span>{{ $t('profile.credits.hint') || '使用AI分析等功能会消耗积分VIP用户免费' }}</span>
<span>{{ $t('profile.credits.hint') || '使用AI分析/回测/监控等功能会消耗积分VIP仅可免费使用VIP免费指标。' }}</span>
</div>
</a-card>
</a-col>
@@ -715,8 +715,8 @@ export default {
},
handleRecharge () {
// Telegram
window.open(this.rechargeTelegramUrl, '_blank')
// /
this.$router.push('/billing')
},
formatCredits (credits) {
+4 -6
View File
@@ -219,7 +219,7 @@ export default {
saving: false,
schema: {},
values: {},
activeKeys: ['server', 'auth', 'ai', 'trading'],
activeKeys: ['auth', 'ai', 'trading'],
passwordVisible: {},
showRestartTip: false,
// OpenRouter
@@ -311,19 +311,17 @@ export default {
getGroupIcon (groupKey) {
const icons = {
auth: 'lock',
server: 'cloud-server',
worker: 'schedule',
notification: 'notification',
email: 'mail',
sms: 'phone',
strategy: 'fund',
network: 'global',
app: 'appstore',
ai: 'robot',
trading: 'stock',
data_source: 'database',
search: 'search',
agent: 'experiment'
agent: 'experiment',
security: 'safety',
billing: 'dollar'
}
return icons[groupKey] || 'setting'
},
@@ -22,7 +22,10 @@
</a-tag>
</template>
<template slot="entryPrice" slot-scope="text, record">
${{ parseFloat(record.entry_price || text || 0).toFixed(4) }}
<span v-if="hasValidPrice(record.entry_price || text)">
${{ parseFloat(record.entry_price || text).toFixed(4) }}
</span>
<span v-else>--</span>
</template>
<template slot="currentPrice" slot-scope="text, record">
${{ parseFloat(record.current_price || text || 0).toFixed(4) }}
@@ -160,12 +163,8 @@ export default {
if (!isFinite(lev) || lev <= 0) lev = 1
if (mt === 'spot') lev = 1
// entry_price 0 null使 current_price
let entryPrice = parseFloat(position.entry_price || position.entryPrice || 0)
if (!entryPrice || entryPrice <= 0) {
// entry_price 使 current_price
entryPrice = parseFloat(position.current_price || position.currentPrice || 0)
}
// entry_price退 current_price=
const entryPrice = parseFloat(position.entry_price || position.entryPrice || 0)
const size = parseFloat(position.size || '0') || 0
const pnl = parseFloat(position.unrealized_pnl || position.unrealizedPnl || '0') || 0
let pnlPercent = parseFloat(position.pnl_percent || position.pnlPercent || '0') || 0
@@ -183,7 +182,7 @@ export default {
symbol: position.symbol || '',
side: position.side || 'long',
size: size > 0 ? size.toString() : '0',
entry_price: entryPrice > 0 ? entryPrice.toString() : (position.entry_price || position.entryPrice || '0'),
entry_price: entryPrice > 0 ? entryPrice.toString() : '0',
current_price: position.current_price || position.currentPrice || '0',
unrealized_pnl: position.unrealized_pnl || position.unrealizedPnl || '0',
pnl_percent: pnlPercent,
@@ -199,6 +198,10 @@ export default {
this.positions = []
}
},
hasValidPrice (price) {
const value = parseFloat(price)
return Number.isFinite(value) && value > 0
},
startPolling () {
this.stopPolling()
this.pollingTimer = setInterval(() => {
@@ -369,10 +369,23 @@
:wrapClassName="isMobile ? 'mobile-modal' : ''"
:bodyStyle="{ maxHeight: '70vh', overflowY: 'auto' }">
<a-spin :spinning="loadingIndicators">
<a-steps :current="currentStep" class="steps-container">
<a-step :title="$t('trading-assistant.form.step1')" />
<a-step :title="$t('trading-assistant.form.step2Params')" />
<a-step :title="$t('trading-assistant.form.step3Signal')" />
<!-- Simple / Advanced mode toggle -->
<div class="creation-mode-toggle" v-if="!editingStrategy">
<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') }}
</a-radio-button>
<a-radio-button value="advanced">
<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')" />
</a-steps>
<div class="form-container">
@@ -467,18 +480,43 @@
:placeholder="$t('trading-assistant.placeholders.inputStrategyName')" />
</a-form-item>
<!-- 策略类型选择 -->
<a-form-item :label="$t('trading-assistant.form.strategyType')">
<a-radio-group
v-decorator="['cs_strategy_type', { initialValue: 'single' }]"
@change="handleStrategyTypeChange">
<a-radio value="single">{{ $t('trading-assistant.form.strategyTypeSingle') }}</a-radio>
<a-radio value="cross_sectional">{{ $t('trading-assistant.form.strategyTypeCrossSectional') }}</a-radio>
</a-radio-group>
<div class="form-item-hint">
{{ $t('trading-assistant.form.strategyTypeHint') }}
</div>
</a-form-item>
<!-- ===== Simple mode: show defaults summary + toggle ===== -->
<div v-if="isSimpleMode && !editingStrategy" class="simple-defaults-summary">
<a-alert type="info" show-icon style="margin-bottom: 12px;">
<template #message>
<span>{{ $t('trading-assistant.form.simpleDefaultsHint') }}</span>
</template>
<template #description>
<span>
{{ $t('trading-assistant.form.klinePeriod') }}: <b>15m</b> ·
{{ $t('trading-assistant.form.leverage') }}: <b>5x</b> ·
{{ $t('trading-assistant.form.marketType') }}: <b>{{ $t('trading-assistant.form.marketTypeFutures') }}</b> ·
{{ $t('dashboard.indicator.backtest.field.stopLossPct') }}: <b>3%</b> ·
{{ $t('dashboard.indicator.backtest.field.takeProfitPct') }}: <b>6%</b>
</span>
</template>
</a-alert>
<a-button type="link" size="small" @click="showAdvancedSettings = !showAdvancedSettings" style="padding: 0; margin-bottom: 12px;">
<a-icon :type="showAdvancedSettings ? 'up' : 'down'" />
{{ showAdvancedSettings ? $t('trading-assistant.form.hideAdvancedSettings') : $t('trading-assistant.form.showAdvancedSettings') }}
</a-button>
</div>
<!-- ===== Strategy type: only show in advanced mode ===== -->
<div v-show="isAdvancedMode || editingStrategy || showAdvancedSettings">
<!-- 策略类型选择 -->
<a-form-item :label="$t('trading-assistant.form.strategyType')">
<a-radio-group
v-decorator="['cs_strategy_type', { initialValue: 'single' }]"
@change="handleStrategyTypeChange">
<a-radio value="single">{{ $t('trading-assistant.form.strategyTypeSingle') }}</a-radio>
<a-radio value="cross_sectional">{{ $t('trading-assistant.form.strategyTypeCrossSectional') }}</a-radio>
</a-radio-group>
<div class="form-item-hint">
{{ $t('trading-assistant.form.strategyTypeHint') }}
</div>
</a-form-item>
</div>
<!-- 截面策略配置 -->
<template v-if="form.getFieldValue('cs_strategy_type') === 'cross_sectional'">
@@ -633,94 +671,100 @@
</div>
</a-form-item>
<a-row :gutter="16">
<a-col :xs="24" :sm="24" :md="12" :lg="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" :sm="24" :md="12" :lg="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>
<div class="form-item-hint">
{{ $t('trading-assistant.form.marketTypeHint') }}
</div>
</a-form-item>
</a-col>
</a-row>
<!-- ===== Advanced trading params (capital/leverage/direction/timeframe etc.) ===== -->
<div v-show="isAdvancedMode || editingStrategy || showAdvancedSettings">
<a-row :gutter="16">
<a-col :xs="24" :sm="24" :md="12" :lg="12">
<a-form-item :label="`${$t('trading-assistant.form.leverage')} (x)`">
<a-input-number
v-decorator="['leverage', { initialValue: 1, rules: [{ required: true, message: $t('trading-assistant.validation.leverageRequired') }] }]"
:min="1"
:max="form.getFieldValue('market_type') === 'spot' ? 1 : 125"
:step="1"
style="width: 100%"
:disabled="form.getFieldValue('market_type') === 'spot'" />
<div class="form-item-hint">
<span v-if="form.getFieldValue('market_type') === 'spot'">
{{ $t('trading-assistant.form.spotLeverageFixed') }}
</span>
<span v-else>
{{ $t('trading-assistant.form.leverageHint') }}
</span>
</div>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="24" :md="12" :lg="12">
<a-form-item :label="$t('trading-assistant.form.tradeDirection')">
<a-radio-group
v-decorator="['trade_direction', { initialValue: 'long' }]"
:disabled="form.getFieldValue('market_type') === 'spot'">
<a-radio value="long">{{ $t('trading-assistant.form.tradeDirectionLong') }}</a-radio>
<a-radio value="short" :disabled="form.getFieldValue('market_type') === 'spot'">
{{ $t('trading-assistant.form.tradeDirectionShort') }}
</a-radio>
<a-radio value="both" :disabled="form.getFieldValue('market_type') === 'spot'">
{{ $t('trading-assistant.form.tradeDirectionBoth') }}
</a-radio>
</a-radio-group>
<div
v-if="form.getFieldValue('market_type') === 'spot'"
class="form-item-hint"
style="color: #ff9800;">
{{ $t('trading-assistant.form.spotOnlyLongHint') }}
</div>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :xs="24" :sm="24" :md="12" :lg="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" :sm="24" :md="12" :lg="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>
<div class="form-item-hint">
{{ $t('trading-assistant.form.marketTypeHint') }}
</div>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :xs="24" :sm="24" :md="12" :lg="12">
<a-form-item :label="`${$t('trading-assistant.form.leverage')} (x)`">
<a-input-number
v-decorator="['leverage', { initialValue: 5, rules: [{ required: true, message: $t('trading-assistant.validation.leverageRequired') }] }]"
:min="1"
:max="form.getFieldValue('market_type') === 'spot' ? 1 : 125"
:step="1"
style="width: 100%"
:disabled="form.getFieldValue('market_type') === 'spot'" />
<div class="form-item-hint">
<span v-if="form.getFieldValue('market_type') === 'spot'">
{{ $t('trading-assistant.form.spotLeverageFixed') }}
</span>
<span v-else>
{{ $t('trading-assistant.form.leverageHint') }}
</span>
</div>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="24" :md="12" :lg="12">
<a-form-item :label="$t('trading-assistant.form.tradeDirection')">
<a-radio-group
v-decorator="['trade_direction', { initialValue: 'long' }]"
:disabled="form.getFieldValue('market_type') === 'spot'">
<a-radio value="long">{{ $t('trading-assistant.form.tradeDirectionLong') }}</a-radio>
<a-radio value="short" :disabled="form.getFieldValue('market_type') === 'spot'">
{{ $t('trading-assistant.form.tradeDirectionShort') }}
</a-radio>
<a-radio value="both" :disabled="form.getFieldValue('market_type') === 'spot'">
{{ $t('trading-assistant.form.tradeDirectionBoth') }}
</a-radio>
</a-radio-group>
<div
v-if="form.getFieldValue('market_type') === 'spot'"
class="form-item-hint"
style="color: #ff9800;">
{{ $t('trading-assistant.form.spotOnlyLongHint') }}
</div>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :xs="24" :sm="24" :md="12" :lg="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-col :xs="24" :sm="24" :md="12" :lg="12"></a-col>
</a-row>
</div><!-- / v-show advanced trading params wrapper -->
<a-row :gutter="16">
<a-col :xs="24" :sm="24" :md="12" :lg="12">
<a-form-item :label="$t('trading-assistant.form.klinePeriod')">
<a-select
v-decorator="['timeframe', { initialValue: '1H', 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-col :xs="24" :sm="24" :md="12" :lg="12"></a-col>
</a-row>
</a-form>
</div>
@@ -728,7 +772,7 @@
</div>
<!-- Step 2: params (backtest-like / trading params) -->
<div v-show="currentStep === 1" class="step-content">
<div v-show="currentStep === 1 || (isSimpleMode && currentStep === 0 && showAdvancedSettings)" class="step-content">
<!-- 指标策略策略参数 -->
<div v-if="strategyType === 'indicator'">
<a-form :form="form" layout="vertical">
@@ -739,7 +783,7 @@
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.stopLossPct')">
<a-input-number
v-decorator="['stop_loss_pct', { initialValue: 0 }]"
v-decorator="['stop_loss_pct', { initialValue: 3 }]"
:min="0"
:max="100"
:step="0.01"
@@ -750,7 +794,7 @@
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.takeProfitPct')">
<a-input-number
v-decorator="['take_profit_pct', { initialValue: 0 }]"
v-decorator="['take_profit_pct', { initialValue: 6 }]"
:min="0"
:max="1000"
:step="0.01"
@@ -1040,6 +1084,21 @@
</div>
</a-form-item>
<!-- Live trading disclaimer: must accept before configuring live trading -->
<a-form-item v-if="executionModeUi === 'live' && canUseLiveTrading" class="live-disclaimer-item">
<a-alert
type="warning"
showIcon
style="margin-bottom: 8px"
:message="$t('trading-assistant.liveDisclaimer.title')"
:description="$t('trading-assistant.liveDisclaimer.content')" />
<a-checkbox
v-decorator="['live_disclaimer_ack', { valuePropName: 'checked', initialValue: false }]"
@change="onLiveDisclaimerAckChange">
{{ $t('trading-assistant.liveDisclaimer.agree') }}
</a-checkbox>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.notifyChannels')">
<a-checkbox-group
v-decorator="['notify_channels', { initialValue: ['browser'] }]"
@@ -1088,7 +1147,15 @@
<a-divider v-if="executionModeUi === 'live' && canUseLiveTrading" />
<!-- Live trading: exchange credentials -->
<div v-if="executionModeUi === 'live' && canUseLiveTrading">
<a-alert
v-if="executionModeUi === 'live' && canUseLiveTrading && !liveDisclaimerAckUi"
type="warning"
showIcon
style="margin-bottom: 12px"
:message="$t('trading-assistant.liveDisclaimer.blockTitle')"
:description="$t('trading-assistant.liveDisclaimer.blockDesc')" />
<div v-if="executionModeUi === 'live' && canUseLiveTrading && liveDisclaimerAckUi">
<a-alert
type="info"
show-icon
@@ -1096,7 +1163,7 @@
:message="$t('trading-assistant.form.liveTradingConfigTitle')"
:description="$t('trading-assistant.form.liveTradingConfigHint')" />
<!-- ========== Broker Configuration (US/HK Stocks) ========== -->
<!-- ========== Broker Configuration (US Stocks) ========== -->
<template v-if="isIBKRMarket">
<a-form-item :label="$t('trading-assistant.form.broker')">
<a-select
@@ -1504,7 +1571,7 @@ const EXCHANGE_OPTIONS = [
{ value: 'deepcoin', labelKey: 'deepcoin' }
]
// Traditional broker options (US/HK stocks) - extensible for future brokers
// Traditional broker options (US stocks) - extensible for future brokers
const BROKER_OPTIONS = [
{ value: 'ibkr', labelKey: 'ibkr', name: 'Interactive Brokers' }
// Future brokers can be added here:
@@ -1530,6 +1597,20 @@ export default {
PositionRecords
},
computed: {
isAdvancedMode () {
return this.creationMode === 'advanced'
},
isSimpleMode () {
return this.creationMode === 'simple'
},
// Map internal currentStep to displayed step index for simple mode
displayCurrentStep () {
if (this.isSimpleMode && !this.editingStrategy) {
// simple mode: step 0 0, step 2 1 (step 1 is skipped)
return this.currentStep === 0 ? 0 : 1
}
return this.currentStep
},
isDarkTheme () {
return this.navTheme === 'dark' || this.navTheme === 'realdark'
},
@@ -1537,9 +1618,9 @@ export default {
// Exchanges that require passphrase
return ['okx', 'okex', 'coinbaseexchange', 'kucoin', 'bitget', 'deepcoin'].includes(this.currentExchangeId)
},
// Check if current market uses IBKR (US Stock / HK Stock)
// Check if current market uses IBKR (US Stock)
isIBKRMarket () {
return ['USStock', 'HShare'].includes(this.selectedMarketCategory)
return this.selectedMarketCategory === 'USStock'
},
// Check if current market uses MT5 (Forex)
isMT5Market () {
@@ -1601,15 +1682,15 @@ export default {
const cat = this.selectedMarketCategory || 'Crypto'
return String(cat).toLowerCase() === 'crypto'
},
// Check if selected market supports live trading (Crypto, USStock/HShare with IBKR, or Forex with MT5)
// Check if selected market supports live trading (Crypto, USStock with IBKR, or Forex with MT5)
canUseLiveTrading () {
const cat = this.selectedMarketCategory || 'Crypto'
// Crypto always supports live trading via crypto exchanges
if (String(cat).toLowerCase() === 'crypto') {
return true
}
// USStock/HShare can use IBKR for live trading
if (['USStock', 'HShare'].includes(cat)) {
// USStock can use IBKR for live trading
if (cat === 'USStock') {
return true
}
// Forex can use MT5 for live trading
@@ -1626,8 +1707,8 @@ export default {
if (String(cat).toLowerCase() === 'crypto') {
return ['binance', 'okx', 'bitget', 'bybit', 'coinbaseexchange', 'kraken', 'kucoin', 'gate', 'bitfinex'].includes(exchangeId)
}
// USStock/HShare use IBKR
if (['USStock', 'HShare'].includes(cat)) {
// USStock uses IBKR
if (cat === 'USStock') {
return this.currentBrokerId === 'ibkr'
}
// Forex uses MT5
@@ -1641,7 +1722,7 @@ export default {
// Binance Demo Trading
return this.currentExchangeId && this.currentExchangeId.toLowerCase() === 'binance'
},
// Broker options for US/HK stocks (with i18n support)
// Broker options for US stocks (with i18n support)
brokerOptions () {
return BROKER_OPTIONS.map(broker => {
let label = ''
@@ -1830,9 +1911,12 @@ export default {
strategies: [],
selectedStrategy: null,
showFormModal: false,
// Simple / Advanced creation mode
creationMode: 'simple', // 'simple' or 'advanced'
showAdvancedSettings: false,
// Only indicator strategy in local mode
strategyType: 'indicator',
selectedMarketCategory: 'Crypto', // AShare / USStock / Crypto / Forex
selectedMarketCategory: 'Crypto', // USStock / Crypto / Forex / Futures
currentStep: 0,
saving: false,
loadingIndicators: false,
@@ -1862,6 +1946,7 @@ export default {
isEditMode: false, //
supportedIPs: [], // IP
executionModeUi: 'signal',
liveDisclaimerAckUi: false,
notifyChannelsUi: ['browser'],
// User's notification settings from profile
userNotificationSettings: {
@@ -1893,8 +1978,6 @@ export default {
addSymbolMarketTypes: [
{ value: 'Crypto', i18nKey: 'dashboard.analysis.market.Crypto' },
{ value: 'USStock', i18nKey: 'dashboard.analysis.market.USStock' },
{ value: 'HShare', i18nKey: 'dashboard.analysis.market.HShare' },
{ value: 'AShare', i18nKey: 'dashboard.analysis.market.AShare' },
{ value: 'Forex', i18nKey: 'dashboard.analysis.market.Forex' },
{ value: 'Futures', i18nKey: 'dashboard.analysis.market.Futures' }
],
@@ -2223,9 +2306,7 @@ export default {
USStock: 'green',
Crypto: 'purple',
Forex: 'gold',
Futures: 'cyan',
AShare: 'blue',
HShare: 'orange'
Futures: 'cyan'
}
return colors[market] || 'default'
},
@@ -2257,7 +2338,7 @@ export default {
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ forex_broker_id: 'mt5' })
} catch (e) { }
} else if (['USStock', 'HShare'].includes(this.selectedMarketCategory)) {
} else if (this.selectedMarketCategory === 'USStock') {
this.currentBrokerId = 'ibkr'
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ broker_id: 'ibkr' })
@@ -2265,8 +2346,8 @@ export default {
}
// Markets without live trading support: force back to signal mode
// Crypto, USStock, HShare, Forex support live trading; others do not
const supportsLiveTrading = ['Crypto', 'USStock', 'HShare', 'Forex'].includes(this.selectedMarketCategory)
// Crypto, USStock, Forex support live trading; others do not
const supportsLiveTrading = ['Crypto', 'USStock', 'Forex'].includes(this.selectedMarketCategory)
if (!supportsLiveTrading) {
this.executionModeUi = 'signal'
try {
@@ -2300,7 +2381,7 @@ export default {
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ forex_broker_id: 'mt5' })
} catch (e) { }
} else if (['USStock', 'HShare'].includes(this.selectedMarketCategory)) {
} else if (this.selectedMarketCategory === 'USStock') {
this.currentBrokerId = 'ibkr'
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ broker_id: 'ibkr' })
@@ -2308,7 +2389,7 @@ export default {
}
// Markets without live trading support: force back to signal mode
const supportsLiveTrading = ['Crypto', 'USStock', 'HShare', 'Forex'].includes(this.selectedMarketCategory)
const supportsLiveTrading = ['Crypto', 'USStock', 'Forex'].includes(this.selectedMarketCategory)
if (!supportsLiveTrading) {
this.executionModeUi = 'signal'
try {
@@ -2406,6 +2487,13 @@ export default {
onExecutionModeChange (e) {
const v = e && e.target ? e.target.value : e
this.executionModeUi = v || 'signal'
// Reset disclaimer ack when switching execution mode
this.liveDisclaimerAckUi = false
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ live_disclaimer_ack: false })
} catch (err) { }
// If market doesn't support live trading, force signal mode
if (!this.canUseLiveTrading && this.executionModeUi !== 'signal') {
this.executionModeUi = 'signal'
@@ -2414,6 +2502,13 @@ export default {
} catch (err) { }
}
},
onLiveDisclaimerAckChange (e) {
const checked = !!(e && e.target && e.target.checked)
this.liveDisclaimerAckUi = checked
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ live_disclaimer_ack: checked })
} catch (err) { }
},
onNotifyChannelsChange (vals) {
this.notifyChannelsUi = Array.isArray(vals) ? vals : []
},
@@ -2478,13 +2573,16 @@ export default {
this.aiFilterEnabledUi = false
this.selectedMarketCategory = 'Crypto'
this.selectedSymbols = []
this.showAdvancedSettings = false
this.form.resetFields()
this.form.setFieldsValue({
execution_mode: 'signal',
notify_channels: ['browser'],
save_credential: false
save_credential: false,
live_disclaimer_ack: false
})
this.liveDisclaimerAckUi = false
this.showFormModal = true
this.$nextTick(() => {
@@ -2543,6 +2641,8 @@ export default {
this.selectedMarketCategory = strategy.market_category || 'Crypto'
const executionMode = strategy.execution_mode || 'signal'
this.executionModeUi = executionMode
// Editing an existing live strategy: default as acknowledged to avoid blocking edits
this.liveDisclaimerAckUi = executionMode === 'live'
const notifyChannels = (strategy.notification_config && strategy.notification_config.channels) || ['browser']
this.notifyChannelsUi = Array.isArray(notifyChannels) ? notifyChannels : ['browser']
@@ -2607,6 +2707,7 @@ export default {
this.form.setFieldsValue({
execution_mode: this.executionModeUi,
live_disclaimer_ack: this.liveDisclaimerAckUi,
notify_channels: this.notifyChannelsUi,
notify_email: strategy.notification_config?.targets?.email || '',
notify_phone: strategy.notification_config?.targets?.phone || '',
@@ -2653,13 +2754,13 @@ export default {
if (strategy.exchange_config) {
const exchangeId = strategy.exchange_config.exchange_id || ''
const isLive = this.executionModeUi === 'live'
const supportsLiveTrading = ['Crypto', 'USStock', 'HShare', 'Forex'].includes(this.selectedMarketCategory)
const isBrokerMarket = ['USStock', 'HShare'].includes(this.selectedMarketCategory)
const supportsLiveTrading = ['Crypto', 'USStock', 'Forex'].includes(this.selectedMarketCategory)
const isBrokerMarket = this.selectedMarketCategory === 'USStock'
const isForexMarket = this.selectedMarketCategory === 'Forex'
if (isLive && supportsLiveTrading) {
if (isBrokerMarket) {
// Broker configuration (US/HK stocks)
// Broker configuration (US stocks)
this.currentBrokerId = exchangeId || 'ibkr'
this.form.setFieldsValue({
broker_id: exchangeId || 'ibkr',
@@ -2858,6 +2959,9 @@ export default {
this.trailingEnabledUi = false
this.entryPctMaxUi = 100
this.aiFilterEnabledUi = false
this.showAdvancedSettings = false
this.executionModeUi = 'signal'
this.liveDisclaimerAckUi = false
this.form.resetFields()
},
@@ -3577,17 +3681,15 @@ export default {
//
handleNext () {
if (this.currentStep === 0) {
// Step 1: basic config (strategy name/symbol/capital/leverage/market type/timeframe)
// symbol selectedSymbols
const fieldsToValidate = [
'indicator_id',
'strategy_name',
'initial_capital',
'market_type',
'leverage',
'trade_direction',
'timeframe'
]
// 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')
}
// symbol
if (this.isEditMode) {
fieldsToValidate.push('symbol')
@@ -3633,7 +3735,12 @@ export default {
this.normalizeEntryPct()
})
this.currentStep++
// In simple mode: skip step 1 (params) and jump directly to step 2 (execution)
if (this.isSimpleMode && !this.editingStrategy) {
this.currentStep = 2
} else {
this.currentStep++
}
})
} else if (this.currentStep === 1) {
// Step 2: backtest-like configs are optional; proceed directly.
@@ -3649,7 +3756,12 @@ export default {
},
handlePrev () {
if (this.currentStep > 0) {
this.currentStep--
// In simple mode: from step 2 (execution) go back to step 0 (basic), skipping step 1 (params)
if (this.isSimpleMode && !this.editingStrategy && this.currentStep === 2) {
this.currentStep = 0
} else {
this.currentStep--
}
}
},
async handleSubmit () {
@@ -3659,6 +3771,12 @@ export default {
this.saving = true
const isLive = this.canUseLiveTrading && values.execution_mode === 'live'
if (isLive && !values.live_disclaimer_ack) {
this.$message.warning(this.$t('trading-assistant.liveDisclaimer.required'))
this.saving = false
return
}
if (isLive) {
const testResult = this.testResult
if (!testResult) {
@@ -3729,7 +3847,7 @@ export default {
indicator_code: indicator.code || ''
},
exchange_config: isLive ? (this.isIBKRMarket ? {
// Broker configuration (US/HK stocks)
// Broker configuration (US stocks)
exchange_id: values.broker_id || this.currentBrokerId || 'ibkr',
// IBKR specific fields
ibkr_host: values.ibkr_host || '127.0.0.1',
@@ -5008,6 +5126,15 @@ export default {
background: linear-gradient(180deg, #0d1117 0%, #161b22 100%);
color: var(--dark-text-color, #fff);
.creation-mode-toggle {
background: rgba(24, 144, 255, 0.08);
border-color: rgba(24, 144, 255, 0.2);
.mode-hint {
color: rgba(255, 255, 255, 0.45);
}
}
//
.strategy-list-col {
.strategy-list-card {
@@ -5251,6 +5378,27 @@ export default {
margin-left: 4px;
}
// Simple/Advanced mode toggle
.creation-mode-toggle {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding: 8px 12px;
background: rgba(24, 144, 255, 0.04);
border-radius: 8px;
border: 1px solid rgba(24, 144, 255, 0.12);
.mode-hint {
color: rgba(0, 0, 0, 0.45);
font-size: 12px;
}
}
.simple-defaults-summary {
margin-top: 8px;
}
//
.steps-container {
margin-bottom: 24px;
@@ -140,6 +140,11 @@
</div>
<div class="summary-info">
<div class="summary-value">{{ strategySummary.running_strategies || 0 }}</div>
<div class="summary-sub">
{{ $t('systemOverview.live') || '实盘' }}: {{ strategySummary.running_live_strategies || 0 }}
/
{{ $t('systemOverview.signal') || '仅通知' }}: {{ strategySummary.running_signal_strategies || 0 }}
</div>
<div class="summary-label">{{ $t('systemOverview.runningStrategies') || 'Running' }}</div>
</div>
</div>
@@ -149,6 +154,11 @@
</div>
<div class="summary-info">
<div class="summary-value">{{ formatNumber(strategySummary.total_capital) }}</div>
<div class="summary-sub">
{{ $t('systemOverview.live') || '实盘' }}: {{ formatNumber(strategySummary.live_capital) }}
/
{{ $t('systemOverview.signal') || '仅通知' }}: {{ formatNumber(strategySummary.signal_capital) }}
</div>
<div class="summary-label">{{ $t('systemOverview.totalCapital') || 'Total Capital' }}</div>
</div>
</div>
@@ -161,6 +171,11 @@
{{ formatPnl(strategySummary.total_pnl) }}
<span class="roi-badge">{{ strategySummary.total_roi || 0 }}%</span>
</div>
<div class="summary-sub">
{{ $t('systemOverview.live') || '实盘' }}: {{ formatPnl(strategySummary.live_pnl) }}
/
{{ $t('systemOverview.signal') || '仅通知' }}: {{ formatPnl(strategySummary.signal_pnl) }}
</div>
<div class="summary-label">{{ $t('systemOverview.totalPnl') || 'Total PnL' }}</div>
</div>
</div>
@@ -1151,6 +1166,15 @@ export default {
}
}
.summary-sub {
font-size: 12px;
color: #64748b;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.summary-label {
font-size: 13px;
color: #94a3b8;
@@ -1330,6 +1354,9 @@ export default {
background: rgba(255, 255, 255, 0.08);
}
}
.summary-sub {
color: #8b949e;
}
.summary-label {
color: #6e7681;
}