前端: 新增 RealTimeQuoteResponse 类型;新增 useRealtimeQuote Hook 并在报告页图表旁展示价格与时间戳(严格 TTL,无兜底)
FastAPI: 新增 GET /financials/{market}/{symbol}/realtime?max_age_seconds=.. 只读端点;通过 DataPersistenceClient 读取 Rust 缓存
Rust: 新增 realtime_quotes hypertable 迁移;新增 POST /api/v1/market-data/quotes 与 GET /api/v1/market-data/quotes/{symbol}?market=..;新增 DTO/Model/DB 函数;修正 #[api] 宏与路径参数;生成 SQLx 离线缓存 (.sqlx) 以支持离线构建
Python: DataPersistenceClient 新增 upsert/get 实时报价,并调整 GET 路径与参数
说明: TradingView 图表是第三方 websocket,不受我们缓存控制;页面数值展示走自有缓存通路,统一且可控。
384 lines
14 KiB
JavaScript
384 lines
14 KiB
JavaScript
import init, {
|
||
init_cli,
|
||
run_command_async,
|
||
get_completions,
|
||
get_history_item,
|
||
search_history
|
||
} from '/cli-ui/forge_cli_wasm.js';
|
||
|
||
async function main() {
|
||
// 1. Initialize xterm.js
|
||
const term = new Terminal({
|
||
cursorBlink: true,
|
||
theme: {
|
||
background: '#1e1e1e',
|
||
foreground: '#d4d4d4',
|
||
},
|
||
cols: 120, // Set a reasonable terminal width
|
||
scrollback: 1000,
|
||
convertEol: true, // Convert \n to \r\n for proper line endings
|
||
});
|
||
const fitAddon = new FitAddon.FitAddon();
|
||
term.loadAddon(fitAddon);
|
||
term.open(document.getElementById('terminal'));
|
||
fitAddon.fit();
|
||
window.addEventListener('resize', () => fitAddon.fit());
|
||
|
||
term.writeln('Welcome to the Forge CLI (WASM Interface)');
|
||
term.writeln('------------------------------------------');
|
||
term.writeln('');
|
||
|
||
try {
|
||
// 2. Load and initialize the WASM module
|
||
term.write('Loading WASM module...');
|
||
await init();
|
||
term.writeln('\r✅ WASM module loaded successfully.');
|
||
|
||
// 3. Fetch OpenAPI spec and initialize the CLI
|
||
const baseUrl = window.location.origin; // 动态获取base URL
|
||
term.write(`Fetching OpenAPI spec from ${baseUrl}/api-docs/openapi.json...`);
|
||
const response = await fetch(`${baseUrl}/api-docs/openapi.json`);
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to fetch spec: ${response.statusText}`);
|
||
}
|
||
const specJson = await response.text();
|
||
const spec = JSON.parse(specJson);
|
||
// 保存到全局以便 JS fallback 使用
|
||
window.__openapiSpec = spec;
|
||
window.__baseUrl = baseUrl;
|
||
|
||
init_cli(specJson, baseUrl);
|
||
term.writeln('\r✅ CLI initialized with OpenAPI spec.');
|
||
|
||
} catch (e) {
|
||
term.writeln(`\r\n❌ Error during initialization: ${e}`);
|
||
return;
|
||
}
|
||
|
||
// 4. Implement the REPL with enhanced functionality
|
||
let currentLine = '';
|
||
let cursorPosition = 0; // 光标在当前行中的位置
|
||
let historyIndex = -1; // -1表示当前输入,>=0表示历史记录索引
|
||
let isInReverseSearch = false;
|
||
let reverseSearchQuery = '';
|
||
let completionMenu = null; // 当前显示的补全菜单
|
||
|
||
const prompt = '\r\n$ ';
|
||
const promptOnly = '$ '; // 不包含换行的提示符,用于重绘
|
||
|
||
// 重绘当前行
|
||
function redrawLine() {
|
||
// 移动到行首并清除从提示符后的所有内容
|
||
term.write('\r' + promptOnly);
|
||
term.write('\x1b[K'); // 清除从光标到行尾的内容
|
||
|
||
if (isInReverseSearch) {
|
||
// 在反向搜索模式下,替换整个提示符
|
||
term.write('\r\x1b[K'); // 清除整行
|
||
term.write(`(reverse-i-search)'${reverseSearchQuery}': ${currentLine}`);
|
||
} else {
|
||
term.write(currentLine);
|
||
}
|
||
|
||
// 移动光标到正确位置
|
||
if (cursorPosition < currentLine.length) {
|
||
const moveCursor = currentLine.length - cursorPosition;
|
||
term.write('\x1b[' + moveCursor + 'D'); // 向左移动光标
|
||
}
|
||
}
|
||
|
||
// 插入字符到当前位置
|
||
function insertChar(char) {
|
||
currentLine = currentLine.slice(0, cursorPosition) + char + currentLine.slice(cursorPosition);
|
||
cursorPosition++;
|
||
redrawLine();
|
||
}
|
||
|
||
// 删除字符
|
||
function deleteChar() {
|
||
if (cursorPosition > 0) {
|
||
currentLine = currentLine.slice(0, cursorPosition - 1) + currentLine.slice(cursorPosition);
|
||
cursorPosition--;
|
||
redrawLine();
|
||
}
|
||
}
|
||
|
||
// 移动光标
|
||
function moveCursor(direction) {
|
||
if (direction === 'left' && cursorPosition > 0) {
|
||
cursorPosition--;
|
||
term.write('\x1b[D');
|
||
} else if (direction === 'right' && cursorPosition < currentLine.length) {
|
||
cursorPosition++;
|
||
term.write('\x1b[C');
|
||
}
|
||
}
|
||
|
||
// 处理Tab补全
|
||
function handleTabCompletion() {
|
||
try {
|
||
const completionResult = get_completions(currentLine, cursorPosition);
|
||
const suggestions = JSON.parse(completionResult.suggestions);
|
||
|
||
if (suggestions.length === 0) {
|
||
return;
|
||
}
|
||
|
||
if (suggestions.length === 1) {
|
||
// 只有一个建议,直接补全
|
||
const suggestion = suggestions[0];
|
||
const beforeCursor = currentLine.slice(0, suggestion.start_pos);
|
||
const afterCursor = currentLine.slice(suggestion.end_pos);
|
||
currentLine = beforeCursor + suggestion.value + afterCursor;
|
||
cursorPosition = beforeCursor.length + suggestion.value.length;
|
||
redrawLine();
|
||
} else {
|
||
// 多个建议,显示补全菜单
|
||
term.writeln('');
|
||
suggestions.slice(0, 10).forEach(suggestion => {
|
||
const desc = suggestion.description ? ` - ${suggestion.description}` : '';
|
||
term.writeln(` ${suggestion.value}${desc}`);
|
||
});
|
||
redrawLine();
|
||
}
|
||
} catch (e) {
|
||
console.error('Tab completion error:', e);
|
||
}
|
||
}
|
||
|
||
// 处理历史记录导航
|
||
function navigateHistory(direction) {
|
||
if (direction === 'up') {
|
||
const item = get_history_item(historyIndex + 1);
|
||
if (item) {
|
||
historyIndex++;
|
||
currentLine = item;
|
||
cursorPosition = currentLine.length;
|
||
redrawLine();
|
||
}
|
||
} else if (direction === 'down') {
|
||
if (historyIndex > 0) {
|
||
historyIndex--;
|
||
const item = get_history_item(historyIndex);
|
||
if (item) {
|
||
currentLine = item;
|
||
cursorPosition = currentLine.length;
|
||
redrawLine();
|
||
}
|
||
} else if (historyIndex === 0) {
|
||
historyIndex = -1;
|
||
currentLine = '';
|
||
cursorPosition = 0;
|
||
redrawLine();
|
||
}
|
||
}
|
||
}
|
||
|
||
// 处理反向搜索
|
||
function handleReverseSearch(char) {
|
||
if (char) {
|
||
reverseSearchQuery += char;
|
||
}
|
||
|
||
try {
|
||
const searchResults = JSON.parse(search_history(reverseSearchQuery));
|
||
if (searchResults.length > 0) {
|
||
currentLine = searchResults[0];
|
||
cursorPosition = currentLine.length;
|
||
}
|
||
redrawLine();
|
||
} catch (e) {
|
||
console.error('Reverse search error:', e);
|
||
}
|
||
}
|
||
|
||
// 退出反向搜索模式
|
||
function exitReverseSearch() {
|
||
isInReverseSearch = false;
|
||
reverseSearchQuery = '';
|
||
cursorPosition = currentLine.length;
|
||
redrawLine();
|
||
}
|
||
|
||
// JS fallback:当 wasm 返回 Path not found 时,用 JS 直接按 OpenAPI 执行
|
||
async function executeCommandJS(commandLine) {
|
||
try {
|
||
const spec = window.__openapiSpec;
|
||
const baseUrl = window.__baseUrl || '';
|
||
if (!spec) return 'Error: OpenAPI spec not loaded.';
|
||
const tokens = commandLine.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
||
if (tokens.length === 0) return '';
|
||
const cmd = tokens[0];
|
||
const args = {};
|
||
for (let i = 1; i < tokens.length; i++) {
|
||
const t = tokens[i];
|
||
if (t.startsWith('--')) {
|
||
const key = t.replace(/^--/, '');
|
||
const val = (i + 1 < tokens.length && !tokens[i + 1].startsWith('--')) ? tokens[++i] : '';
|
||
args[key] = val.replace(/^"|"$/g, '');
|
||
}
|
||
}
|
||
const parts = cmd.split('.');
|
||
const method = parts.pop().toUpperCase();
|
||
const cmdSegs = parts;
|
||
// 匹配路径模板
|
||
let matched = null;
|
||
for (const [key, item] of Object.entries(spec.paths || {})) {
|
||
const keySegs = key.split('/').filter(s => s);
|
||
if (keySegs.length !== cmdSegs.length) continue;
|
||
let ok = true;
|
||
for (let i = 0; i < keySegs.length; i++) {
|
||
const ks = keySegs[i];
|
||
const cs = cmdSegs[i];
|
||
const isParam = ks.startsWith('{') && ks.endsWith('}');
|
||
if (!isParam && ks !== cs) { ok = false; break; }
|
||
}
|
||
if (ok) { matched = [key, item]; break; }
|
||
}
|
||
if (!matched) {
|
||
return `API request failed (JS fallback): Path not found for /${cmdSegs.join('/')}`;
|
||
}
|
||
const [pathTemplate, pathItem] = matched;
|
||
const op = (pathItem[method.toLowerCase()]);
|
||
if (!op) return `API request failed (JS fallback): Operation not found for ${cmd}`;
|
||
// 构造路径和查询
|
||
let finalPath = pathTemplate;
|
||
const used = new Set();
|
||
if (Array.isArray(op.parameters)) {
|
||
for (const p of op.parameters) {
|
||
const prm = p && p.name ? p : (p && p.$ref ? null : null);
|
||
if (!prm) continue;
|
||
if (p.in === 'path' && args[p.name] != null) {
|
||
finalPath = finalPath.replace(`{${p.name}}`, encodeURIComponent(args[p.name]));
|
||
used.add(p.name);
|
||
}
|
||
}
|
||
}
|
||
const query = [];
|
||
for (const [k, v] of Object.entries(args)) {
|
||
if (!used.has(k)) query.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
|
||
}
|
||
let serverUrl = '';
|
||
if (Array.isArray(spec.servers) && spec.servers.length > 0 && spec.servers[0].url) {
|
||
serverUrl = spec.servers[0].url;
|
||
}
|
||
const url = `${baseUrl}${serverUrl}${finalPath}${query.length ? ('?' + query.join('&')) : ''}`;
|
||
const resp = await fetch(url, { method });
|
||
const text = await resp.text();
|
||
try {
|
||
return JSON.stringify(JSON.parse(text), null, 2);
|
||
} catch {
|
||
return text;
|
||
}
|
||
} catch (e) {
|
||
return `API request failed (JS fallback): ${e}`;
|
||
}
|
||
}
|
||
|
||
term.write(prompt);
|
||
|
||
term.onKey(({ key, domEvent }) => {
|
||
const { keyCode, ctrlKey, altKey, metaKey } = domEvent;
|
||
|
||
// Ctrl+R - 反向搜索
|
||
if (ctrlKey && keyCode === 82 && !isInReverseSearch) {
|
||
isInReverseSearch = true;
|
||
reverseSearchQuery = '';
|
||
currentLine = '';
|
||
cursorPosition = 0;
|
||
redrawLine();
|
||
return;
|
||
}
|
||
|
||
// 在反向搜索模式下的处理
|
||
if (isInReverseSearch) {
|
||
if (keyCode === 13) { // Enter - 接受搜索结果
|
||
exitReverseSearch();
|
||
return;
|
||
} else if (keyCode === 27) { // Esc - 取消搜索
|
||
isInReverseSearch = false;
|
||
reverseSearchQuery = '';
|
||
currentLine = '';
|
||
cursorPosition = 0;
|
||
redrawLine();
|
||
return;
|
||
} else if (keyCode === 8) { // Backspace - 删除搜索字符
|
||
if (reverseSearchQuery.length > 0) {
|
||
reverseSearchQuery = reverseSearchQuery.slice(0, -1);
|
||
handleReverseSearch();
|
||
}
|
||
return;
|
||
} else if (!ctrlKey && !altKey && !metaKey && key.length === 1) {
|
||
handleReverseSearch(key);
|
||
return;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 普通模式下的处理
|
||
if (keyCode === 13) { // Enter - 执行命令
|
||
if (currentLine.trim()) {
|
||
term.writeln('');
|
||
|
||
// 异步执行命令
|
||
(async () => {
|
||
try {
|
||
let result = await run_command_async(currentLine);
|
||
const plain = String(result);
|
||
if (plain.includes('Path not found for')) {
|
||
result = await executeCommandJS(currentLine);
|
||
}
|
||
// 清理ANSI转义序列
|
||
const cleanResult = String(result)
|
||
.replace(/\x1b\[[0-9;]*m/g, '')
|
||
.replace(/\x1b\[[0-9]*[A-Za-z]/g, '')
|
||
.replace(/\[\d+m/g, '');
|
||
|
||
const lines = cleanResult.split('\n');
|
||
lines.forEach((line, index) => {
|
||
if (index === lines.length - 1 && line === '') {
|
||
return;
|
||
}
|
||
term.writeln(line);
|
||
});
|
||
} catch (error) {
|
||
term.writeln(`Error: ${error}`);
|
||
}
|
||
|
||
term.write(prompt);
|
||
})();
|
||
|
||
currentLine = '';
|
||
cursorPosition = 0;
|
||
historyIndex = -1;
|
||
} else {
|
||
term.write(prompt);
|
||
}
|
||
} else if (keyCode === 9) { // Tab - 补全
|
||
domEvent.preventDefault();
|
||
handleTabCompletion();
|
||
} else if (keyCode === 8) { // Backspace
|
||
deleteChar();
|
||
} else if (keyCode === 37) { // 左箭头
|
||
moveCursor('left');
|
||
} else if (keyCode === 39) { // 右箭头
|
||
moveCursor('right');
|
||
} else if (keyCode === 38) { // 上箭头 - 历史记录上一个
|
||
navigateHistory('up');
|
||
} else if (keyCode === 40) { // 下箭头 - 历史记录下一个
|
||
navigateHistory('down');
|
||
} else if (keyCode === 36) { // Home - 移到行首
|
||
cursorPosition = 0;
|
||
redrawLine();
|
||
} else if (keyCode === 35) { // End - 移到行尾
|
||
cursorPosition = currentLine.length;
|
||
redrawLine();
|
||
} else if (!ctrlKey && !altKey && !metaKey && key.length === 1) {
|
||
// 普通字符输入
|
||
insertChar(key);
|
||
}
|
||
});
|
||
}
|
||
|
||
main();
|