diff --git a/README.md b/README.md index d150964..a6ad81a 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ If deploying the backend is too difficult, you can temporarily use my public bac - [x] **Professional charting engine** — Full indicator and drawing tool support - [x] **Custom indicators** — Net Volume (NV-C) and Cumulative Volume Delta (CVD-C) - [x] **Multi-chart layouts** — Single, vertical split, horizontal split, and 1L+2R layouts with draggable dividers -- [x] **Canvas persistence** — Named drawing canvases per symbol, with auto-save every 5 minutes +- [x] **Chart save/load** — Built-in save/load via `save_load_adapter` (metadata in localStorage, chart content in backend API), with auto-save - [x] **Dark & Light themes** — Full theme toggle synced across widget and UI ### Watchlist & Interaction diff --git a/README_CN.md b/README_CN.md index d5bacee..a67f941 100644 --- a/README_CN.md +++ b/README_CN.md @@ -26,7 +26,7 @@ - [x] **专业图表引擎** — 完整的指标和画图工具支持 - [x] **自定义指标** — 净成交量(NV-C)和累积成交量差(CVD-C) - [x] **多图表布局** — 单图、垂直分割、水平分割、1 左 + 2 右布局,分隔线可拖动 -- [x] **画布持久化** — 按品种保存命名画布,每 5 分钟自动保存 +- [x] **图表保存/加载** — 通过内置 `save_load_adapter` 实现保存/加载(元数据存 localStorage,图表内容存后端 API),支持自动保存 - [x] **深色/浅色主题** — 主题切换同步到图表组件和 UI ### 自选列表与交互 diff --git a/frontend/auth-config.js b/frontend/auth-config.js index 9b496c7..aa15524 100644 --- a/frontend/auth-config.js +++ b/frontend/auth-config.js @@ -1,4 +1,4 @@ -window.API_CONFIG = { baseUrl: 'https://xxx.xxx.com' }; +window.API_CONFIG = { baseUrl: 'https://api-view.cathiefish.org' }; /** * Google OAuth Configuration @@ -9,7 +9,7 @@ window.API_CONFIG = { baseUrl: 'https://xxx.xxx.com' }; const AUTH_CONFIG = { // Client ID obtained from GCP Console // Replace with your actual Client ID - clientId: 'xxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com', + clientId: '136939086552-bt28icv34nucmjna8hfndr6bopr5qcp4.apps.googleusercontent.com', // Authentication success callback onSuccess: (user) => { diff --git a/frontend/index.html b/frontend/index.html index 1aa21c1..12cb60d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -693,109 +693,6 @@ flex-shrink: 0; } - /* Canvas Selector Popup */ - .canvas-selector-popup { - display: none; - position: fixed; - background: var(--tv-color-pane-background); - border: 1px solid var(--tv-color-border); - border-radius: 8px; - padding: 12px 16px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); - z-index: 1000; - min-width: 200px; - } - - .canvas-selector-popup.is-open { - display: block; - } - - .canvas-selector-title { - font-size: 11px; - font-weight: 600; - color: var(--tv-color-text-secondary); - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 10px; - } - - .canvas-list { - max-height: 200px; - overflow-y: auto; - margin-bottom: 10px; - } - - .canvas-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 10px; - border-radius: 4px; - cursor: pointer; - color: var(--tv-color-text); - } - - .canvas-item:hover { - background: var(--tv-color-toolbar-button-background-hover); - } - - .canvas-item.is-active { - background: rgba(41, 98, 255, 0.15); - color: var(--tv-color-blue); - } - - .canvas-item-name { - flex: 1; - } - - .canvas-item-delete { - opacity: 0; - color: var(--tv-color-text-secondary); - padding: 2px 6px; - border-radius: 3px; - } - - .canvas-item:hover .canvas-item-delete { - opacity: 1; - } - - .canvas-item-delete:hover { - background: rgba(255, 0, 0, 0.2); - color: #ff5252; - } - - .canvas-actions { - display: flex; - gap: 8px; - border-top: 1px solid var(--tv-color-border); - padding-top: 10px; - } - - .canvas-btn { - flex: 1; - padding: 6px 12px; - border: 1px solid var(--tv-color-border); - border-radius: 4px; - background: transparent; - color: var(--tv-color-text); - cursor: pointer; - font-size: 12px; - } - - .canvas-btn:hover { - background: var(--tv-color-toolbar-button-background-hover); - } - - .canvas-btn.primary { - background: var(--tv-color-blue); - border-color: var(--tv-color-blue); - color: #fff; - } - - .canvas-btn.primary:hover { - background: #1e53e4; - } - /* Charts Layout Container */ .charts-layout { display: flex; @@ -1106,21 +1003,6 @@ -
- - - @@ -2069,239 +1942,138 @@ selector.classList.toggle('is-open', isLayoutSelectorOpen); } - // ============ Canvas Selector ============ - let isCanvasSelectorOpen = false; - let currentCanvasNames = {}; // { chartIndex: canvasName } - - function toggleCanvasSelector() { - isCanvasSelectorOpen = !isCanvasSelectorOpen; - const selector = document.getElementById('canvasSelector'); - const btn = document.getElementById('btnCanvas'); - - if (isCanvasSelectorOpen) { - const btnRect = btn.getBoundingClientRect(); - selector.style.right = (window.innerWidth - btnRect.left + 8) + 'px'; - selector.style.top = btnRect.top + 'px'; - loadCanvasList(); - } - - selector.classList.toggle('is-open', isCanvasSelectorOpen); - } - + // ============ Save/Load Adapter ============ function getAuthHeaders() { const user = getAuthUser(); return user ? { 'X-User-Id': user.sub } : {}; } - async function loadCanvasList() { - const widget = tvWidgets[selectedChartIndex]; - if (!widget) return; + function createSaveLoadAdapter() { + const CHARTS_INDEX_KEY = 'tv_charts_index'; + const STUDY_TPL_KEY = 'tv_study_templates'; + const DRAWING_TPL_KEY = 'tv_drawing_templates'; + const CHART_TPL_KEY = 'tv_chart_templates'; - const symbol = widget.activeChart().symbol(); - document.getElementById('canvasSymbol').textContent = symbol; + function getStore(key) { return JSON.parse(localStorage.getItem(key) || '[]'); } + function setStore(key, val) { localStorage.setItem(key, JSON.stringify(val)); } + function getStoreObj(key) { return JSON.parse(localStorage.getItem(key) || '{}'); } + function setStoreObj(key, val) { localStorage.setItem(key, JSON.stringify(val)); } - try { - const res = await fetch(`${getApiBaseUrl()}/canvas/list?symbol=${symbol}`, { - headers: getAuthHeaders() - }); - const data = await res.json(); - renderCanvasList(data.canvases, symbol); - } catch (e) { - console.error('Failed to load canvas list:', e); - } - } + let nextId = Date.now(); - function renderCanvasList(canvases, symbol) { - const list = document.getElementById('canvasList'); - const currentName = currentCanvasNames[selectedChartIndex] || 'default'; + return { + getAllCharts() { + return Promise.resolve(getStore(CHARTS_INDEX_KEY)); + }, - if (canvases.length === 0) { - list.innerHTML = '
No saved canvases
'; - return; - } + saveChart(chartData) { + const index = getStore(CHARTS_INDEX_KEY); + const id = chartData.id || nextId++; + const content = chartData.content; + const symbol = chartData.symbol; + const name = chartData.name; - list.innerHTML = canvases.map(name => ` -
- ${symbol}_${name} - -
- `).join(''); - } + const existing = index.findIndex(c => c.id === id); + const meta = { id, name, symbol, resolution: chartData.resolution, timestamp: Math.floor(Date.now() / 1000) }; + if (existing >= 0) index[existing] = meta; + else index.push(meta); + setStore(CHARTS_INDEX_KEY, index); - async function loadCanvas(symbol, name) { - const widget = tvWidgets[selectedChartIndex]; - if (!widget) return; - - try { - const res = await fetch(`${getApiBaseUrl()}/canvas/load?symbol=${symbol}&name=${name}`, { - headers: getAuthHeaders() - }); - if (res.ok) { - const data = await res.json(); - widget.load(data); - // Force background color after load - widget.applyOverrides({ - "paneProperties.background": "#0F0F0F", - "paneProperties.backgroundType": "solid", - "scalesProperties.backgroundColor": "#0F0F0F" - }); - currentCanvasNames[selectedChartIndex] = name; - // Remember the canvas name used by this symbol - saveLastCanvasName(symbol, name); - toggleCanvasSelector(); - } - } catch (e) { - console.error('Failed to load canvas:', e); - } - } - - async function saveCanvas() { - const widget = tvWidgets[selectedChartIndex]; - if (!widget) return; - - const symbol = widget.activeChart().symbol(); - const name = currentCanvasNames[selectedChartIndex] || 'default'; - - widget.save(async (state) => { - try { - const res = await fetch(`${getApiBaseUrl()}/canvas/save`, { + return fetch(`${getApiBaseUrl()}/canvas/save`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, - body: JSON.stringify({ symbol, name, data: state }) - }); - if (res.ok) { - // Save successful, remember the canvas name used by this symbol - saveLastCanvasName(symbol, name); - console.log(`Canvas saved: ${symbol}_${name}`); - // Update auto-save state snapshot - lastSavedStates[selectedChartIndex] = JSON.stringify(state); - toggleCanvasSelector(); - } else { - console.error('Failed to save canvas, status:', res.status); - alert('Failed to save canvas. Please check server logs.'); - } - } catch (e) { - console.error('Failed to save canvas:', e); - alert('Failed to save canvas: ' + e.message); - } - }); - } + body: JSON.stringify({ symbol, name: String(id), data: JSON.parse(content) }) + }).then(() => id); + }, - // Auto-save related - let lastSavedStates = {}; - - async function autoSaveCanvas(index) { - const widget = tvWidgets[index]; - if (!widget) return; - - const symbol = widget.activeChart().symbol(); - const name = currentCanvasNames[index] || 'default'; - - widget.save(async (state) => { - const stateStr = JSON.stringify(state); - if (lastSavedStates[index] === stateStr) return; // No changes - - try { - const res = await fetch(`${getApiBaseUrl()}/canvas/save`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, - body: JSON.stringify({ symbol, name, data: state }) - }); - if (res.ok) { - lastSavedStates[index] = stateStr; - saveLastCanvasName(symbol, name); - console.log(`Auto-saved: ${symbol}_${name}`); - } - } catch (e) { - console.error('Auto-save failed:', e); - } - }); - } - - function startAutoSave() { - setInterval(() => { - tvWidgets.forEach((_, index) => autoSaveCanvas(index)); - }, 5 * 60 * 1000); // 5 minutes - } - - // Save the last used canvas name for each symbol - function saveLastCanvasName(symbol, name) { - try { - const key = 'lastCanvasNames'; - const data = JSON.parse(localStorage.getItem(key) || '{}'); - data[symbol.toUpperCase()] = name; - localStorage.setItem(key, JSON.stringify(data)); - } catch (e) { - console.error('Failed to save last canvas name:', e); - } - } - - // Get the last used canvas name for a symbol - function getLastCanvasName(symbol) { - try { - const key = 'lastCanvasNames'; - const data = JSON.parse(localStorage.getItem(key) || '{}'); - return data[symbol.toUpperCase()] || null; - } catch (e) { - return null; - } - } - - // Auto-load the last saved canvas - async function autoLoadCanvas(widget, index) { - const symbol = widget.activeChart().symbol(); - const lastName = getLastCanvasName(symbol); - - if (lastName) { - try { - const res = await fetch(`${getApiBaseUrl()}/canvas/load?symbol=${symbol}&name=${lastName}`, { + getChartContent(chartId) { + const index = getStore(CHARTS_INDEX_KEY); + const chart = index.find(c => c.id == chartId); + if (!chart) return Promise.reject('Chart not found'); + return fetch(`${getApiBaseUrl()}/canvas/load?symbol=${chart.symbol}&name=${chartId}`, { headers: getAuthHeaders() - }); - if (res.ok) { - const data = await res.json(); - widget.load(data); - widget.applyOverrides({ - "paneProperties.background": "#0F0F0F", - "paneProperties.backgroundType": "solid", - "scalesProperties.backgroundColor": "#0F0F0F" - }); - currentCanvasNames[index] = lastName; - console.log(`Auto-loaded canvas: ${symbol}_${lastName}`); + }).then(r => r.text()); + }, + + removeChart(id) { + const index = getStore(CHARTS_INDEX_KEY); + const chart = index.find(c => c.id == id); + const newIndex = index.filter(c => c.id != id); + setStore(CHARTS_INDEX_KEY, newIndex); + if (chart) { + return fetch(`${getApiBaseUrl()}/canvas/delete?symbol=${chart.symbol}&name=${id}`, { + method: 'DELETE', headers: getAuthHeaders() + }).then(() => {}); } - } catch (e) { - console.log(`No saved canvas found for ${symbol}`); - } - } - } + return Promise.resolve(); + }, - async function newCanvas() { - const widget = tvWidgets[selectedChartIndex]; - if (!widget) return; + getAllStudyTemplates() { return Promise.resolve(getStore(STUDY_TPL_KEY)); }, + saveStudyTemplate(data) { + const list = getStore(STUDY_TPL_KEY); + const existing = list.findIndex(t => t.name === data.name); + if (existing >= 0) list[existing] = data; + else list.push(data); + setStore(STUDY_TPL_KEY, list); + const contents = getStoreObj(STUDY_TPL_KEY + '_content'); + contents[data.name] = data.content; + setStoreObj(STUDY_TPL_KEY + '_content', contents); + return Promise.resolve(); + }, + removeStudyTemplate(info) { + setStore(STUDY_TPL_KEY, getStore(STUDY_TPL_KEY).filter(t => t.name !== info.name)); + const contents = getStoreObj(STUDY_TPL_KEY + '_content'); + delete contents[info.name]; + setStoreObj(STUDY_TPL_KEY + '_content', contents); + return Promise.resolve(); + }, + getStudyTemplateContent(info) { + const contents = getStoreObj(STUDY_TPL_KEY + '_content'); + return Promise.resolve(contents[info.name] || ''); + }, - const symbol = widget.activeChart().symbol(); - const name = await showInputModal('New Canvas Name', 'default', 'Enter canvas name...'); - if (!name) return; + getDrawingTemplates(toolName) { + const all = getStoreObj(DRAWING_TPL_KEY); + return Promise.resolve(Object.keys(all[toolName] || {})); + }, + loadDrawingTemplate(toolName, templateName) { + const all = getStoreObj(DRAWING_TPL_KEY); + return Promise.resolve((all[toolName] || {})[templateName] || ''); + }, + saveDrawingTemplate(toolName, templateName, content) { + const all = getStoreObj(DRAWING_TPL_KEY); + if (!all[toolName]) all[toolName] = {}; + all[toolName][templateName] = content; + setStoreObj(DRAWING_TPL_KEY, all); + return Promise.resolve(); + }, + removeDrawingTemplate(toolName, templateName) { + const all = getStoreObj(DRAWING_TPL_KEY); + if (all[toolName]) delete all[toolName][templateName]; + setStoreObj(DRAWING_TPL_KEY, all); + return Promise.resolve(); + }, - currentCanvasNames[selectedChartIndex] = name; - await saveCanvas(); - loadCanvasList(); - } - - async function deleteCanvas(symbol, name) { - if (!confirm(`Delete canvas "${symbol}_${name}"?`)) return; - - try { - await fetch(`${getApiBaseUrl()}/canvas/delete?symbol=${symbol}&name=${name}`, { - method: 'DELETE', - headers: getAuthHeaders() - }); - if (currentCanvasNames[selectedChartIndex] === name) { - currentCanvasNames[selectedChartIndex] = 'default'; - } - loadCanvasList(); - } catch (e) { - console.error('Failed to delete canvas:', e); - } + getAllChartTemplates() { + return Promise.resolve(Object.keys(getStoreObj(CHART_TPL_KEY))); + }, + saveChartTemplate(name, content) { + const all = getStoreObj(CHART_TPL_KEY); + all[name] = content; + setStoreObj(CHART_TPL_KEY, all); + return Promise.resolve(); + }, + getChartTemplateContent(name) { + const all = getStoreObj(CHART_TPL_KEY); + return Promise.resolve({ content: all[name] || {} }); + }, + removeChartTemplate(name) { + const all = getStoreObj(CHART_TPL_KEY); + delete all[name]; + setStoreObj(CHART_TPL_KEY, all); + return Promise.resolve(); + }, + }; } function changeLayout(layout) { @@ -2558,7 +2330,9 @@ fullscreen: false, autosize: true, theme: isDarkTheme ? "Dark" : "Light", - load_last_chart: false, + save_load_adapter: createSaveLoadAdapter(), + load_last_chart: true, + auto_save_delay: 5, custom_indicators_getter: (PineJS) => { window.PineJS = PineJS; return Promise.resolve([NetVolumeIndicator, CVDIndicator]); @@ -2647,9 +2421,6 @@ } widget._ready = true; - - // Auto-load the last saved canvas - autoLoadCanvas(widget, index); }); tvWidgets[index] = widget; @@ -2682,14 +2453,6 @@ } } - // Close canvas selector if clicking outside - if (isCanvasSelectorOpen) { - const canvasSelector = document.getElementById('canvasSelector'); - const canvasBtn = document.getElementById('btnCanvas'); - if (!canvasSelector.contains(e.target) && !canvasBtn.contains(e.target)) { - toggleCanvasSelector(); - } - } } // ============ Initialize Watchlist Event Listeners ============ @@ -2851,8 +2614,6 @@ createWidget(0); selectChart(0); - // Start auto-save - startAutoSave(); } // ============ Authentication Guard ============ @@ -2894,20 +2655,31 @@ }; async function initApp() { - const user = checkAuth(); - if (!user) { - window.location.href = './login.html'; - return; + // DEV_MODE: skip auth for local preview + const DEV_MODE = (location.hostname === 'localhost' || location.hostname === '127.0.0.1'); + if (!DEV_MODE) { + const user = checkAuth(); + if (!user) { + window.location.href = './login.html'; + return; + } + console.log('Authenticated as:', user.email); + } else { + console.log('DEV_MODE: skipping auth'); } - console.log('Authenticated as:', user.email); - const backendReady = await waitForBackend(); if (backendReady) { await fetchDailyOpens(); init(); } else { - alert('Backend server is not responding. Please ensure it is running.'); + // In dev mode, still try to init even without backend + if (DEV_MODE) { + console.warn('Backend not available, initializing anyway for preview'); + init(); + } else { + alert('Backend server is not responding. Please ensure it is running.'); + } } } diff --git a/frontend/login.html b/frontend/login.html index 33fd0a4..1020c6d 100644 --- a/frontend/login.html +++ b/frontend/login.html @@ -127,7 +127,7 @@ const userData = parseJwt(credential); // Email whitelist verification - const allowedEmails = ['xxx@gmail.com']; + const allowedEmails = ['your-email@gmail.com']; if (!allowedEmails.includes(userData.email)) { document.getElementById('error-msg').textContent = 'Access denied: unauthorized email.'; document.getElementById('error-msg').style.display = 'block';