Replace custom canvas save/load with built-in save_load_adapter

- Remove custom canvas selector UI (HTML popup, CSS styles, toolbar button)
- Remove all custom canvas JS functions (toggleCanvasSelector, loadCanvas,
  saveCanvas, autoSaveCanvas, startAutoSave, newCanvas, deleteCanvas, etc.)
- Add createSaveLoadAdapter() implementing TradingView's save_load_adapter:
  - Chart layouts: localStorage index + backend API storage (no backend changes)
  - Study/Drawing/Chart templates: pure localStorage
- Enable load_last_chart and auto_save_delay in widget config
- Add DEV_MODE auth bypass for localhost development
- Update auth-config.js with production API URL and OAuth client ID
- Update README.md and README_CN.md feature descriptions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
0xcathiefish
2026-02-14 15:19:27 +00:00
parent 2c3eec4742
commit 9812bc7635
5 changed files with 139 additions and 367 deletions
+1 -1
View File
@@ -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] **Professional charting engine** — Full indicator and drawing tool support
- [x] **Custom indicators** — Net Volume (NV-C) and Cumulative Volume Delta (CVD-C) - [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] **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 - [x] **Dark & Light themes** — Full theme toggle synced across widget and UI
### Watchlist & Interaction ### Watchlist & Interaction
+1 -1
View File
@@ -26,7 +26,7 @@
- [x] **专业图表引擎** — 完整的指标和画图工具支持 - [x] **专业图表引擎** — 完整的指标和画图工具支持
- [x] **自定义指标** — 净成交量(NV-C)和累积成交量差(CVD-C) - [x] **自定义指标** — 净成交量(NV-C)和累积成交量差(CVD-C)
- [x] **多图表布局** — 单图、垂直分割、水平分割、1 左 + 2 右布局,分隔线可拖动 - [x] **多图表布局** — 单图、垂直分割、水平分割、1 左 + 2 右布局,分隔线可拖动
- [x] **画布持久化**按品种保存命名画布,每 5 分钟自动保存 - [x] **图表保存/加载**通过内置 `save_load_adapter` 实现保存/加载(元数据存 localStorage,图表内容存后端 API),支持自动保存
- [x] **深色/浅色主题** — 主题切换同步到图表组件和 UI - [x] **深色/浅色主题** — 主题切换同步到图表组件和 UI
### 自选列表与交互 ### 自选列表与交互
+2 -2
View File
@@ -1,4 +1,4 @@
window.API_CONFIG = { baseUrl: 'https://xxx.xxx.com' }; window.API_CONFIG = { baseUrl: 'https://api-view.cathiefish.org' };
/** /**
* Google OAuth Configuration * Google OAuth Configuration
@@ -9,7 +9,7 @@ window.API_CONFIG = { baseUrl: 'https://xxx.xxx.com' };
const AUTH_CONFIG = { const AUTH_CONFIG = {
// Client ID obtained from GCP Console // Client ID obtained from GCP Console
// Replace with your actual Client ID // Replace with your actual Client ID
clientId: 'xxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com', clientId: '136939086552-bt28icv34nucmjna8hfndr6bopr5qcp4.apps.googleusercontent.com',
// Authentication success callback // Authentication success callback
onSuccess: (user) => { onSuccess: (user) => {
+134 -362
View File
@@ -693,109 +693,6 @@
flex-shrink: 0; 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 Container */
.charts-layout { .charts-layout {
display: flex; display: flex;
@@ -1106,21 +1003,6 @@
</path> </path>
</svg> </svg>
</button> </button>
<button class="toolbar-button" id="btnCanvas" onclick="toggleCanvasSelector()" title="Canvas Selector">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44" width="44" height="44"
fill="currentColor">
<g clip-path="url(#a)">
<path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"
d="M22 8a14 14 0 1 0 0 28 14 14 0 0 0 0-28ZM9 22a13 13 0 0 1 22.03-9.35L27.5 16.2a8 8 0 1 0 .68.73l3.55-3.55A13 13 0 1 1 9 22Zm17.79-5.1a7 7 0 1 0 .68.74l-3.62 3.6A2 2 0 0 1 22 24a2 2 0 1 1 1.25-3.56l3.54-3.54Z">
</path>
</g>
<defs>
<clipPath id="a">
<path fill="currentColor" d="M0 0h44v44H0z"></path>
</clipPath>
</defs>
</svg>
</button>
<div class="toolbar-spacer"></div> <div class="toolbar-spacer"></div>
<button class="toolbar-button" id="btnTheme" onclick="toggleTheme()" title="Toggle Theme"> <button class="toolbar-button" id="btnTheme" onclick="toggleTheme()" title="Toggle Theme">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44" width="44" height="44" fill="none"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44" width="44" height="44" fill="none">
@@ -1173,15 +1055,6 @@
</div> </div>
</div> </div>
<!-- Canvas Selector Popup -->
<div class="canvas-selector-popup" id="canvasSelector">
<div class="canvas-selector-title">Canvas: <span id="canvasSymbol">-</span></div>
<div class="canvas-list" id="canvasList"></div>
<div class="canvas-actions">
<button class="canvas-btn" onclick="newCanvas()">New</button>
<button class="canvas-btn primary" onclick="saveCanvas()">Save</button>
</div>
</div>
</div> </div>
</div> </div>
@@ -2069,239 +1942,138 @@
selector.classList.toggle('is-open', isLayoutSelectorOpen); selector.classList.toggle('is-open', isLayoutSelectorOpen);
} }
// ============ Canvas Selector ============ // ============ Save/Load Adapter ============
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);
}
function getAuthHeaders() { function getAuthHeaders() {
const user = getAuthUser(); const user = getAuthUser();
return user ? { 'X-User-Id': user.sub } : {}; return user ? { 'X-User-Id': user.sub } : {};
} }
async function loadCanvasList() { function createSaveLoadAdapter() {
const widget = tvWidgets[selectedChartIndex]; const CHARTS_INDEX_KEY = 'tv_charts_index';
if (!widget) return; 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(); function getStore(key) { return JSON.parse(localStorage.getItem(key) || '[]'); }
document.getElementById('canvasSymbol').textContent = symbol; 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 { let nextId = Date.now();
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);
}
}
function renderCanvasList(canvases, symbol) { return {
const list = document.getElementById('canvasList'); getAllCharts() {
const currentName = currentCanvasNames[selectedChartIndex] || 'default'; return Promise.resolve(getStore(CHARTS_INDEX_KEY));
},
if (canvases.length === 0) { saveChart(chartData) {
list.innerHTML = '<div style="color:var(--tv-color-text-secondary);padding:8px;">No saved canvases</div>'; const index = getStore(CHARTS_INDEX_KEY);
return; const id = chartData.id || nextId++;
} const content = chartData.content;
const symbol = chartData.symbol;
const name = chartData.name;
list.innerHTML = canvases.map(name => ` const existing = index.findIndex(c => c.id === id);
<div class="canvas-item ${name === currentName ? 'is-active' : ''}" onclick="loadCanvas('${symbol}', '${name}')"> const meta = { id, name, symbol, resolution: chartData.resolution, timestamp: Math.floor(Date.now() / 1000) };
<span class="canvas-item-name">${symbol}_${name}</span> if (existing >= 0) index[existing] = meta;
<span class="canvas-item-delete" onclick="event.stopPropagation();deleteCanvas('${symbol}','${name}')">✕</span> else index.push(meta);
</div> setStore(CHARTS_INDEX_KEY, index);
`).join('');
}
async function loadCanvas(symbol, name) { return fetch(`${getApiBaseUrl()}/canvas/save`, {
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`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ symbol, name, data: state }) body: JSON.stringify({ symbol, name: String(id), data: JSON.parse(content) })
}); }).then(() => id);
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);
}
});
}
// Auto-save related getChartContent(chartId) {
let lastSavedStates = {}; const index = getStore(CHARTS_INDEX_KEY);
const chart = index.find(c => c.id == chartId);
async function autoSaveCanvas(index) { if (!chart) return Promise.reject('Chart not found');
const widget = tvWidgets[index]; return fetch(`${getApiBaseUrl()}/canvas/load?symbol=${chart.symbol}&name=${chartId}`, {
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}`, {
headers: getAuthHeaders() headers: getAuthHeaders()
}); }).then(r => r.text());
if (res.ok) { },
const data = await res.json();
widget.load(data); removeChart(id) {
widget.applyOverrides({ const index = getStore(CHARTS_INDEX_KEY);
"paneProperties.background": "#0F0F0F", const chart = index.find(c => c.id == id);
"paneProperties.backgroundType": "solid", const newIndex = index.filter(c => c.id != id);
"scalesProperties.backgroundColor": "#0F0F0F" setStore(CHARTS_INDEX_KEY, newIndex);
}); if (chart) {
currentCanvasNames[index] = lastName; return fetch(`${getApiBaseUrl()}/canvas/delete?symbol=${chart.symbol}&name=${id}`, {
console.log(`Auto-loaded canvas: ${symbol}_${lastName}`); method: 'DELETE', headers: getAuthHeaders()
}).then(() => {});
} }
} catch (e) { return Promise.resolve();
console.log(`No saved canvas found for ${symbol}`); },
}
}
}
async function newCanvas() { getAllStudyTemplates() { return Promise.resolve(getStore(STUDY_TPL_KEY)); },
const widget = tvWidgets[selectedChartIndex]; saveStudyTemplate(data) {
if (!widget) return; 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(); getDrawingTemplates(toolName) {
const name = await showInputModal('New Canvas Name', 'default', 'Enter canvas name...'); const all = getStoreObj(DRAWING_TPL_KEY);
if (!name) return; 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; getAllChartTemplates() {
await saveCanvas(); return Promise.resolve(Object.keys(getStoreObj(CHART_TPL_KEY)));
loadCanvasList(); },
} saveChartTemplate(name, content) {
const all = getStoreObj(CHART_TPL_KEY);
async function deleteCanvas(symbol, name) { all[name] = content;
if (!confirm(`Delete canvas "${symbol}_${name}"?`)) return; setStoreObj(CHART_TPL_KEY, all);
return Promise.resolve();
try { },
await fetch(`${getApiBaseUrl()}/canvas/delete?symbol=${symbol}&name=${name}`, { getChartTemplateContent(name) {
method: 'DELETE', const all = getStoreObj(CHART_TPL_KEY);
headers: getAuthHeaders() return Promise.resolve({ content: all[name] || {} });
}); },
if (currentCanvasNames[selectedChartIndex] === name) { removeChartTemplate(name) {
currentCanvasNames[selectedChartIndex] = 'default'; const all = getStoreObj(CHART_TPL_KEY);
} delete all[name];
loadCanvasList(); setStoreObj(CHART_TPL_KEY, all);
} catch (e) { return Promise.resolve();
console.error('Failed to delete canvas:', e); },
} };
} }
function changeLayout(layout) { function changeLayout(layout) {
@@ -2558,7 +2330,9 @@
fullscreen: false, fullscreen: false,
autosize: true, autosize: true,
theme: isDarkTheme ? "Dark" : "Light", theme: isDarkTheme ? "Dark" : "Light",
load_last_chart: false, save_load_adapter: createSaveLoadAdapter(),
load_last_chart: true,
auto_save_delay: 5,
custom_indicators_getter: (PineJS) => { custom_indicators_getter: (PineJS) => {
window.PineJS = PineJS; window.PineJS = PineJS;
return Promise.resolve([NetVolumeIndicator, CVDIndicator]); return Promise.resolve([NetVolumeIndicator, CVDIndicator]);
@@ -2647,9 +2421,6 @@
} }
widget._ready = true; widget._ready = true;
// Auto-load the last saved canvas
autoLoadCanvas(widget, index);
}); });
tvWidgets[index] = widget; 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 ============ // ============ Initialize Watchlist Event Listeners ============
@@ -2851,8 +2614,6 @@
createWidget(0); createWidget(0);
selectChart(0); selectChart(0);
// Start auto-save
startAutoSave();
} }
// ============ Authentication Guard ============ // ============ Authentication Guard ============
@@ -2894,20 +2655,31 @@
}; };
async function initApp() { async function initApp() {
const user = checkAuth(); // DEV_MODE: skip auth for local preview
if (!user) { const DEV_MODE = (location.hostname === 'localhost' || location.hostname === '127.0.0.1');
window.location.href = './login.html'; if (!DEV_MODE) {
return; 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(); const backendReady = await waitForBackend();
if (backendReady) { if (backendReady) {
await fetchDailyOpens(); await fetchDailyOpens();
init(); init();
} else { } 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.');
}
} }
} }
+1 -1
View File
@@ -127,7 +127,7 @@
const userData = parseJwt(credential); const userData = parseJwt(credential);
// Email whitelist verification // Email whitelist verification
const allowedEmails = ['xxx@gmail.com']; const allowedEmails = ['your-email@gmail.com'];
if (!allowedEmails.includes(userData.email)) { if (!allowedEmails.includes(userData.email)) {
document.getElementById('error-msg').textContent = 'Access denied: unauthorized email.'; document.getElementById('error-msg').textContent = 'Access denied: unauthorized email.';
document.getElementById('error-msg').style.display = 'block'; document.getElementById('error-msg').style.display = 'block';