Files
YuWuKunCheng 11d897ebaa 第十版
2026-06-07 17:02:10 +08:00

2034 lines
77 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh">
<head>
<title>TradingView Chart with Python Terminal</title>
<meta charset="utf-8" name="viewport" content=
"width=device-width,initial-scale=1.0,maximum-scale=1.0,minimum-scale=1.0">
<script type="text/javascript" src=
"{{url_for('charting_library',path='/charting_library.standalone.js')}}"></script>
<link rel="stylesheet" href=
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<style>
/* 全局重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body, html {
width: 100%;
height: 100%;
overflow: hidden;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: #0b0e14;
}
.chart-area {
width: 100%;
height: 100%;
background: #131821;
overflow: hidden;
}
#tv_chart_container {
width: 100%;
height: 100%;
}
/* ----- Python 浮动面板样式 (主题自适应,修复深色文字可见性) ----- */
.python-panel {
position: fixed;
z-index: 20000;
width: 560px;
max-width: 85vw;
height: 640px;
max-height: 80vh;
border-radius: 24px;
display: flex;
flex-direction: column;
overflow: hidden;
transition: background 0.2s, border-color 0.2s, box-shadow 0.2s;
font-family: inherit;
}
.python-panel.dark-theme {
background: rgba(18, 25, 35, 0.96);
backdrop-filter: blur(24px) saturate(180%);
border: 1px solid rgba(72, 120, 255, 0.5);
box-shadow: 0 25px 40px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.08);
color: #e2e9ff; /* 基础文字颜色亮色 */
}
.python-panel.light-theme {
background: rgba(245, 248, 250, 0.96);
backdrop-filter: blur(24px) saturate(180%);
border: 1px solid rgba(41, 98, 255, 0.3);
box-shadow: 0 25px 40px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(255, 255, 255, 0.5);
color: #1e293b;
}
.python-panel-header {
padding: 14px 18px;
border-bottom: 1px solid;
display: flex;
justify-content: space-between;
align-items: center;
cursor: grab;
user-select: none;
}
.dark-theme .python-panel-header {
background: rgba(12, 20, 30, 0.85);
border-bottom-color: rgba(100, 150, 255, 0.35);
}
.light-theme .python-panel-header {
background: rgba(240, 245, 250, 0.85);
border-bottom-color: rgba(41, 98, 255, 0.2);
}
.panel-title {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
font-size: 15px;
}
.dark-theme .panel-title {
background: linear-gradient(135deg, #a0c0ff, #6ea8fe);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.light-theme .panel-title {
background: linear-gradient(135deg, #1e4bd2, #3b82f6);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.panel-title i {
font-size: 18px;
background: none;
-webkit-background-clip: unset;
}
.dark-theme .panel-title i {
color: #3b82f6;
}
.light-theme .panel-title i {
color: #2962ff;
}
.panel-close {
border-radius: 30px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.dark-theme .panel-close {
background: rgba(40, 55, 80, 0.8);
color: #cbd5ff;
}
.light-theme .panel-close {
background: rgba(220, 230, 245, 0.8);
color: #2c3e66;
}
.panel-close:hover {
background: #dc2626;
color: white;
}
.terminal-code-area {
flex: 1;
padding: 16px;
display: flex;
flex-direction: column;
min-height: 0;
}
.dark-theme .terminal-code-area {
background: rgba(8, 14, 22, 0.5);
}
.light-theme .terminal-code-area {
background: rgba(230, 240, 250, 0.5);
}
.terminal-prompt {
margin-bottom: 6px;
font-weight: 600;
}
.dark-theme .terminal-prompt {
color: #6abf69;
}
.light-theme .terminal-prompt {
color: #2b7e2e;
}
.terminal-input {
width: 100%;
flex: 1;
border-radius: 16px;
font-size: 13px;
line-height: 1.5;
resize: none;
outline: none;
padding: 14px;
font-family: 'JetBrains Mono', monospace;
}
.dark-theme .terminal-input {
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(100, 160, 255, 0.4);
color: #e2e9ff;
}
.dark-theme .terminal-input::placeholder {
color: #8aa0c0;
}
.light-theme .terminal-input {
background: white;
border: 1px solid #cbd5e1;
color: #1e293b;
}
.light-theme .terminal-input::placeholder {
color: #94a3b8;
}
.terminal-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
}
.examples-bar {
padding: 10px 16px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.dark-theme .examples-bar {
background: rgba(10, 18, 28, 0.7);
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.light-theme .examples-bar {
background: rgba(230, 240, 250, 0.6);
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.example-chip {
padding: 5px 14px;
border-radius: 40px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: 0.15s;
}
.dark-theme .example-chip {
background: rgba(30, 45, 70, 0.8);
border: 0.5px solid rgba(80, 140, 255, 0.5);
color: #b4ccff;
}
.light-theme .example-chip {
background: rgba(220, 230, 245, 0.9);
border: 0.5px solid rgba(41, 98, 255, 0.3);
color: #1e3a8a;
}
.example-chip:hover {
background: #2e4a8e;
color: white;
transform: scale(1.02);
}
.terminal-output-area {
height: 210px;
overflow-y: auto;
padding: 12px 14px;
font-size: 12px;
}
.dark-theme .terminal-output-area {
background: rgba(4, 10, 18, 0.75);
backdrop-filter: blur(8px);
border-top: 1px solid #2a3a55;
color: #e2e9ff; /* 确保输出文字明亮 */
}
.light-theme .terminal-output-area {
background: rgba(240, 248, 255, 0.8);
border-top: 1px solid #d4dcec;
color: #1e293b;
}
.terminal-output-area::-webkit-scrollbar {
width: 5px;
}
.terminal-output-area::-webkit-scrollbar-track {
background: transparent;
}
.terminal-output-area::-webkit-scrollbar-thumb {
background: #3a506e;
border-radius: 10px;
}
.output-line {
margin: 6px 0;
padding: 6px 12px;
border-radius: 10px;
border-left: 4px solid;
white-space: pre-wrap;
word-break: break-word;
}
.dark-theme .output-line {
background: rgba(20, 30, 45, 0.5);
color: inherit;
}
.light-theme .output-line {
background: rgba(255, 255, 255, 0.7);
color: inherit;
}
.output-line.success {
border-left-color: #10b981;
}
.output-line.error {
border-left-color: #f43f5e;
}
.output-line.info {
border-left-color: #3b82f6;
}
.terminal-status-bar {
padding: 8px 16px;
font-size: 11px;
display: flex;
justify-content: space-between;
}
.dark-theme .terminal-status-bar {
background: rgba(0, 0, 0, 0.4);
border-top: 1px solid #2a3a55;
color: #9ab3d0;
}
.light-theme .terminal-status-bar {
background: rgba(230, 240, 250, 0.7);
border-top: 1px solid #d1d9e6;
color: #334155;
}
.status-dot {
width: 9px;
height: 9px;
border-radius: 10px;
display: inline-block;
margin-right: 6px;
box-shadow: 0 0 6px currentColor;
}
.status-dot.connected {
background: #10b981;
box-shadow: 0 0 12px #10b981;
animation: pulse-glow 1.8s infinite;
}
.status-dot.disconnected {
background: #f87171;
}
@keyframes pulse-glow {
0% {
opacity: 0.8;
box-shadow: 0 0 5px #10b981;
}
100% {
opacity: 1;
box-shadow: 0 0 14px #34d399;
}
}
.terminal-controls {
display: flex;
gap: 8px;
pointer-events: auto;
}
.terminal-btn {
border-radius: 30px;
padding: 4px 14px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: 0.2s;
}
.dark-theme .terminal-btn {
background: rgba(40, 55, 80, 0.9);
color: #d0e0ff;
border: 0.5px solid rgba(100, 160, 255, 0.5);
}
.light-theme .terminal-btn {
background: rgba(220, 230, 245, 0.9);
color: #1e2c4a;
border: 0.5px solid rgba(41, 98, 255, 0.3);
}
.terminal-btn:hover {
background: rgba(59, 130, 246, 0.8);
color: white;
border-color: #3b82f6;
}
.terminal-btn.primary {
background: linear-gradient(145deg, #1e4bd2, #3b82f6);
border: none;
color: white;
box-shadow: 0 2px 8px #1e4bd280;
}
.terminal-btn.primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
</head>
<body>
<div class="chart-area">
<div id="tv_chart_container"></div>
</div>
<script type="text/javascript">
// ============ 配置 ============
class Config {
static get debug() {
const params = new URLSearchParams(window.location.search);
return params.get('debug') === 'true';
}
static get theme() {
const params = new URLSearchParams(window.location.search);
return params.get('theme') || "light";
}
static get exchange() {
const params = new URLSearchParams(window.location.search);
return params.get('exchange') || 'bitstamp';
}
static get symbol() {
const params = new URLSearchParams(window.location.search);
return params.get('symbol') || 'btcusd';
}
static get interval() {
const params = new URLSearchParams(window.location.search);
return params.get('interval') || '1';
}
static get cover() {
const dict = new Map([
[60, "1"],
[180, "3"],
[300, "5"],
[900, "15"],
[1800, "30"],
[3600, "1H"],
[7200, "2H"],
[14400, "4h"],
[21600, "6H"],
[43200, "12H"],
[86400, "1D"],
[259200, "3D"]
]);
const params = new URLSearchParams(window.location.search);
const val = params.get('step');
const step = Number(val);
const o = !val || isNaN(step) ? 60 : step;
return dict.get(Number(o)) || '1';
}
static get EMA_LENGTH() {
const params = new URLSearchParams(window.location.search);
return Number(params.get('ema')) || 31;
}
static get step() {
const params = new URLSearchParams(window.location.search);
const val = params.get('step');
const step = Number(val);
return !val || isNaN(step) ? 60 : step;
}
static get limit() {
const params = new URLSearchParams(window.location.search);
const val = params.get('limit');
const limit = Number(val);
return !val || isNaN(limit) ? 1000 : limit;
}
static get generator() {
const params = new URLSearchParams(window.location.search);
return params.get('generator') || 'False';
}
static get points() {
const params = new URLSearchParams(window.location.search);
const points = params.get('points');
if (!points) return [];
return points.split(',').map(Number).filter(n => !isNaN(n));
}
static get nonAsciiParams() {
const params = new URLSearchParams(window.location.search);
const result = {};
for (const [key, value] of params.entries()) {
if (/[^\u0020-\u007E]/.test(key)) {
result[key] = value;
}
}
return result;
}
static formatPeriod(seconds) {
const map = {
60: '1分钟', 180: '3分钟', 300: '5分钟', 900: '15分钟', 1800: '30分钟',
3600: '1小时', 7200: '2小时', 14400: '4小时', 21600: '6小时',
43200: '12小时', 86400: '日线', 259200: '3日线'
};
return map[seconds] || `${seconds}秒`;
}
}
class Utils {
static generateClientId() {
return 'client_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now();
}
static log(message, data = null) {
if (Config.debug) {
console.log(message, data || '');
}
}
static error(message, error = null) {
console.error(message, error || '');
}
static info(message, error = null) {
console.info(message, error || '');
}
}
// ============ 统一 WebSocket 管理器 ============
class UnifiedWebSocketManager {
constructor() {
this.clientId = Utils.generateClientId();
this.socket = null;
this.isConnected = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 3000;
this.handlers = {chart: [], python: []};
}
connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
this.socket = new WebSocket(`${protocol}//${window.location.host}/ws/${this.clientId}`);
this.socket.onopen = () => {
Utils.log("WebSocket统一连接已建立");
this.isConnected = true;
this.reconnectAttempts = 0;
this.updateConnectionStatus(true);
};
this.socket.onclose = () => {
Utils.log("WebSocket连接已关闭");
this.isConnected = false;
this.updateConnectionStatus(false);
this.attemptReconnect();
};
this.socket.onerror = (error) => {
Utils.error("WebSocket错误:", error);
this.isConnected = false;
this.updateConnectionStatus(false);
};
this.socket.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
this.handleMessage(message);
} catch (error) {
Utils.error("解析WebSocket消息失败:", error);
}
};
}
handleMessage(message) {
Utils.log("收到消息:", message);
const module = message.module || 'chart';
if (module === 'python') {
this.handlePythonMessage(message);
} else {
this.handleChartMessage(message);
}
}
handleChartMessage(message) {
this.handlers.chart.forEach(handler => {
try {
handler(message);
} catch (error) {
Utils.error("图表消息处理错误:", error);
}
});
}
handlePythonMessage(message) {
this.handlers.python.forEach(handler => {
try {
handler(message);
} catch (error) {
Utils.error("Python消息处理错误:", error);
}
});
}
sendChartMessage(message) {
this.send({...message, module: 'chart'});
}
sendPythonMessage(command, data = {}) {
this.send({command, ...data, module: 'python'});
}
send(message) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message));
} else {
Utils.error("WebSocket未连接,消息未发送:", message);
}
}
on(module, handler) {
if (this.handlers[module]) {
this.handlers[module].push(handler);
}
}
off(module, handler) {
if (this.handlers[module]) {
this.handlers[module] = this.handlers[module].filter(h => h !== handler);
}
}
updateConnectionStatus(connected) {
const event = new CustomEvent('websocket-status', {detail: {connected}});
window.dispatchEvent(event);
}
attemptReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
Utils.log(`尝试重新连接 (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
setTimeout(() => this.connect(), this.reconnectDelay);
} else {
Utils.error("达到最大重连次数");
}
}
disconnect() {
if (this.socket) {
this.socket.close();
}
}
}
// ============ Python 终端管理器 ============
class PythonTerminalManager {
constructor(websocketManager) {
this.websocketManager = websocketManager;
this.outputBuffer = [];
this.init();
}
init() {
this.bindEvents();
this.websocketManager.on('python', (message) => {
this.handlePythonMessage(message);
});
// 暴露接口供按钮调用
window.pythonWidget = {
executeCode: () => this.executeCode(),
clearCode: () => this.clearCode(),
loadExample: (name) => this.loadExample(name)
};
}
bindEvents() {
window.addEventListener('websocket-status', (event) => {
this.updateConnectionStatus(event.detail.connected);
});
const input = document.getElementById('codeInput');
input.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
this.executeCode();
}
});
}
updateConnectionStatus(connected) {
const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText');
const info = document.getElementById('connectionInfo');
if (connected) {
dot.className = 'status-dot connected';
text.textContent = '已连接';
info.textContent = 'Python 3.x | 在线';
} else {
dot.className = 'status-dot';
text.textContent = '离线';
info.textContent = 'Python 3.x | 连接中...';
}
}
executeCode() {
const code = document.getElementById('codeInput').value.trim();
if (!code) {
this.appendOutput('❌ 错误: 请输入代码', 'error');
return;
}
const executeBtn = document.getElementById('executeBtn');
executeBtn.disabled = true;
executeBtn.textContent = '执行中...';
this.appendOutput(`[${new Date().toLocaleTimeString()}] 开始执行...`, 'info');
this.websocketManager.sendPythonMessage('execute', {code});
}
clearCode() {
document.getElementById('codeInput').value = '';
this.appendOutput('代码已清空', 'info');
}
loadExample(name) {
const examples = {
'basic': `# 基础示例
print("Hello TradingView!")
import math
print(f"圆周率: {math.pi}")
print(f"2的平方根: {math.sqrt(2)}")
numbers = [1, 2, 3, 4, 5]
print(f"数字列表: {numbers}")
print(f"总和: {sum(numbers)}")`,
'chart': `# 图表分析
print("=== 图表数据分析 ===")
# 这里可以访问观察员对象,例如:
# print(观察员.笔序列)`,
'indicators': `# 技术指标计算
print("=== 技术指标 ===")`,
'trend': `# 趋势分析
print("=== 趋势分析 ===")
序列 = 图表展示序列(观察员)
基础序列 = 观察员.笔序列[272:281]
扩展序列 = []
线段.扩展分析(None, 基础序列, 扩展序列,观察员,观察员.配置)
中枢.分析(扩展序列,序列,观察员,False,"临时扩展_")`
};
if (examples[name]) {
document.getElementById('codeInput').value = examples[name];
this.appendOutput(`已加载 ${name} 示例`, 'info');
}
}
handlePythonMessage(message) {
const executeBtn = document.getElementById('executeBtn');
if (executeBtn) {
executeBtn.disabled = false;
executeBtn.textContent = '执行 (Ctrl+Enter)';
}
switch (message.type) {
case 'execution_result':
if (message.success) {
if (message.output) {
this.appendOutput(message.output, 'success');
} else {
this.appendOutput('✅ 执行成功(无输出)', 'success');
}
} else {
let errorMsg = message.error?.message || message.message || '执行错误';
this.appendOutput(`❌ 执行失败: ${errorMsg}`, 'error');
if (message.output) {
this.appendOutput(message.output, 'error');
}
}
break;
case 'environment_info':
this.appendOutput(`环境信息: ${message.execution_count} 次执行`, 'info');
break;
case 'environment_reset':
this.appendOutput(message.message, 'info');
break;
case 'chart_data':
if (message.data.error) {
this.appendOutput(`图表数据错误: ${message.data.error}`, 'error');
} else {
this.appendOutput(`图表数据已更新: ${message.data.symbol}`, 'info');
}
break;
}
}
appendOutput(text, type = 'info') {
const outputArea = document.getElementById('outputArea');
const line = document.createElement('div');
line.className = `output-line ${type}`;
line.textContent = text;
outputArea.appendChild(line);
outputArea.scrollTop = outputArea.scrollHeight;
const lines = outputArea.getElementsByClassName('output-line');
if (lines.length > 100) {
lines[0].remove();
}
}
}
// 在 TradingViewManager 类外部或内部定义映射表
const SHAPE_MAP = {
LineToolTrendLine: {shape: 'trend_line', minPoints: 2},
LineToolRectangle: {shape: 'rectangle', minPoints: 2},
LineToolArrowMarkDown: {shape: 'arrow_down', minPoints: 1},
LineToolArrowMarkUp: {shape: 'arrow_up', minPoints: 1},
LineToolPath: {shape: 'path', minPoints: 2},
LineToolHorzLine: {shape: 'horizontal_line', minPoints: 1},
LineToolVertLine: {shape: 'vertical_line', minPoints: 1},
// 可根据需要添加更多...
};
// ============ TradingView 图表管理器 ============
class TradingViewManager {
constructor(websocketManager) {
this.websocketManager = websocketManager;
this.widget = null;
this.shapeIds = new Map();
this._pendingShapeCreate = new Map(); // id → Promise, 追踪正在创建的图形
this.onRealtimeCallback = null;
this.initDataFeed();
this.websocketManager.on('chart', (message) => {
this.handleChartMessage(message);
});
this._defaultPropertiesCache = {}; // { [toolName]: { key: value } }
}
initDataFeed() {
this.datafeed = {
onReady: (callback) => {
Utils.log("[Datafeed.onReady]");
setTimeout(() => callback({
supports_search: false,
supports_group_request: false,
supports_marks: false,
supports_timescale_marks: true,
supports_time: true,
supported_resolutions: [Config.cover,],
}));
},
searchSymbols: async (userInput, exchange, symbolType, onResultReadyCallback) => {
Utils.log("[Datafeed.searchSymbols]", userInput, exchange, symbolType);
},
resolveSymbol: async (symbolName, onSymbolResolvedCallback, onResolveErrorCallback) => {
Utils.log("[Datafeed.resolveSymbol]", symbolName);
const symbolInfo = {
exchange: Config.exchange,
ticker: Config.symbol,
name: Config.symbol,
description: Config.symbol,
type: "",
session: "24x7",
timezone: "Asia/Shanghai",
minmov: 1,
pricescale: 100,
visible_plots_set: "ohlcv",
has_no_volume: false,
has_weekly_and_monthly: false,
supported_resolutions: ["1", "3", "5", "15", "30", "1H", "2H", "4H", "6H", "12H", "1D", "3D"],
volume_precision: 1,
data_status: "streaming",
has_intraday: true,
has_daily: true,
has_ticks: true,
monthly_multipliers: [],
weekly_multipliers: [],
};
onSymbolResolvedCallback(symbolInfo);
},
getBars: async (symbolInfo, resolution, periodParams, onHistoryCallback, onErrorCallback) => {
const {from, to, firstDataRequest} = periodParams;
Utils.log("[Datafeed.getBars]", {from, to, firstDataRequest});
onHistoryCallback([], {noData: true});
},
subscribeBars: (symbolInfo, resolution, onRealtimeCallback, subscriberUID, onResetCacheNeededCallback) => {
Utils.log("[Datafeed.subscribeBars] Subscriber UID:", subscriberUID);
this.onRealtimeCallback = onRealtimeCallback;
},
unsubscribeBars: (subscriberUID) => {
Utils.log("[Datafeed.unsubscribeBars] Subscriber UID:", subscriberUID);
}
};
}
initWidget() {
this.widget = new TradingView.widget({
symbol: Config.exchange + ":" + Config.symbol,
interval: Config.cover,
timezone: "Asia/Shanghai",
fullscreen: true,
container: "tv_chart_container",
datafeed: this.datafeed,
library_path: "charting_library/",
locale: "zh",
theme: Config.theme,
debug: false,
timeframe: "3D",
user_id: "public_user_id",
client_id: "yourserver.com",
favorites: {
intervals: ["1", "3", "5"],
drawingTools: ["LineToolPath", "LineToolRectangle", "LineToolTrendLine"],
},
disabled_features: [
"use_localstorage_for_settings",
"header_symbol_search",
"header_undo_redo",
"header_screenshot",
"header_compare",
"header_chart_type",
"go_to_date",
],
time_frames: [
{text: "3d", resolution: Config.cover, description: "3 Days"},
{text: "7d", resolution: Config.cover, description: "7 Days"},
{text: "1m", resolution: Config.cover, description: "一个月"},
{text: "64d", resolution: Config.cover, description: "64天"},
],
});
return this.widget;
}
setupChartEvents() {
this.widget.onChartReady(() => {
this.websocketManager.sendChartMessage({
type: 'ready',
exchange: Config.exchange,
symbol: Config.symbol,
freq: Config.step,
limit: Config.limit,
generator: Config.generator,
points: Config.points,
config: Config.nonAsciiParams
});
const chart = this.widget.activeChart();
this.widget.subscribe("drawing_event", (sourceId, drawingEventType) => {
if (drawingEventType.indexOf("click") === 0) {
this.handleDrawingClick(sourceId);
}
});
// 存储最新的十字线位置
let currentCrosshair = {time: null, price: null};
// 订阅十字线移动事件,持续更新位置
chart.crossHairMoved().subscribe(null, ({time, price}) => {
currentCrosshair = {time, price};
});
// 订阅全局鼠标松开事件(仅在点击时处理)
this.widget.subscribe('mouse_up', (params) => {
if (currentCrosshair.time !== null) {
const timestamp = currentCrosshair.time; // Unix时间戳(秒)
const price = currentCrosshair.price;
console.log(`用户点击K线时间: ${timestamp}, 价格: ${price}`);
// 如果你有历史数据数组,可以匹配完整K线信息
// const bar = yourBarsData.find(b => b.time === timestamp);
// console.log('完整K线:', bar);
}
});
});
}
handleDrawingClick(sourceId) {
try {
const shape = this.widget.activeChart().getShapeById(sourceId);
if (!shape) return;
const properties = shape.getProperties();
const points = shape.getPoints();
const toolName = shape._source.toolname;
Utils.info("Drawing clicked:", {toolName, properties, points});
switch (toolName) {
case "LineToolTrendLine":
shape.setProperties({showLabel: !properties.showLabel});
//shape.setProperties({textcolor: invertAlphaToRgba(properties.textcolor)});
this.websocketManager.sendChartMessage({
type: "query_by_index",
data_type: properties.title,
index: properties.title
});
break;
case "LineToolRectangle":
shape.setProperties({showLabel: !properties.showLabel});
//shape.setProperties({textColor: invertAlphaToRgba(properties.textColor)});
this.websocketManager.sendChartMessage({
type: "query_by_index",
data_type: properties.title,
index: properties.title
});
break;
case "LineToolArrowMarkDown":
case "LineToolArrowMarkUp":
shape.setProperties({showLabel: !properties.showLabel});
//shape.setProperties({color: invertAlphaToRgba(properties.color)});
break;
case "LineToolPath":
this.websocketManager.sendChartMessage({
type: "save_path",
data: properties,
points: points,
index: properties.title
});
const prices = points.map(item => Math.round(item.price));
const textToCopy = prices.join(', ');
navigator.clipboard.writeText(textToCopy)
.then(() => Utils.log('数据已复制到剪切板:', textToCopy))
.catch(err => Utils.error('复制失败:', err));
break;
}
} catch (error) {
Utils.error("Error handling drawing click:", error);
}
}
async handleChartMessage(message) {
Utils.log("处理图表消息:", message.type);
switch (message.type) {
case 'realtime':
if (this.widget && this.onRealtimeCallback) {
const bar = {
time: message.timestamp*1000,
close: message.close,
open: message.open,
high: message.high,
low: message.low,
volume: message.volume,
};
this.onRealtimeCallback(bar);
}
break;
case 'shape':
if (message.cmd === "APPEND") {
await this.addShape(message.id, message.points, message.name, message.overrides);
} else if (message.cmd === "REMOVE") {
await this.removeShape(message.id);
} else if (message.cmd === "MODIFY") {
await this.updateShape(message.id, message.points, message.overrides);
}
break;
case 'query_result':
Utils.log("查询结果:", message.data);
break;
case 'ready_ack':
Utils.log("图表分析器启动确认:", message);
break;
case 'connected':
Utils.log("连接成功:", message);
break;
}
}
async addShape(id, points, name, properties) {
const options = {
shape: name,
lock: false,
disableSelection: false,
disableSave: false,
disableUndo: true,
overrides: properties || {},
//zOrder: 'top',
};
try {
if (!this.widget || !this.widget.chart) {
return;
}
// 等待之前相同 ID 的创建完成(避免竞态)
const prevPending = this._pendingShapeCreate.get(id);
if (prevPending) {
await prevPending;
}
// 重复判断
const existShapeId = this.shapeIds.get(id);
if (existShapeId) {
// Utils.error("图形已存在", {id});
await this.updateShape(id, points, properties);
return;
}
// 记录 pending 状态,供 removeShape/updateShape 等待
const createPromise = (async () => {
// 创建图形
const shapeId = await this.widget.chart().createMultipointShape(points, options);
const shape = this.widget.chart().getShapeById(shapeId);
if (!shape) {
Utils.error("添加形状失败:shape 为 null", {id, shapeId});
return null;
}
// 设置属性
//Utils.log("shapeId:", shape.getProperties());
//shape.setProperties(properties);
shape.bringToFront();
// 保存映射
this.shapeIds.set(id, shapeId);
Utils.log("添加形状", {id, shapeId});
return shapeId;
})();
this._pendingShapeCreate.set(id, createPromise);
const shapeId = await createPromise;
if (shapeId === null) {
// shape creation failed
}
} catch (error) {
Utils.error("添加形状失败", error);
} finally {
this._pendingShapeCreate.delete(id);
}
}
async removeShape(id) {
try {
if (!this.widget || !this.widget.chart) return;
// 等待该 ID 的 addShape 完成(避免竞态)
const pending = this._pendingShapeCreate.get(id);
if (pending) {
await pending;
}
const shapeId = this.shapeIds.get(id);
if (!shapeId) {
Utils.log("图形不存在,无需删除", {id});
return;
}
const shape = this.widget.chart().getShapeById(shapeId);
if (shape) {
await this.widget.chart().removeEntity(shapeId);
this.shapeIds.delete(id);
}
Utils.log("删除形状成功", {id});
} catch (error) {
Utils.error("删除形状失败", error);
}
}
async updateShape(id, points, properties) {
try {
if (!this.widget || !this.widget.chart) return;
// 等待该 ID 的 addShape 完成(避免竞态)
const pending = this._pendingShapeCreate.get(id);
if (pending) {
await pending;
}
const shapeId = this.shapeIds.get(id);
if (!shapeId) {
Utils.error("更新失败:图形不存在", {id});
return;
}
const shape = this.widget.chart().getShapeById(shapeId);
if (!shape) {
Utils.error("更新失败:未找到图形实例", {id, shapeId});
return;
}
// 更新点位
if (points) {
await shape.setPoints(points);
}
// 更新配置与属性
if (properties) {
shape.setProperties(properties);
}
shape.bringToFront();
Utils.log("更新形状成功", {id});
} catch (error) {
Utils.error("更新形状失败", error);
}
}
async hideShapesByType(targetType, shapeType) {
try {
if (!this.widget || !this.widget.chart) return;
this.widget.activeChart().getAllShapes().forEach(({name, id}) => {
if (name === shapeType) {
const shape = this.widget.activeChart().getShapeById(id);
const properties = shape.getProperties();
const customId = properties.title;
try {
const [flag, period, type, index] = customId.split(':');
if (type.indexOf(targetType) !== -1) {
shape.setProperties({visible: !properties.visible});
// Utils.log(`已隐藏图形: ${customId}`);
}
} catch (error) {
Utils.log(`隐藏失败: ${customId}`);
Utils.error("批量隐藏图形失败", error);
}
}
});
} catch (error) {
Utils.error("批量隐藏图形失败", error);
}
}
async getDefaultsForTool(toolName, points) {
if (this._defaultPropertiesCache[toolName]) {
return this._defaultPropertiesCache[toolName];
}
const config = SHAPE_MAP[toolName];
if (!config) {
console.warn(`未映射的工具 ${toolName},跳过默认属性获取`);
this._defaultPropertiesCache[toolName] = {};
return {};
}
const chart = this.widget.activeChart();
let shapeId = null;
this._fetchingDefaults = true;
try {
shapeId = await chart.createMultipointShape(points, {
shape: config.shape,
disableSave: true,
disableUndo: true,
overrides: {visible: false}
});
const shape = chart.getShapeById(shapeId);
if (shape) {
this._defaultPropertiesCache[toolName] = {...shape.getProperties()};
chart.removeEntity(shapeId, {disableUndo: true}); // 无需等待删除完成
}
} catch (error) {
console.error(`获取默认属性失败 (${toolName}):`, error);
this._defaultPropertiesCache[toolName] = {};
} finally {
this._fetchingDefaults = false;
}
return this._defaultPropertiesCache[toolName];
}
async syncShapeOverrides() {
if (!this.widget || !this.widget.activeChart()) return;
const chart = this.widget.activeChart();
const shapes = chart.getAllShapes();
const payloads = [];
for (const {name, id} of shapes) {
const shape = chart.getShapeById(id);
if (!shape) continue;
const toolName = shape._source.toolname;
// 获取(或从缓存)该工具的默认属性
const defaultProps = await this.getDefaultsForTool(toolName, shape.getPoints());
const currentProps = shape.getProperties();
const overrides = {};
for (const [key, value] of Object.entries(currentProps)) {
// 只对比已缓存的字段,忽略未记录的属性(如内部生成的特殊字段)
if (defaultProps.hasOwnProperty(key) && value !== defaultProps[key]) {
overrides[key] = value;
}
}
payloads.push({
shapeType: SHAPE_MAP[toolName].shape,
points: shape.getPoints().map(p => ({time: p.time, price: p.price})),
overrides
});
}
if (payloads.length > 0) {
this.websocketManager.sendChartMessage({
type: 'sync_shape_overrides',
data: payloads
});
}
}
createHeaderButtons() {
const buttonConfigs = [
{
text: "笔买卖点", title: "显示隐藏买卖点", handler: () => {
this.hideShapesByType("笔中枢", "arrow_down");
this.hideShapesByType("笔中枢", "arrow_up")
}
},
{
text: "段买卖点", title: "显示隐藏买卖点", handler: () => {
this.hideShapesByType("中枢段", "arrow_down");
this.hideShapesByType("中枢段", "arrow_up")
}
},
{
text: "特征序列",
title: "显示隐藏特征序列",
handler: () => this.hideShapesByType("特征", "trend_line")
},
{text: "📤 固化至本地", title: "发送图形差异属性到服务器", handler: () => this.syncShapeOverrides()}
];
buttonConfigs.forEach(config => {
this.createHeaderButton(config.text, config.title, config.handler);
});
this.createButton();
}
createHeaderButton(text, title, clickHandler) {
const button = this.widget.createButton();
button.setAttribute("title", title);
button.textContent = text;
button.addEventListener("click", clickHandler);
}
setShapesVisibleByPeriod(rawType, periodSeconds, visible) {
if (!this.widget || !this.widget.activeChart()) return;
const allShapes = this.widget.activeChart().getAllShapes();
allShapes.forEach(({id}) => {
const shape = this.widget.activeChart().getShapeById(id);
if (!shape) return;
const props = shape.getProperties();
const title = props.title || '';
const parts = title.split(':');
if (parts.length < 3) return;
const [, periodStr, typeFromTitle] = parts;
const periodFromTitle = Number(periodStr);
if (typeFromTitle === rawType && periodFromTitle === periodSeconds) {
shape.setProperties({visible});
}
});
}
isPeriodVisible(rawType, periodSeconds) {
if (!this.widget || !this.widget.activeChart()) return true;
const allShapes = this.widget.activeChart().getAllShapes();
let hasAny = false;
for (const {id} of allShapes) {
const shape = this.widget.activeChart().getShapeById(id);
if (!shape) continue;
const props = shape.getProperties();
const title = props.title || '';
const parts = title.split(':');
if (parts.length < 3) continue;
const [, periodStr, typeFromTitle] = parts;
const periodFromTitle = Number(periodStr);
if (typeFromTitle === rawType && periodFromTitle === periodSeconds) {
hasAny = true;
if (props.visible === false) return false;
}
}
return hasAny;
}
/**
* 创建动态下拉菜单(按基础类型 + 周期分组)
*/
setupCustomDropdown(tvWidget) {
if (!tvWidget) return;
// ---------- 辅助函数:获取当前主题相关样式 ----------
const isDark = () => tvWidget.getTheme() === 'dark';
const getThemeColor = (light, dark) => isDark() ? dark : light;
// ---------- 1. 创建工具栏按钮 ----------
const createButton = () => {
const btn = tvWidget.createButton({align: 'right'});
btn.innerHTML = `⚙️ 图表工具`;
btn.style.cssText = `
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
padding: 0 12px;
margin: 0 4px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
color: ${getThemeColor('#131722', '#d1d4dc')};
background: transparent;
border: none;
cursor: pointer;
transition: background 0.2s, color 0.2s;
font-family: -apple-system, BlinkMacSystemFont, 'Trebuchet MS', Roboto, Ubuntu, sans-serif;
user-select: none;
`;
btn.addEventListener('mouseenter', () => {
btn.style.background = isDark() ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)';
});
btn.addEventListener('mouseleave', () => {
btn.style.background = 'transparent';
});
return btn;
};
// ---------- 2. 创建下拉面板 DOM ----------
const createPanel = () => {
const panel = document.createElement('div');
panel.style.cssText = `
position: absolute;
z-index: 10000;
background: ${getThemeColor('#ffffff', '#1e2532')};
border-radius: 8px;
box-shadow: 0 6px 20px rgba(0,0,0,0.3);
padding: 8px 0;
min-width: 240px;
max-height: 70vh;
overflow-y: auto;
border: 1px solid ${getThemeColor('#d1d4dc', '#3a4a6a')};
color: ${getThemeColor('#131722', '#d1d9e6')};
font-family: -apple-system, BlinkMacSystemFont, 'Trebuchet MS', Roboto, sans-serif;
display: none;
backdrop-filter: blur(4px);
`;
document.body.appendChild(panel);
return panel;
};
// ---------- 3. 刷新菜单内容(核心逻辑)----------
const refreshMenu = (panel, triggerBtn) => {
panel.innerHTML = '';
const chart = tvWidget.activeChart();
if (!chart) return;
// 收集图形数据:{ 基础类型: Set(周期) }
const typePeriods = {};
chart.getAllShapes().forEach(({id}) => {
const shape = chart.getShapeById(id);
if (!shape) return;
const props = shape.getProperties();
const title = props.title || '';
const parts = title.split(':');
if (parts.length < 3) return;
const period = Number(parts[1]);
const rawType = parts[2];
if (!typePeriods[rawType]) typePeriods[rawType] = new Set();
typePeriods[rawType].add(period);
});
if (Object.keys(typePeriods).length === 0) {
const emptyDiv = document.createElement('div');
emptyDiv.textContent = '暂无图形数据';
emptyDiv.style.padding = '12px 16px';
emptyDiv.style.opacity = '0.7';
panel.appendChild(emptyDiv);
return;
}
// 为每个基础类型创建可折叠分组
Object.keys(typePeriods).sort().forEach(baseType => {
const section = document.createElement('div');
const header = document.createElement('div');
header.style.cssText = `
padding: 10px 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
background: ${isDark() ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.02)'};
border-bottom: 1px solid rgba(128,128,128,0.2);
`;
header.innerHTML = `<span style="font-weight:600;">${baseType}</span><span class="toggle-arrow" style="transition: transform 0.2s;">▼</span>`;
const content = document.createElement('div');
content.style.display = 'block';
header.addEventListener('click', () => {
const arrow = header.querySelector('.toggle-arrow');
const isHidden = content.style.display === 'none';
content.style.display = isHidden ? 'block' : 'none';
arrow.style.transform = isHidden ? 'rotate(0deg)' : 'rotate(-90deg)';
});
const periods = Array.from(typePeriods[baseType]).sort((a, b) => a - b);
periods.forEach(period => {
const row = document.createElement('div');
row.style.cssText = `
padding: 6px 16px 6px 28px;
display: flex;
align-items: center;
cursor: pointer;
transition: background 0.15s;
`;
row.addEventListener('mouseenter', () => {
row.style.background = isDark() ? '#2a374b' : '#f0f3fa';
});
row.addEventListener('mouseleave', () => {
row.style.background = 'transparent';
});
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = this.isPeriodVisible(baseType, period);
checkbox.style.cssText = `
margin-right: 12px;
accent-color: #2962ff;
width: 16px;
height: 16px;
cursor: pointer;
`;
const label = document.createElement('span');
label.textContent = Config.formatPeriod(period);
label.style.flex = '1';
row.appendChild(checkbox);
row.appendChild(label);
row.addEventListener('click', (e) => {
if (e.target !== checkbox) checkbox.checked = !checkbox.checked;
this.setShapesVisibleByPeriod(baseType, period, checkbox.checked);
});
content.appendChild(row);
});
section.appendChild(header);
section.appendChild(content);
panel.appendChild(section);
});
// 底部全局控制按钮
const footer = document.createElement('div');
footer.style.cssText = `
border-top: 1px solid ${isDark() ? '#3a4a6a' : '#d1d4dc'};
padding: 8px 16px;
display: flex;
gap: 8px;
`;
const showAllBtn = document.createElement('button');
showAllBtn.textContent = '全部显示';
showAllBtn.style.cssText = `flex:1; padding: 4px; background: #2563eb; color:white; border:none; border-radius: 4px; cursor:pointer;`;
showAllBtn.addEventListener('click', () => {
Object.keys(typePeriods).forEach(baseType => {
typePeriods[baseType].forEach(period => {
this.setShapesVisibleByPeriod(baseType, period, true);
});
});
refreshMenu(panel, triggerBtn); // 递归刷新以更新复选框状态
});
const hideAllBtn = document.createElement('button');
hideAllBtn.textContent = '全部隐藏';
hideAllBtn.style.cssText = `flex:1; padding: 4px; background: #ef4444; color:white; border:none; border-radius: 4px; cursor:pointer;`;
hideAllBtn.addEventListener('click', () => {
Object.keys(typePeriods).forEach(baseType => {
typePeriods[baseType].forEach(period => {
this.setShapesVisibleByPeriod(baseType, period, false);
});
});
refreshMenu(panel, triggerBtn);
});
footer.appendChild(showAllBtn);
footer.appendChild(hideAllBtn);
panel.appendChild(footer);
};
// ---------- 4. 面板显示/隐藏及定位 ----------
const showPanel = (panel, triggerBtn) => {
refreshMenu(panel, triggerBtn);
panel.style.display = 'block';
const rect = triggerBtn.getBoundingClientRect();
requestAnimationFrame(() => {
const panelWidth = panel.offsetWidth;
let left = rect.right - panelWidth;
if (left < 0) left = 5;
panel.style.top = `${rect.bottom + 4}px`;
panel.style.left = `${left}px`;
});
};
const hidePanel = (panel) => {
panel.style.display = 'none';
};
// ---------- 5. 组装并绑定事件 ----------
const customBtn = createButton();
const panelDiv = createPanel();
let isPanelVisible = false;
customBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (isPanelVisible) {
hidePanel(panelDiv);
isPanelVisible = false;
} else {
showPanel(panelDiv, customBtn);
isPanelVisible = true;
}
});
// 点击外部关闭面板
document.addEventListener('click', (e) => {
if (!panelDiv.contains(e.target) && e.target !== customBtn && isPanelVisible) {
hidePanel(panelDiv);
isPanelVisible = false;
}
});
// 窗口大小变化时重新定位
window.addEventListener('resize', () => {
if (isPanelVisible) {
showPanel(panelDiv, customBtn);
}
});
// 保存刷新函数供外部调用(可选)
this.refreshDropdown = () => refreshMenu(panelDiv, customBtn);
}
// 创建 Python 终端按钮 + 浮动面板
setupPythonTerminal(tvWidget) {
if (!tvWidget) return;
// 私有状态变量
let panelDiv = null;
let panelVisible = false;
let triggerButton = null;
let resizeHandler = null;
let panelPythonHandler = null;
// 获取当前主题 (实时)
const getCurrentTheme = () => tvWidget.getTheme ? tvWidget.getTheme() : 'dark';
// 应用主题样式到面板 (确保文字颜色正确)
const applyThemeToPanel = () => {
if (!panelDiv) return;
const theme = getCurrentTheme();
panelDiv.classList.remove('dark-theme', 'light-theme');
panelDiv.classList.add(theme === 'dark' ? 'dark-theme' : 'light-theme');
// 额外确保连接状态点样式
const statusDot = panelDiv.querySelector('#panelStatusDot');
if (statusDot && this.websocketManager.isConnected) {
statusDot.classList.add('connected');
}
};
// 智能定位面板 (确保在可视区域内)
const repositionPanel = () => {
if (!panelDiv || !triggerButton) return;
const btnRect = triggerButton.getBoundingClientRect();
const wasVisible = panelDiv.style.display === 'flex';
if (!wasVisible) {
panelDiv.style.visibility = 'hidden';
panelDiv.style.display = 'flex';
}
const panelWidth = panelDiv.offsetWidth;
const panelHeight = panelDiv.offsetHeight;
let left = btnRect.right - panelWidth;
let top = btnRect.bottom + 8;
if (left < 10) left = 10;
if (left + panelWidth > window.innerWidth - 10)
left = window.innerWidth - panelWidth - 10;
if (top + panelHeight > window.innerHeight - 10) {
top = btnRect.top - panelHeight - 8;
if (top < 10) top = 10;
}
panelDiv.style.left = `${left}px`;
panelDiv.style.top = `${top}px`;
if (!wasVisible) {
panelDiv.style.display = 'none';
panelDiv.style.visibility = '';
}
};
// 显示面板
const showPanel = () => {
if (!panelDiv) return;
applyThemeToPanel(); // 确保主题样式最新
repositionPanel(); // 校准位置
panelDiv.style.display = 'flex';
panelVisible = true;
// 点击外部关闭
const outsideClickListener = (e) => {
if (!panelDiv.contains(e.target) && !triggerButton.contains(e.target)) {
hidePanel();
document.removeEventListener('click', outsideClickListener);
}
};
setTimeout(() => document.addEventListener('click', outsideClickListener), 50);
resizeHandler = () => {
if (panelVisible) repositionPanel();
};
window.addEventListener('resize', resizeHandler);
};
// 隐藏面板
const hidePanel = () => {
if (panelDiv) {
panelDiv.style.display = 'none';
panelVisible = false;
if (resizeHandler) {
window.removeEventListener('resize', resizeHandler);
resizeHandler = null;
}
}
};
// 创建面板 DOM 并绑定事件 (只执行一次)
const createPanelDOM = () => {
const panel = document.createElement('div');
panel.className = 'python-panel';
panel.style.display = 'none';
panel.innerHTML = `
<div class="python-panel-header" id="pythonPanelHeader">
<div class="panel-title">
<i class="fab fa-python"></i> Python 交互式终端
</div>
<div class="terminal-controls" style="gap: 8px;">
<button class="terminal-btn" id="clearPanelCodeBtn">清空</button>
<button class="terminal-btn primary" id="executePanelCodeBtn">执行 (Ctrl+Enter)</button>
<div class="panel-close" id="closePythonPanelBtn"><i class="fas fa-times"></i></div>
</div>
</div>
<div class="terminal-code-area">
<div class="terminal-prompt">>>> Python 3.x 环境 | 支持动态执行</div>
<textarea class="terminal-input" id="pythonCodeTextarea" placeholder="# 输入 Python 代码,点击执行或按 Ctrl+Enter"></textarea>
</div>
<div class="examples-bar">
<span class="example-chip" data-python-example="basic">基础示例</span>
<span class="example-chip" data-python-example="chart">图表分析</span>
<span class="example-chip" data-python-example="indicators">技术指标</span>
<span class="example-chip" data-python-example="trend">趋势分析</span>
</div>
<div class="terminal-output-area" id="pythonOutputArea">
<div class="output-line info">✨ 就绪 — 点击按钮执行代码,支持 Ctrl+Enter</div>
</div>
<div class="terminal-status-bar">
<div><span class="status-dot" id="panelStatusDot"></span> <span id="panelStatusText">连接中...</span></div>
<div>Python 3.x | WebSocket</div>
</div>
`;
document.body.appendChild(panel);
panelDiv = panel;
// 获取内部元素
const textarea = panel.querySelector('#pythonCodeTextarea');
const executeBtn = panel.querySelector('#executePanelCodeBtn');
const clearBtn = panel.querySelector('#clearPanelCodeBtn');
const closeBtn = panel.querySelector('#closePythonPanelBtn');
const outputArea = panel.querySelector('#pythonOutputArea');
const statusDot = panel.querySelector('#panelStatusDot');
const statusText = panel.querySelector('#panelStatusText');
// 辅助输出函数
const appendOutput = (text, type = 'info') => {
const line = document.createElement('div');
line.className = `output-line ${type}`;
line.textContent = text;
outputArea.appendChild(line);
outputArea.scrollTop = outputArea.scrollHeight;
if (outputArea.children.length > 150) outputArea.removeChild(outputArea.children[0]);
};
// 执行代码
const executeCode = () => {
const code = textarea.value.trim();
if (!code) {
appendOutput('❌ 请输入代码', 'error');
return;
}
executeBtn.disabled = true;
executeBtn.textContent = '执行中...';
appendOutput(`[${new Date().toLocaleTimeString()}] 执行代码...`, 'info');
this.websocketManager.sendPythonMessage('execute', {code});
};
executeBtn.addEventListener('click', executeCode);
clearBtn.addEventListener('click', () => {
textarea.value = '';
appendOutput('✏️ 代码已清空', 'info');
});
closeBtn.addEventListener('click', () => hidePanel());
textarea.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
executeCode();
}
});
// 示例代码
const examples = {
basic: `# 基础测试
print("Hello TradingView 图表!")
import math
print(f"圆周率: {math.pi}")
numbers = [1,2,3,4,5]
print(f"列表总和: {sum(numbers)}")
print("Python 终端集成成功")`,
chart: `# 图表分析示例
print("=== 图表数据上下文 ===")
print(f"当前周期: ${Config.cover}")
print(f"品种: ${Config.symbol}")
print(f"EMA 长度: ${Config.EMA_LENGTH}")`,
indicators: `# 技术指标模拟`,
trend: `# 趋势分析`
};
panel.querySelectorAll('.example-chip').forEach(chip => {
const exName = chip.getAttribute('data-python-example');
chip.addEventListener('click', () => {
if (examples[exName]) {
textarea.value = examples[exName];
appendOutput(`📁 已加载示例: ${exName}`, 'info');
} else {
appendOutput(`示例 ${exName} 不存在`, 'warning');
}
});
});
// WebSocket 连接状态更新
const updateStatus = (connected) => {
if (connected) {
statusDot.className = 'status-dot connected';
statusText.textContent = '已连接';
} else {
statusDot.className = 'status-dot disconnected';
statusText.textContent = '离线';
}
};
window.addEventListener('websocket-status', (e) => updateStatus(e.detail.connected));
updateStatus(this.websocketManager.isConnected);
// Python 执行结果处理
panelPythonHandler = (msg) => {
if (executeBtn) {
executeBtn.disabled = false;
executeBtn.textContent = '执行 (Ctrl+Enter)';
}
if (msg.type === 'execution_result') {
if (msg.success) {
if (msg.output) appendOutput(msg.output, 'success');
else appendOutput('✅ 执行成功 (无输出)', 'success');
} else {
let errMsg = msg.error?.message || msg.message || '执行错误';
appendOutput(`❌ 执行失败: ${errMsg}`, 'error');
if (msg.output) appendOutput(msg.output, 'error');
}
} else if (msg.type === 'environment_info') {
appendOutput(`️ 环境信息: ${msg.execution_count} 次执行`, 'info');
} else if (msg.type === 'environment_reset') {
appendOutput(msg.message, 'info');
}
};
this.websocketManager.on('python', panelPythonHandler);
// 拖拽功能
const header = panel.querySelector('#pythonPanelHeader');
let isDragging = false, dragStartX, dragStartY, startLeft, startTop;
header.addEventListener('mousedown', (e) => {
if (e.target.closest('.terminal-controls') || e.target.closest('.panel-close')) return;
e.preventDefault();
isDragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
const left = panel.style.left;
const top = panel.style.top;
startLeft = left ? parseInt(left) : panel.offsetLeft;
startTop = top ? parseInt(top) : panel.offsetTop;
document.body.style.userSelect = 'none';
header.style.cursor = 'grabbing';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
let newLeft = startLeft + (e.clientX - dragStartX);
let newTop = startTop + (e.clientY - dragStartY);
newLeft = Math.min(window.innerWidth - panel.offsetWidth - 15, Math.max(15, newLeft));
newTop = Math.min(window.innerHeight - panel.offsetHeight - 15, Math.max(15, newTop));
panel.style.left = `${newLeft}px`;
panel.style.top = `${newTop}px`;
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
document.body.style.userSelect = '';
header.style.cursor = 'grab';
}
});
};
// 创建工具栏按钮
const createToolbarButton = () => {
const btn = tvWidget.createButton({align: 'right'});
btn.innerHTML = `<i class="fab fa-python" style="margin-right: 6px;"></i>Python 终端`;
btn.style.cssText = `
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
padding: 0 14px;
margin: 0 6px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
background: rgba(30, 60, 100, 0.85);
color: #e0edff;
border: 1px solid rgba(59, 130, 246, 0.6);
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(4px);
z-index: 100;
`;
btn.addEventListener('mouseenter', () => {
btn.style.background = 'rgba(59, 130, 246, 0.9)';
btn.style.color = 'white';
});
btn.addEventListener('mouseleave', () => {
btn.style.background = 'rgba(30, 60, 100, 0.85)';
btn.style.color = '#e0edff';
});
return btn;
};
// 最终初始化:创建按钮和面板
if (!panelDiv) createPanelDOM();
triggerButton = createToolbarButton();
triggerButton.addEventListener('click', (e) => {
e.stopPropagation();
if (panelVisible) hidePanel();
else showPanel();
});
}
async createButton() {
// 现在你可以安全地使用 customBtn 作为触发器
this.setupCustomDropdown(this.widget);
// Python 终端按钮(核心)
this.setupPythonTerminal(this.widget);
}
}
// ============ 主应用程序 ============
class App {
constructor() {
this.websocketManager = new UnifiedWebSocketManager();
this.tradingViewManager = null;
this.pythonTerminalManager = null;
}
init() {
this.websocketManager.connect();
this.initTradingView();
}
initTradingView() {
if (typeof TradingView === 'undefined') {
console.error("TradingView库未加载");
setTimeout(() => this.initTradingView(), 1000);
return;
}
this.tradingViewManager = new TradingViewManager(this.websocketManager);
window.tvWidget = this.tradingViewManager.initWidget();
window.tvWidget.headerReady().then(() => {
window.tvWidget.activeChart().createStudy("MACD", false, false, {
in_0: 13,
in_1: 31,
in_2: 11,
in_3: "close"
});
//window.tvWidget.activeChart().createStudy("Volume", true);
window.tvWidget.activeChart().createStudy('Moving Average Exponential', false, false, {length: Config.EMA_LENGTH});
this.tradingViewManager.createHeaderButtons();
this.tradingViewManager.setupChartEvents();
});
}
}
// ============ 启动应用 ============
window.addEventListener("DOMContentLoaded", () => {
new App().init();
});
</script>
</body>
</html>