Spaces:
1v1
/
Running

gp2 / templates /index.html
1v1's picture
Update templates/index.html
2571147 verified
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI股票分析系统</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
</head>
<body class="bg-gray-100">
<!-- 添加公告栏 -->
{% if announcement %}
<div id="announcement-container" class="fixed top-4 right-4 max-w-md z-50 animate-fade-in-down">
<div class="bg-blue-50 border-l-4 border-blue-500 p-4 rounded-lg shadow-lg">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-blue-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3 pr-8">
<p class="text-sm text-blue-700 whitespace-pre-line" id="announcement-text"></p>
<p class="text-xs text-gray-500 mt-1" id="announcement-timer"></p>
</div>
<button onclick="closeAnnouncement()" class="absolute top-2 right-2 text-gray-400 hover:text-gray-600">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<script>
// 处理公告文本中的URL
const announcementText = `{{ announcement }}`;
const urlRegex = /(https?:\/\/[^\s]+)/g;
document.getElementById('announcement-text').innerHTML = announcementText.replace(
urlRegex,
'<a href="$1" target="_blank" class="text-blue-600 hover:text-blue-800 underline">$1</a>'
);
// 设置自动关闭时间(5秒)
const autoCloseTime = 5;
let remainingTime = autoCloseTime;
const timerElement = document.getElementById('announcement-timer');
// 更新倒计时显示
function updateTimer() {
timerElement.textContent = `${remainingTime}秒后自动关闭`;
if (remainingTime <= 0) {
closeAnnouncement();
} else {
remainingTime--;
setTimeout(updateTimer, 1000);
}
}
// 启动倒计时
updateTimer();
// 关闭公告的函数
function closeAnnouncement() {
const container = document.getElementById('announcement-container');
if(!container){
return;
}
container.style.opacity = '0';
container.style.transform = 'translateY(-10px)';
container.style.transition = 'all 0.3s ease-out';
setTimeout(() => {
container.remove();
}, 300);
}
</script>
{% endif %}
<div class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-8 text-center">AI股票分析系统</h1>
<!-- 添加市场时间显示 -->
<div class="max-w-4xl mx-auto mb-8">
<div class="bg-white rounded-lg shadow-md p-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
<!-- 当前时间 -->
<div class="text-center">
<p class="text-gray-600 mb-2">当前时间</p>
<p id="currentTime" class="text-2xl font-bold text-gray-800"></p>
</div>
<!-- A股状态 -->
<div class="text-center">
<p class="text-gray-600 mb-2">A股市场</p>
<p id="cnMarketStatus" class="text-lg font-medium"></p>
<p id="cnMarketTimer" class="text-sm text-gray-500 mt-1"></p>
</div>
<!-- 港股状态 -->
<div class="text-center">
<p class="text-gray-600 mb-2">港股市场</p>
<p id="hkMarketStatus" class="text-lg font-medium"></p>
<p id="hkMarketTimer" class="text-sm text-gray-500 mt-1"></p>
</div>
<!-- 美股状态 -->
<div class="text-center">
<p class="text-gray-600 mb-2">美股市场</p>
<p id="usMarketStatus" class="text-lg font-medium"></p>
<p id="usMarketTimer" class="text-sm text-gray-500 mt-1"></p>
</div>
</div>
</div>
</div>
<script>
// 更新时间显示
function updateMarketTime() {
const now = new Date();
// 更新当前时间
document.getElementById('currentTime').textContent =
now.toLocaleTimeString('zh-CN', { hour12: false });
// 获取中国时间
const cnOptions = { timeZone: 'Asia/Shanghai', hour12: false };
const cnTime = now.toLocaleString('zh-CN', cnOptions);
const cnHour = new Date(cnTime).getHours();
const cnMinute = new Date(cnTime).getMinutes();
// A股市场状态
const cnMarketOpen = (cnHour === 9 && cnMinute >= 30) || (cnHour === 10) ||
(cnHour === 11 && cnMinute <= 30) ||
(cnHour >= 13 && cnHour < 15);
updateMarketStatus('cn', cnMarketOpen, cnHour, cnMinute, 9, 30, 15, 0);
// 港股市场状态(与A股相同时区)
const hkMarketOpen = (cnHour === 9 && cnMinute >= 30) ||
(cnHour === 10) || (cnHour === 11) ||
(cnHour >= 13 && cnHour < 16);
updateMarketStatus('hk', hkMarketOpen, cnHour, cnMinute, 9, 30, 16, 0);
// 获取美国东部时间
const usOptions = { timeZone: 'America/New_York', hour12: false };
const usTime = now.toLocaleString('zh-CN', usOptions);
const usHour = new Date(usTime).getHours();
const usMinute = new Date(usTime).getMinutes();
// 美股市场状态
const usMarketOpen = (usHour >= 9 && usHour < 16) ||
(usHour === 16 && usMinute === 0);
updateMarketStatus('us', usMarketOpen, usHour, usMinute, 9, 30, 16, 0);
}
// 更新市场状态的通用函数
function updateMarketStatus(market, isOpen, currentHour, currentMinute, openHour, openMinute, closeHour, closeMinute) {
const element = document.getElementById(`${market}MarketStatus`);
const timer = document.getElementById(`${market}MarketTimer`);
if (isOpen) {
element.textContent = '交易中';
element.className = 'text-lg font-medium text-green-600';
// 计算距离收盘时间
const now = new Date();
const closeTime = new Date(now);
closeTime.setHours(closeHour, closeMinute, 0);
const timeToClose = closeTime - now;
const hours = Math.floor(timeToClose / 3600000);
const minutes = Math.floor((timeToClose % 3600000) / 60000);
if (hours >= 0 && minutes >= 0) {
timer.textContent = `距离收盘还有 ${hours}小时${minutes}分钟`;
}
} else {
element.textContent = '已休市';
element.className = 'text-lg font-medium text-gray-600';
// 计算距离下一个开盘时间
const now = new Date();
const nextOpen = new Date(now);
if (market === 'us') {
// 获取美东时间的下一个开盘时间
const usTime = new Date(now.toLocaleString('en-US', { timeZone: 'America/New_York' }));
const usHour = usTime.getHours();
// 如果当前美东时间已过收盘时间,设置为下一个交易日
if (usHour >= closeHour) {
nextOpen.setDate(nextOpen.getDate() + 1);
}
// 设置为美东时间的开盘时间
nextOpen.setHours(openHour + 12, openMinute, 0); // 加12小时转换为北京时间
} else {
// 其他市场的逻辑保持不变
if (currentHour >= closeHour) {
nextOpen.setDate(nextOpen.getDate() + 1);
}
nextOpen.setHours(openHour, openMinute, 0);
}
const timeToOpen = nextOpen - now;
const hours = Math.floor(timeToOpen / 3600000);
const minutes = Math.floor((timeToOpen % 3600000) / 60000);
// 确保显示的时间始终为正数
if (hours >= 0 && minutes >= 0) {
timer.textContent = `距离开盘还有 ${hours}小时${minutes}分钟`;
} else {
timer.textContent = '计算开盘时间中...';
}
}
}
// 每秒更新一次时间
setInterval(updateMarketTime, 1000);
updateMarketTime(); // 立即执行一次
</script>
<div class="max-w-4xl mx-auto">
<!-- 批量分析 -->
<div class="bg-white p-6 rounded-lg shadow-md">
<h2 class="text-xl font-semibold mb-4">股票批量分析</h2>
<!-- API配置部分 -->
<div class="mb-6 border-b pb-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-700">API配置</h3>
<button id="toggleApiConfig" class="text-blue-600 hover:text-blue-800 text-sm flex items-center">
<span id="toggleApiConfigText">显示配置</span>
<svg id="toggleApiConfigIcon" class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
</div>
<div id="apiConfigPanel" class="hidden space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="apiUrl" class="block text-sm font-medium text-gray-700 mb-1">API URL</label>
<input type="text" id="apiUrl"
class="w-full p-2 border rounded bg-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="例如: https://api.openai.com"
value="{{ default_api_url }}"
oninput="updateFormattedUrl(this.value)">
<p id="formattedUrl" class="mt-1 text-sm text-gray-500 break-all"></p>
</div>
<div>
<label for="apiModel" class="block text-sm font-medium text-gray-700 mb-1">API 模型</label>
<input type="text" id="apiModel"
class="w-full p-2 border rounded bg-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="例如: gpt-3.5-turbo"
value="{{ default_api_model }}">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="apiKey" class="block text-sm font-medium text-gray-700 mb-1">API Key</label>
<input type="password" id="apiKey"
class="w-full p-2 border rounded bg-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="输入您的API Key">
<p class="mt-1 text-sm text-gray-500">如不填写,将使用系统默认配置</p>
</div>
<div>
<label for="apiTimeout" class="block text-sm font-medium text-gray-700 mb-1">API 超时时间 (秒)</label>
<input type="number" id="apiTimeout"
class="w-full p-2 border rounded bg-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="例如: 60"
value="{{ default_api_timeout }}" min="1" max="300">
<p class="mt-1 text-sm text-gray-500">请求超时时间,默认60秒</p>
</div>
</div>
<div class="flex justify-between">
<div class="flex items-center">
<input type="checkbox" id="saveApiConfig" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="saveApiConfig" class="ml-2 block text-sm text-gray-700">保存配置到本地</label>
</div>
<div>
<button id="resetApiConfig" class="text-gray-600 hover:text-gray-800 text-sm mr-3">
重置为默认
</button>
<button id="testApiConfig" class="bg-blue-100 text-blue-700 px-3 py-1 rounded hover:bg-blue-200 text-sm">
测试连接
</button>
</div>
</div>
</div>
</div>
<!-- 添加市场类型选择 -->
<div class="mb-4">
<label for="marketType" class="block text-sm font-medium text-gray-700 mb-2">
选择市场类型
</label>
<select id="marketType" onchange="handleMarketTypeChange()"
class="w-full p-2 border rounded bg-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="A">A股</option>
<option value="HK">港股</option>
<option value="US">美股</option>
<option value="ETF">ETF基金</option>
<option value="LOF">LOF基金</option>
</select>
</div>
<!-- 搜索框 -->
<div id="searchContainer" class="mb-4 hidden">
<div class="relative">
<input type="text"
id="searchInput"
class="w-full p-2 border rounded bg-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="输入名称回车搜索"
onkeydown="handleKeyDown(event)">
<div id="searchLoading" class="absolute right-3 top-2.5 hidden">
<svg class="animate-spin h-5 w-5 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<div id="searchResults" class="absolute z-10 w-full mt-1 bg-white border rounded-md shadow-lg hidden max-h-80 overflow-y-auto">
</div>
</div>
<div id="searchError" class="hidden absolute z-10 w-full mt-1 p-3 bg-red-50 text-red-600 rounded-md border border-red-200">
</div>
</div>
</div>
<!-- 修改搜索相关函数 -->
<script>
// 创建防抖函数
function debounce(func, wait) {
let timeout;
return function (...args) {
clearTimeout(timeout);
timeout = setTimeout(() => {
func.apply(this, args);
}, wait);
};
}
function handleKeyDown(event) {
// 检查是否按下回车键且不在输入法编辑状态
if (event.key === 'Enter' && !event.isComposing) {
event.preventDefault(); // 阻止默认行为
const keyword = event.target.value.trim();
if (keyword) {
debouncedSearch(keyword);
}
}
}
// 使用防抖包装搜索函数
const debouncedSearch = debounce(async (keyword) => {
if (!keyword) {
document.getElementById('searchResults').classList.add('hidden');
document.getElementById('searchError').classList.add('hidden');
return;
}
const marketType = document.getElementById('marketType').value;
document.getElementById('searchLoading').classList.remove('hidden');
document.getElementById('searchError').classList.add('hidden');
try {
let endpoint = '';
if (marketType === 'US') {
endpoint = '/search_us_stocks';
} else if (['ETF', 'LOF'].includes(marketType)) {
endpoint = '/search_funds';
}
const response = await fetch(`${endpoint}?market_type=${marketType}&keyword=${encodeURIComponent(keyword)}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '搜索失败');
}
displaySearchResults(data.results, marketType);
} catch (error) {
console.error('搜索出错:', error);
const errorDiv = document.getElementById('searchError');
errorDiv.textContent = `搜索出错: ${error.message}`;
errorDiv.classList.remove('hidden');
document.getElementById('searchResults').classList.add('hidden');
} finally {
document.getElementById('searchLoading').classList.add('hidden');
}
}, 500);
</script>
<div class="mb-4">
<label for="batchStocks" class="block text-sm font-medium text-gray-700 mb-2">
输入代码
</label>
<textarea id="batchStocks"
class="w-full p-2 border rounded h-32"
placeholder="输入代码,支持多个代码(用回车或逗号分隔)"></textarea>
</div>
<!-- 添加新的 JavaScript 代码 -->
<script>
let searchTimeout;
function handleMarketTypeChange() {
const marketType = document.getElementById('marketType').value;
const searchContainer = document.getElementById('searchContainer');
const searchInput = document.getElementById('searchInput');
const batchStocks = document.getElementById('batchStocks');
// 显示/隐藏搜索框
searchContainer.classList.toggle('hidden', !['US', 'ETF', 'LOF'].includes(marketType));
// 更新搜索框提示文本
if (marketType === 'US') {
searchInput.placeholder = '输入美股名称回车搜索(中文和英文都试下)';
} else if (marketType === 'ETF') {
searchInput.placeholder = '输入ETF基金名称回车搜索';
} else if (marketType === 'LOF') {
searchInput.placeholder = '输入LOF基金名称回车搜索';
}
// 清空搜索结果和输入
document.getElementById('searchResults').innerHTML = '';
searchInput.value = '';
// 清空代码输入框
batchStocks.value = '';
}
function debounceSearch(event) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchUsStocks(event.target.value);
}, 300);
}
function displaySearchResults(results, marketType) {
const resultsDiv = document.getElementById('searchResults');
if (!results || results.length === 0) {
resultsDiv.classList.add('hidden');
return;
}
let html = '<div class="divide-y divide-gray-100">'; // 添加分割线
results.forEach(item => {
let rightContent = '';
if (marketType === 'US') {
rightContent = `
<div class="font-medium">$${item.price}</div>
<div class="text-sm text-gray-500">市值: ${formatMarketValue(item.market_value)}</div>
`;
} else {
rightContent = `
<div class="font-medium">¥${item.price}</div>
<div class="text-sm text-gray-500">
${item.discount_rate ? `折价率: ${item.discount_rate}%` : ''}
</div>
`;
}
html += `
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer flex justify-between items-center"
onclick="selectStock('${item.symbol}')">
<div>
<div class="font-medium">${item.name}</div>
<div class="text-sm text-gray-500">${item.symbol}</div>
</div>
<div class="text-right">
${rightContent}
</div>
</div>
`;
});
html += '</div>';
resultsDiv.innerHTML = html;
resultsDiv.classList.remove('hidden');
}
function formatMarketValue(value) {
if (value >= 1e12) {
return (value / 1e12).toFixed(2) + '万亿';
} else if (value >= 1e8) {
return (value / 1e8).toFixed(2) + '亿';
} else if (value >= 1e4) {
return (value / 1e4).toFixed(2) + '万';
}
return value.toString();
}
function selectStock(symbol) {
const textarea = document.getElementById('batchStocks');
const currentValue = textarea.value.trim();
// 添加到 textarea
textarea.value = currentValue
? currentValue + '\n' + symbol
: symbol;
// 清空并隐藏搜索结果
document.getElementById('searchInput').value = '';
document.getElementById('searchResults').classList.add('hidden');
}
// 点击外部时隐藏搜索结果
document.addEventListener('click', function(event) {
const searchResults = document.getElementById('searchResults');
const searchInput = document.getElementById('searchInput');
if (!searchResults.contains(event.target) &&
!searchInput.contains(event.target)) {
searchResults.classList.add('hidden');
}
});
</script>
<button id="analyzeBtn" onclick="analyzeStocks()"
class="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 flex items-center justify-center">
<span>开始分析</span>
<div id="loadingSpinner" class="hidden ml-2">
<svg class="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</button>
</div>
<!-- 结果展示 -->
<div id="results" class="mt-8">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-gray-800">分析结果</h2>
<button onclick="copyAnalysisResults()"
class="flex items-center text-blue-600 hover:text-blue-700">
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/>
</svg>
复制分析结果
</button>
<button onclick="downloadPDF()"
class="flex items-center text-blue-600 hover:text-blue-700">
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/>
</svg>
下载分析结果
</button>
</div>
<div id="resultContent" class="space-y-8"></div>
</div>
<script>
function copyAnalysisResults() {
const resultContent = document.getElementById('resultContent');
if (!resultContent.textContent.trim()) {
alert('暂无分析结果可复制');
return;
}
// 提取需要复制的文本
let copyText = '';
const results = resultContent.querySelectorAll('.bg-white');
results.forEach(result => {
// 获取股票代码
const stockCode = result.querySelector('h3').textContent.trim();
copyText += `股票代码:${stockCode}\n`;
// 获取主要指标
const indicators = result.querySelectorAll('.flex.justify-between');
indicators.forEach(indicator => {
const label = indicator.querySelector('.text-gray-600').textContent;
const value = indicator.querySelector('.font-medium').textContent;
copyText += `${label}${value}\n`;
});
// 获取 AI 分析内容
const aiAnalysis = result.querySelector('.prose').textContent;
copyText += `\nAI分析:\n${aiAnalysis}\n`;
copyText += '\n----------------------------------------\n\n';
});
// 复制到剪贴板
const textarea = document.createElement('textarea');
textarea.value = copyText;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
// 显示提示
alert('分析结果已复制到剪贴板');
}
</script>
<script>
let isAnalyzing = false;
let stockAnalysisData = {}; // 存储股票分析数据的对象
async function analyzeStocks() {
if (isAnalyzing) return; // 防止重复点击
const stockInput = document.getElementById('batchStocks').value.trim();
const marketType = document.getElementById('marketType').value;
const analyzeBtn = document.getElementById('analyzeBtn');
const loadingSpinner = document.getElementById('loadingSpinner');
const resultContent = document.getElementById('resultContent');
// 获取API配置
const apiUrl = document.getElementById('apiUrl').value.trim();
const apiKey = document.getElementById('apiKey').value.trim();
const apiModel = document.getElementById('apiModel').value.trim();
const apiTimeout = document.getElementById('apiTimeout').value.trim();
if (!stockInput) {
alert('请输入代码');
return;
}
const stockCodes = stockInput.split(/[\n,]/)
.map(code => code.trim())
.filter(code => code.length > 0);
try {
isAnalyzing = true;
analyzeBtn.disabled = true;
loadingSpinner.classList.remove('hidden');
analyzeBtn.querySelector('span').textContent = '分析中...';
// 清空现有结果并初始化分析数据
resultContent.innerHTML = '';
stockAnalysisData = {};
// 创建结果容器
const resultsContainer = document.createElement('div');
resultsContainer.className = 'space-y-6';
resultContent.appendChild(resultsContainer);
// 使用fetch流式API
const response = await fetch('/analyze', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
stock_codes: stockCodes,
market_type: marketType,
api_url: apiUrl,
api_key: apiKey,
api_model: apiModel,
api_timeout: apiTimeout
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || '分析失败');
}
// 设置流式处理
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
// 持续读取数据流
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 解码接收到的数据并添加到缓冲区
buffer += decoder.decode(value, { stream: true });
// 处理完整的行
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 最后一行可能不完整,保留到下一次处理
for (const line of lines) {
if (line.trim() === '') continue;
try {
const chunk = JSON.parse(line);
handleStreamChunk(chunk, resultsContainer, marketType);
} catch (e) {
console.error('解析流数据出错:', e, line);
}
}
}
// 处理可能遗留在缓冲区的最后一行
if (buffer.trim()) {
try {
const chunk = JSON.parse(buffer);
handleStreamChunk(chunk, resultsContainer, marketType);
} catch (e) {
console.error('解析最后一行数据出错:', e, buffer);
}
}
} catch (error) {
console.error('请求失败:', error);
resultContent.innerHTML = `
<div class="p-6 bg-yellow-50 text-yellow-600 rounded-lg text-center">
分析出错:${error.message}
</div>
`;
} finally {
isAnalyzing = false;
analyzeBtn.disabled = false;
loadingSpinner.classList.add('hidden');
analyzeBtn.querySelector('span').textContent = '开始分析';
}
}
// 处理流式数据的函数
function handleStreamChunk(chunk, container, marketType) {
// 处理初始化信息
if (chunk.stream_type) {
console.log('开始流式分析:', chunk);
return;
}
// 获取股票代码
const stockCode = chunk.stock_code;
// 如果是错误信息
if (chunk.error) {
// 添加或更新显示错误的卡片
let errorCard = document.getElementById(`error-${stockCode}`);
// 处理错误信息,提取关键部分
let errorMessage = chunk.error;
// 移除多余的错误前缀
errorMessage = errorMessage.replace(/获取股票数据失败: /g, '');
// 格式化错误信息
let formattedError = errorMessage;
// 针对特定类型的错误提供更友好的提示
if (errorMessage.includes('无效的A股股票代码格式')) {
formattedError = `<strong>股票代码格式错误</strong>: ${stockCode} 不是有效的A股代码<br>
<small>A股代码应以0、3、6、688或8开头</small>`;
} else if (errorMessage.includes('股票代码格式错误')) {
formattedError = `<strong>股票代码格式错误</strong>: ${errorMessage.split(':')[1] || errorMessage}`;
} else if (errorMessage.includes('数据为空')) {
formattedError = `<strong>数据获取失败</strong>: 未找到股票 ${stockCode} 的交易数据`;
}
if (!errorCard) {
errorCard = document.createElement('div');
errorCard.id = `error-${stockCode}`;
errorCard.className = 'bg-red-50 p-4 rounded-lg text-red-600 mb-4 flex items-start';
// 添加警告图标
errorCard.innerHTML = `
<div class="mr-3 flex-shrink-0">
<svg class="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
</svg>
</div>
<div>
<p class="text-sm font-medium">股票 ${stockCode} 分析失败</p>
<p class="mt-1 text-sm">${formattedError}</p>
</div>
`;
container.appendChild(errorCard);
} else {
errorCard.innerHTML = `
<div class="mr-3 flex-shrink-0">
<svg class="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
</svg>
</div>
<div>
<p class="text-sm font-medium">股票 ${stockCode} 分析失败</p>
<p class="mt-1 text-sm">${formattedError}</p>
</div>
`;
}
return;
}
// 如果是基本报告结构
if (!chunk.ai_analysis_chunk) {
// 存储基本报告数据
stockAnalysisData[stockCode] = {
...chunk,
ai_analysis: ''
};
// 添加或更新股票卡片
createStockCard(stockCode, container, marketType);
return;
}
// 如果是AI分析片段
if (chunk.ai_analysis_chunk) {
// 确保该股票的数据存在
if (!stockAnalysisData[stockCode]) {
stockAnalysisData[stockCode] = {
stock_code: stockCode,
ai_analysis: ''
};
}
// 累加AI分析内容
stockAnalysisData[stockCode].ai_analysis += chunk.ai_analysis_chunk;
// 更新AI分析显示
updateAIAnalysisDisplay(stockCode);
}
}
// 创建股票卡片
function createStockCard(stockCode, container, marketType) {
const result = stockAnalysisData[stockCode];
if (!result) return;
// 根据市场类型设置货币符号
const currencySymbol = (() => {
switch(marketType) {
case 'US':
return '$';
case 'HK':
return 'HK$';
case 'A':
default:
return '¥';
}
})();
// 检查是否已存在该股票的卡片
let stockCard = document.getElementById(`stock-card-${stockCode}`);
if (!stockCard) {
stockCard = document.createElement('div');
stockCard.id = `stock-card-${stockCode}`;
stockCard.className = 'bg-white rounded-lg shadow-lg overflow-hidden';
container.appendChild(stockCard);
}
stockCard.innerHTML = `
<!-- 头部信息 -->
<div class="bg-gradient-to-r from-blue-600 to-blue-700 px-6 py-4">
<h3 class="text-xl font-bold text-white">
${result.stock_code}
</h3>
</div>
<!-- 主要指标 -->
<div class="p-6">
<!-- 修改网格布局:移动端单列显示,平板及以上为两列 -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div class="space-y-3">
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
<span class="text-gray-600">分析时间</span>
<span class="font-medium">${result.analysis_date}</span>
</div>
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
<span class="text-gray-600">当前价格</span>
<span class="font-medium">${currencySymbol}${result.price.toFixed(2)}</span>
</div>
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
<span class="text-gray-600">价格变动</span>
<span class="font-medium ${result.price_change >= 0 ? 'text-red-500' : 'text-green-500'}">
${result.price_change.toFixed(2)}%
</span>
</div>
</div>
<div class="space-y-3 mt-4 sm:mt-0">
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
<span class="text-gray-600">综合评分</span>
<span class="font-medium text-blue-600">${result.score}分</span>
</div>
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
<span class="text-gray-600">投资建议</span>
<span class="font-medium text-purple-600">${result.recommendation}</span>
</div>
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
<span class="text-gray-600">RSI指标</span>
<span class="font-medium">${result.rsi !== null ? result.rsi.toFixed(2) : '暂无数据'}</span>
</div>
</div>
</div>
<!-- AI分析部分,增加上边距防止内容粘连 -->
<div class="mt-6">
<h4 class="text-lg font-semibold text-gray-800 mb-3">AI分析</h4>
<div id="ai-analysis-${stockCode}" class="prose prose-red max-w-none bg-gray-50 p-4 rounded-lg relative">
<!-- 加载动画 -->
<div class="ai-analysis-loading flex flex-col items-center justify-center py-8">
<div class="typing-animation mb-3">
<span></span>
<span></span>
<span></span>
</div>
<p class="text-gray-500 text-sm">AI正在思考分析中...</p>
</div>
<!-- 实际内容容器,增加上边距 -->
<div class="ai-analysis-content hidden mt-4"></div>
</div>
</div>
</div>
<!-- 免责声明 -->
<div class="mt-6 border-t border-gray-100 pt-4">
<div class="bg-red-50 p-4 rounded-lg">
<p class="text-sm text-red-800 font-semibold mb-1">声明:</p>
<p class="text-sm text-red-600">本分析仅基于技术指标和历史数据,不构成投资建议。股市有风险,投资需谨慎。</p>
</div>
</div>
</div>
`;
}
// 更新AI分析显示
function updateAIAnalysisDisplay(stockCode) {
const analysisElement = document.getElementById(`ai-analysis-${stockCode}`);
if (analysisElement && stockAnalysisData[stockCode]) {
const loadingElement = analysisElement.querySelector('.ai-analysis-loading');
const contentElement = analysisElement.querySelector('.ai-analysis-content');
// 如果有AI分析内容
if (stockAnalysisData[stockCode].ai_analysis) {
// 解析Markdown
const parsedContent = marked.parse(stockAnalysisData[stockCode].ai_analysis);
// 检查是否是第一次添加内容
const isFirstUpdate = contentElement.classList.contains('hidden');
// 如果是第一次更新,显示内容区域并隐藏加载动画
if (isFirstUpdate) {
contentElement.innerHTML = parsedContent;
contentElement.classList.remove('hidden');
contentElement.classList.add('fade-in');
// 延迟隐藏加载动画,使过渡更平滑
setTimeout(() => {
loadingElement.style.display = 'none';
}, 300);
} else {
// 获取当前内容长度,用于确定新增内容
const currentLength = contentElement.textContent.length;
// 更新内容
contentElement.innerHTML = parsedContent;
// 尝试高亮新增的文本(通过比较长度)
const allTextNodes = getAllTextNodes(contentElement);
let totalLength = 0;
for (const node of allTextNodes) {
totalLength += node.textContent.length;
if (totalLength > currentLength) {
// 这个节点包含新内容,将其包装在高亮span中
const newTextSpan = document.createElement('span');
newTextSpan.className = 'new-text';
node.parentNode.insertBefore(newTextSpan, node);
newTextSpan.appendChild(node);
break;
}
}
}
}
}
}
// 辅助函数:获取元素内的所有文本节点
function getAllTextNodes(element) {
const textNodes = [];
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false);
let node;
while (node = walker.nextNode()) {
textNodes.push(node);
}
return textNodes;
}
// 旧的displayResults函数保留用于兼容
function displayResults(results) {
const resultContent = document.getElementById('resultContent');
// 清空现有结果
resultContent.innerHTML = '';
stockAnalysisData = {};
// 创建结果容器
const resultsContainer = document.createElement('div');
resultsContainer.className = 'space-y-6';
resultContent.appendChild(resultsContainer);
if (!results || results.length === 0) {
resultsContainer.innerHTML = '<div class="p-6 bg-yellow-50 text-yellow-600 rounded-lg text-center">没有分析结果</div>';
return;
}
// 获取市场类型
const marketType = document.getElementById('marketType').value;
// 处理每个结果
results.forEach(result => {
stockAnalysisData[result.stock_code] = result;
createStockCard(result.stock_code, resultsContainer, marketType);
updateAIAnalysisDisplay(result.stock_code);
});
// 添加 Markdown 样式
addMarkdownStyles();
}
</script>
<!-- 添加 marked.js 用于解析 Markdown -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<!-- API配置相关脚本 -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// API配置面板切换
const toggleApiConfig = document.getElementById('toggleApiConfig');
const apiConfigPanel = document.getElementById('apiConfigPanel');
const toggleApiConfigText = document.getElementById('toggleApiConfigText');
const toggleApiConfigIcon = document.getElementById('toggleApiConfigIcon');
const apiUrl = document.getElementById('apiUrl');
const apiKey = document.getElementById('apiKey');
const apiModel = document.getElementById('apiModel');
const apiTimeout = document.getElementById('apiTimeout');
const saveApiConfig = document.getElementById('saveApiConfig');
const resetApiConfig = document.getElementById('resetApiConfig');
const testApiConfig = document.getElementById('testApiConfig');
// 从localStorage加载保存的配置
loadApiConfig();
// 切换API配置面板显示/隐藏
toggleApiConfig.addEventListener('click', function() {
apiConfigPanel.classList.toggle('hidden');
if (apiConfigPanel.classList.contains('hidden')) {
toggleApiConfigText.textContent = '显示配置';
toggleApiConfigIcon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>';
} else {
toggleApiConfigText.textContent = '隐藏配置';
toggleApiConfigIcon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>';
}
});
// 重置API配置
resetApiConfig.addEventListener('click', function() {
apiUrl.value = '{{ default_api_url }}';
apiKey.value = '';
apiModel.value = '{{ default_api_model }}';
apiTimeout.value = '{{ default_api_timeout }}';
saveApiConfig.checked = false;
// 清除localStorage中的配置
localStorage.removeItem('apiConfig');
alert('已重置为默认配置');
});
// 测试API连接
testApiConfig.addEventListener('click', async function() {
const url = apiUrl.value.trim();
const key = apiKey.value.trim();
const model = apiModel.value.trim();
const timeout = apiTimeout.value.trim();
if (!url) {
alert('请输入API URL');
return;
}
if (!key) {
alert('请输入API Key');
return;
}
try {
testApiConfig.disabled = true;
testApiConfig.textContent = '测试中...';
const response = await fetch('/test_api_connection', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
api_url: url,
api_key: key,
api_model: model,
api_timeout: timeout
})
});
const result = await response.json();
if (result.success) {
alert(result.message);
// 如果勾选了保存配置,则保存到localStorage
if (saveApiConfig.checked) {
saveApiConfigToLocalStorage();
}
} else {
alert(result.message);
}
} catch (error) {
alert(error.message);
} finally {
testApiConfig.disabled = false;
testApiConfig.textContent = '测试连接';
}
});
// 监听输入变化,自动保存配置
[apiUrl, apiKey, apiModel, apiTimeout].forEach(input => {
input.addEventListener('change', function() {
if (saveApiConfig.checked) {
saveApiConfigToLocalStorage();
}
});
});
// 监听保存配置复选框变化
saveApiConfig.addEventListener('change', function() {
if (this.checked) {
saveApiConfigToLocalStorage();
} else {
localStorage.removeItem('apiConfig');
}
});
});
// 保存API配置到localStorage
function saveApiConfigToLocalStorage() {
const apiConfig = {
url: document.getElementById('apiUrl').value.trim(),
model: document.getElementById('apiModel').value.trim(),
key: document.getElementById('apiKey').value.trim(),
timeout: document.getElementById('apiTimeout').value.trim(),
saveEnabled: true
};
localStorage.setItem('apiConfig', JSON.stringify(apiConfig));
}
// 从localStorage加载API配置
function loadApiConfig() {
const savedConfig = localStorage.getItem('apiConfig');
if (savedConfig) {
try {
const config = JSON.parse(savedConfig);
if (config.url) document.getElementById('apiUrl').value = config.url;
if (config.model) document.getElementById('apiModel').value = config.model;
if (config.key) document.getElementById('apiKey').value = config.key;
if (config.timeout) document.getElementById('apiTimeout').value = config.timeout;
document.getElementById('saveApiConfig').checked = config.saveEnabled || false;
} catch (error) {
console.error('加载API配置时出错:', error);
}
}
}
</script>
<script>
// 添加 Markdown 样式
function addMarkdownStyles() {
// 检查是否已经添加了样式
if (!document.getElementById('markdown-styles')) {
const style = document.createElement('style');
style.id = 'markdown-styles';
style.textContent = `
.prose h1 { font-size: 1.5em; margin-top: 1em; margin-bottom: 0.5em; font-weight: bold; }
.prose h2 { font-size: 1.3em; margin-top: 1em; margin-bottom: 0.5em; font-weight: bold; }
.prose h3 { font-size: 1.1em; margin-top: 1em; margin-bottom: 0.5em; font-weight: bold; }
.prose p { margin-bottom: 1em; line-height: 1.6; }
.prose ul { list-style-type: disc; padding-left: 1.5em; margin-bottom: 1em; }
.prose ol { list-style-type: decimal; padding-left: 1.5em; margin-bottom: 1em; }
.prose li { margin-bottom: 0.5em; }
.prose strong { font-weight: 600; color: #1a56db; }
.prose em { font-style: italic; }
.prose blockquote { border-left: 4px solid #e5e7eb; padding-left: 1em; margin: 1em 0; color: #4b5563; }
.prose code { background-color: #f3f4f6; padding: 0.2em 0.4em; border-radius: 0.25em; font-size: 0.9em; }
/* 打字机动画样式 */
.typing-animation {
display: flex;
align-items: center;
}
.typing-animation span {
height: 10px;
width: 10px;
margin: 0 2px;
background-color: #3b82f6;
border-radius: 50%;
display: inline-block;
animation: typing 1.5s infinite ease-in-out;
}
.typing-animation span:nth-child(1) {
animation-delay: 0s;
}
.typing-animation span:nth-child(2) {
animation-delay: 0.3s;
}
.typing-animation span:nth-child(3) {
animation-delay: 0.6s;
}
@keyframes typing {
0% { transform: scale(1); opacity: 0.7; }
50% { transform: scale(1.5); opacity: 1; }
100% { transform: scale(1); opacity: 0.7; }
}
/* 内容淡入效果 */
.ai-analysis-content {
transition: opacity 0.3s ease;
}
.ai-analysis-content.fade-in {
opacity: 0;
animation: fadeIn 0.5s forwards;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* 高亮新增文本效果 */
.new-text {
background-color: rgba(59, 130, 246, 0.1);
animation: highlightFade 2s forwards;
}
@keyframes highlightFade {
from { background-color: rgba(59, 130, 246, 0.1); }
to { background-color: transparent; }
}
`;
document.head.appendChild(style);
}
}
// 页面加载时添加样式
document.addEventListener('DOMContentLoaded', function() {
addMarkdownStyles();
});
</script>
</body>
</html>
<script>
function updateFormattedUrl(url) {
const formattedUrlElement = document.getElementById('formattedUrl');
if (!url) {
formattedUrlElement.textContent = '';
return;
}
let formattedUrl;
if (url.endsWith('/')) {
formattedUrl = `${url}chat/completions`;
} else if (url.endsWith('#')) {
formattedUrl = url.replace("#", "");
} else {
formattedUrl = `${url}/v1/chat/completions`;
}
formattedUrlElement.innerHTML = `
<span class="font-medium">/结尾忽略v1版本,#结尾强制使用输入地址:</span><br>
<span class="text-blue-600">${formattedUrl}</span>
`;
}
// 页面加载时初始化显示
document.addEventListener('DOMContentLoaded', function() {
const apiUrl = document.getElementById('apiUrl');
if (apiUrl.value) {
updateFormattedUrl(apiUrl.value);
}
});
function downloadPDF(){
const aigc = document.querySelector("think").innerText;
const filename = document.querySelector("div.bg-gradient-to-r.from-blue-600.to-blue-700.px-6.py-4").innerText;
if(aigc!=undefined && aigc!=''){
fetch("/generate_pdf", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({"text":aigc,"filename":filename})
})
.then(response => response.blob())
.then(blob => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename+".pdf";
document.body.appendChild(a);
a.click();
a.remove();
})
.catch(error => console.error("Error:", error));
}
}
</script>