web/src/views/UniversalAnalysisView.vue
2025-05-19 10:03:38 +08:00

999 lines
22 KiB
Vue
Raw 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.

<script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue'
import { useRoute } from 'vue-router'
import { http } from '../services/api'
// 获取路由参数,确定分析类型
const route = useRoute()
const analysisType = computed(() => (route.params.type as string) || 'crypto')
// 分析标的代码
const symbolCode = ref('')
const isAnalyzing = ref(false)
const analysisContent = ref('')
const analysisContainer = ref<HTMLElement | null>(null)
const currentThought = ref('')
const showInitialView = ref(true)
const copySuccess = ref(false)
// 根据环境选择API基础URL
const apiBaseUrl =
import.meta.env.MODE === 'development' ? 'http://127.0.0.1:8000' : 'https://api.ibtc.work'
// 根据类型设置页面标题和预设列表
const pageTitle = computed(() => {
switch (analysisType.value) {
case 'stock':
return 'A股股票分析智能体'
case 'crypto':
default:
return '加密货币分析智能体'
}
})
const pageDescription = computed(() => {
switch (analysisType.value) {
case 'stock':
return '获取A股上市公司的深度AI智能分析'
case 'crypto':
default:
return '获取加密货币的深度AI智能分析'
}
})
const inputPlaceholder = computed(() => {
switch (analysisType.value) {
case 'stock':
return '请输入上市公司股票代码'
case 'crypto':
default:
return '请输入BTC、ETH 一次只能分析一个币种'
}
})
// 是否为股票分析模式
const isStockMode = computed(() => analysisType.value === 'stock')
// 常见标的列表
const commonItemsList = computed(() => {
switch (analysisType.value) {
case 'stock':
return [
{ code: '000001', label: '平安银行' },
{ code: '000651', label: '格力电器' },
{ code: '601318', label: '中国平安' },
{ code: '600519', label: '贵州茅台' },
{ code: '601888', label: '中国中免' },
{ code: '000858', label: '五粮液' },
{ code: '600276', label: '恒瑞医药' },
{ code: '002594', label: '比亚迪' },
{ code: '600036', label: '招商银行' },
{ code: '603288', label: '海天味业' },
]
case 'crypto':
default:
return [
{ code: 'BTC', label: 'BTC' },
{ code: 'ETH', label: 'ETH' },
{ code: 'SOL', label: 'SOL' },
{ code: 'SUI', label: 'SUI' },
{ code: 'TRX', label: 'TRX' },
{ code: 'XRP', label: 'XRP' },
{ code: 'BNB', label: 'BNB' },
{ code: 'ADA', label: 'ADA' },
{ code: 'DOGE', label: 'DOGE' },
{ code: 'SHIB', label: 'SHIB' },
]
}
})
// 滚动到底部
const scrollToBottom = async () => {
await nextTick()
if (analysisContainer.value) {
analysisContainer.value.scrollTop = analysisContainer.value.scrollHeight
}
}
// 处理回车键
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault()
handleAnalysis()
}
}
// 处理错误消息的显示
const setErrorMessage = (message: string) => {
analysisContent.value = `错误: ${message}`
currentThought.value = '分析失败'
}
// 分析逻辑
const handleAnalysis = async () => {
const code = symbolCode.value.trim()
if (!code || isAnalyzing.value) return
// 股票模式下验证股票代码格式
if (isStockMode.value && !/^\d{6}$/.test(code)) {
setErrorMessage('请输入正确的6位股票代码')
return
}
// 先切换到分析视图,再设置分析状态
showInitialView.value = false
isAnalyzing.value = true
analysisContent.value = ''
currentThought.value = '准备开始分析...'
try {
let response
// 根据分析类型调用不同的API
if (isStockMode.value) {
// 调用A股分析接口
response = await http.post(`${apiBaseUrl}/adata/${code}/analysis`, {})
} else {
// 调用加密货币分析接口
const requestData = {
symbol: code.toUpperCase(),
}
response = await http.post(`${apiBaseUrl}/crypto/analysis_v2`, requestData)
}
if (!response.ok) {
// 解析错误响应
try {
const errorData = await response.json()
if (errorData && errorData.detail) {
setErrorMessage(errorData.detail)
} else {
setErrorMessage(`请求失败,状态码: ${response.status}`)
}
} catch {
setErrorMessage(`请求失败,状态码: ${response.status}`)
}
return
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('无法获取响应流')
}
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
buffer += chunk
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.trim() || !line.startsWith('data: ')) continue
try {
const jsonStr = line.slice(6)
const data = JSON.parse(jsonStr)
switch (data.event) {
case 'workflow_started':
currentThought.value = 'Agent 正在分析...'
break
case 'node_started':
currentThought.value = `Agent 调用: ${data.data.title}`
break
case 'node_finished':
currentThought.value = `Agent 完成: ${data.data.title}`
break
case 'node_failed':
currentThought.value = `Agent 分析失败 ${data.data.title}`
break
case 'text_chunk':
if (data.data && data.data.text) {
analysisContent.value += data.data.text
await scrollToBottom()
}
break
case 'workflow_finished':
if (data.data && data.data.outputs && data.data.outputs.text) {
if (analysisContent.value !== data.data.outputs.text) {
analysisContent.value = data.data.outputs.text
await scrollToBottom()
}
currentThought.value = `分析完成 (用时 ${Math.round(data.data.elapsed_time)}秒)`
}
break
case 'error':
const errorMessage = data.error || '未知错误'
setErrorMessage(errorMessage)
await scrollToBottom()
break
}
} catch (error) {
console.error('解析响应数据出错:', error)
setErrorMessage('解析响应数据时出错,请稍后重试')
}
}
}
} catch {
setErrorMessage('抱歉,分析请求失败,请稍后重试')
} finally {
isAnalyzing.value = false
await scrollToBottom()
}
}
const resetView = () => {
showInitialView.value = true
symbolCode.value = ''
analysisContent.value = ''
currentThought.value = ''
isAnalyzing.value = false
}
// 清除输入
const clearInput = () => {
symbolCode.value = ''
analysisContent.value = ''
currentThought.value = ''
}
const copyAnalysis = async () => {
if (!analysisContent.value) return
try {
const tempDiv = document.createElement('div')
tempDiv.innerHTML = analysisContent.value
const textContent = tempDiv.textContent || tempDiv.innerText || ''
await navigator.clipboard.writeText(textContent)
copySuccess.value = true
setTimeout(() => {
copySuccess.value = false
}, 2000)
} catch {
console.error('复制失败')
}
}
// 处理快速分析项的点击
const handleCommonAnalysis = (item: { code: string }) => {
symbolCode.value = item.code
handleAnalysis()
}
// 监听路由变化,重置视图
watch(
() => route.params.type,
() => {
resetView()
},
)
</script>
<template>
<div class="universal-analysis-view">
<div class="content-container">
<!-- 初始视图 -->
<div v-if="showInitialView" class="initial-content">
<div class="header-section">
<h1 class="title">{{ pageTitle }}</h1>
<p class="description">{{ pageDescription }}</p>
</div>
<div class="search-section">
<div class="search-container">
<div class="input-wrapper">
<div class="input-container">
<div class="input-area">
<input
v-model="symbolCode"
type="text"
class="search-input"
:placeholder="inputPlaceholder"
@keydown="handleKeydown"
:disabled="isAnalyzing"
:maxlength="isStockMode ? 6 : undefined"
/>
<button
v-if="symbolCode.trim() || analysisContent"
class="clear-button"
@click="clearInput"
:disabled="isAnalyzing"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<button
class="analyze-button"
@click="handleAnalysis"
:disabled="isAnalyzing || !symbolCode.trim()"
>
{{ isAnalyzing ? '正在跳转...' : '开始分析' }}
</button>
</div>
</div>
</div>
</div>
<!-- 快速分析列表 -->
<div class="common-analysis-section">
<div class="common-analysis-container">
<div class="section-header">
<span class="section-title">点击直接快速分析</span>
</div>
<div class="common-analysis-list">
<button
v-for="item in commonItemsList"
:key="item.label"
class="common-analysis-item"
@click="handleCommonAnalysis(item)"
:disabled="isAnalyzing"
>
{{ item.label }}
</button>
</div>
</div>
</div>
</div>
<!-- 分析视图 -->
<div v-else class="analysis-view">
<div class="analysis-header">
<div class="target-info">
<span class="label">正在分析:</span>
<span class="value">{{ symbolCode.toUpperCase() }}</span>
</div>
<div class="action-buttons" v-if="!isAnalyzing">
<button class="action-button" @click="resetView">
<svg
class="icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="22 12 18 12 15 12"></polyline>
<path d="M11.5 3a17 17 0 0 0-6.5 2.5L2 8"></path>
<path d="M2 3v5h5"></path>
<path d="M13.5 21a17 17 0 0 0 6.5-2.5L22 16"></path>
<path d="M22 21v-5h-5"></path>
</svg>
<span>重新分析</span>
</button>
<button
class="action-button copy"
@click="copyAnalysis"
:class="{ success: copySuccess }"
>
<span v-if="!copySuccess">复制结果</span>
<span v-else>复制成功</span>
</button>
</div>
</div>
<div v-if="isAnalyzing" class="analysis-status">
<div class="simple-loader"></div>
<div class="status-text">
<div class="thought-text" v-if="currentThought">{{ currentThought }}</div>
</div>
</div>
<div
class="analysis-container"
ref="analysisContainer"
:class="{ 'fade-in': analysisContent }"
>
<div v-if="analysisContent.startsWith('错误:')" class="error-message">
{{ analysisContent.substring(4) }}
</div>
<div v-else class="analysis-content">
{{ analysisContent }}
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
:root {
--color-accent-rgb: 51, 85, 255; /* 假设accent颜色为#3355ff */
}
.universal-analysis-view {
min-height: 100vh;
height: 100vh;
background-color: var(--color-bg-primary);
padding: 1rem;
display: flex;
flex-direction: column;
overflow: hidden;
}
.content-container {
max-width: 800px;
margin: 0 auto;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
justify-content: center;
}
.initial-content {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 15vh;
gap: 1.5rem;
transition: all 0.5s ease;
}
.header-section {
text-align: center;
transition: all 0.5s ease;
}
.title {
font-size: 2rem;
font-weight: 700;
color: var(--color-text-primary);
margin-bottom: 0.5rem;
transition: all 0.5s ease;
}
.description {
font-size: 0.95rem;
color: var(--color-text-secondary);
transition: all 0.5s ease;
}
.search-section {
width: 100%;
max-width: 800px;
transition: all 0.5s ease;
padding: 0 1.5rem;
}
.search-container {
position: relative;
padding: 1.5rem;
transition: all 0.3s ease;
}
.input-wrapper {
position: relative;
margin-bottom: 0;
}
.input-container {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
min-height: 5.5rem;
border: 2px solid var(--color-accent);
border-radius: var(--border-radius);
background-color: var(--color-bg-primary);
padding: 0.75rem 1rem;
transition: all 0.3s ease;
gap: 0.75rem;
}
.input-area {
display: flex;
align-items: center;
width: 100%;
gap: 0.5rem;
}
.search-input {
flex: 1;
border: none;
background: none;
padding: 0;
font-size: 0.95rem;
color: var(--color-text-primary);
outline: none;
height: 2.5rem;
}
.clear-button {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
color: var(--color-text-secondary);
opacity: 0.6;
transition: all 0.2s ease;
}
.clear-button:hover:not(:disabled) {
opacity: 1;
}
.clear-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.clear-button svg {
width: 16px;
height: 16px;
}
.analyze-button {
width: 100%;
padding: 0.75rem;
font-size: 0.9rem;
font-weight: 500;
color: white;
background-color: var(--color-accent);
border: none;
border-radius: var(--border-radius);
cursor: pointer;
transition: all 0.2s ease;
}
.analyze-button:hover:not(:disabled) {
background-color: var(--color-accent-hover);
}
.analyze-button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
/* 分析视图样式 */
.analysis-view {
display: flex;
flex-direction: column;
height: calc(100vh - 2rem);
padding: 0.5rem;
animation: fadeIn 0.3s ease-out;
}
.analysis-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background-color: var(--color-bg-secondary);
border-radius: var(--border-radius);
flex-shrink: 0;
}
.analysis-status {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
padding: 1rem;
text-align: left;
gap: 1rem;
margin-top: 1rem;
background: transparent;
border-radius: var(--border-radius);
}
.simple-loader {
width: 16px;
height: 16px;
border: 2px solid var(--color-border);
border-top-color: var(--color-text-secondary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.status-text {
display: flex;
flex-direction: row;
align-items: center;
flex: 1;
}
.thought-text {
font-size: 0.95rem;
font-weight: 400;
color: var(--color-text-secondary);
position: relative;
padding-right: 0.5rem;
animation: none;
}
.analysis-container {
flex: 1;
min-height: 0;
overflow-y: auto;
border-radius: var(--border-radius);
margin-top: 1rem;
display: flex;
flex-direction: column;
}
.analysis-container.fade-in {
background-color: var(--color-bg-secondary);
}
.analysis-content {
font-size: 1rem;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
color: var(--color-text-primary);
padding: 1.5rem 2rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
}
.error-message {
color: #ff3333;
font-weight: 500;
padding: 1rem;
background-color: rgba(255, 51, 51, 0.08);
border: 1px solid rgba(255, 51, 51, 0.2);
border-radius: 0.5rem;
margin: 1rem;
text-align: center;
font-size: 1rem;
line-height: 1.5;
}
.error-message::before {
content: '⚠️ ';
}
.target-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.target-info .label {
color: var(--color-text-secondary);
font-size: 0.85rem;
}
.target-info .value {
color: var(--color-accent);
font-weight: 500;
font-size: 0.9rem;
}
.action-buttons {
display: flex;
gap: 1rem;
align-items: center;
}
.action-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid var(--color-accent);
border-radius: var(--border-radius);
background: transparent;
color: var(--color-accent);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.9rem;
font-weight: 500;
}
.action-button:hover {
background-color: var(--color-accent);
color: white;
}
.action-button.copy {
background-color: var(--color-accent);
color: white;
}
.action-button.copy:hover {
opacity: 0.9;
}
.action-button.copy.success {
background-color: #4caf50;
border-color: #4caf50;
}
.action-button .icon {
width: 16px;
height: 16px;
}
/* 快速分析列表样式 */
.common-analysis-section {
width: 100%;
max-width: 800px;
margin: 0 auto;
padding: 0 1.5rem;
}
.common-analysis-container {
width: 100%;
padding: 0 1.5rem;
margin-bottom: 1.5rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.section-title {
font-size: 0.9rem;
color: var(--color-text-secondary);
}
.common-analysis-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.common-analysis-item {
padding: 0.35rem 0.75rem;
font-size: 0.9rem;
color: var(--color-accent);
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-accent);
border-radius: var(--border-radius);
cursor: pointer;
transition: all 0.2s ease;
}
.common-analysis-item:hover:not(:disabled) {
background-color: var(--color-accent);
color: white;
}
.common-analysis-item:disabled {
opacity: 0.6;
cursor: not-allowed;
border-color: var(--color-border);
color: var(--color-text-secondary);
}
/* 动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.universal-analysis-view {
padding: 0.75rem;
}
.analysis-view {
padding: 0.5rem;
margin-top: 4.5rem;
}
.analysis-header {
padding: 1rem;
flex-direction: column;
gap: 0.75rem;
align-items: flex-start;
}
.action-buttons {
width: 100%;
flex-direction: row;
gap: 0.75rem;
}
.action-button {
flex: 1;
justify-content: center;
padding: 0.6rem;
font-size: 0.85rem;
}
.analysis-content {
padding: 1.25rem 1.5rem;
font-size: 0.95rem;
}
.initial-content {
margin-bottom: 12vh;
gap: 1.5rem;
}
.title {
font-size: 1.75rem;
}
.description {
font-size: 0.9rem;
}
.search-input {
font-size: 0.9rem;
}
.analyze-button {
font-size: 0.85rem;
}
.search-container {
padding: 1rem;
}
.search-section {
padding: 0 1rem;
}
.common-analysis-section {
padding: 0 1rem;
}
.common-analysis-container {
padding: 0 1rem;
}
.analysis-status {
padding: 1rem;
margin-top: 0.75rem;
}
.simple-loader {
width: 14px;
height: 14px;
border-width: 2px;
}
.thought-text {
font-size: 0.85rem;
}
}
@media (max-width: 480px) {
.universal-analysis-view {
padding: 0.5rem;
}
.analysis-view {
padding: 0.5rem;
}
.analysis-header {
padding: 1rem;
}
.target-info {
flex-wrap: wrap;
}
.target-info .label {
font-size: 0.8rem;
}
.target-info .value {
font-size: 0.85rem;
}
.analysis-content {
padding: 1rem;
font-size: 0.9rem;
line-height: 2;
}
.initial-content {
margin-bottom: 10vh;
gap: 1rem;
}
.title {
font-size: 1.5rem;
}
.description {
font-size: 0.85rem;
}
.search-input {
font-size: 0.85rem;
}
.analyze-button {
font-size: 0.85rem;
padding: 0.6rem;
}
.action-buttons {
gap: 0.5rem;
}
.action-button {
padding: 0.5rem;
font-size: 0.8rem;
}
.search-container {
padding: 0.75rem;
}
.search-section {
padding: 0 0.75rem;
}
.common-analysis-section {
padding: 0 0.75rem;
}
.common-analysis-container {
padding: 0 0.75rem;
}
.common-analysis-item {
font-size: 0.85rem;
padding: 0.3rem 0.6rem;
}
.analysis-status {
padding: 0.75rem;
flex-direction: column;
gap: 0.75rem;
align-items: center;
}
.simple-loader {
width: 12px;
height: 12px;
border-width: 1.5px;
margin: 0 auto;
}
.thought-text {
font-size: 0.8rem;
text-align: center;
}
}
</style>