737 lines
16 KiB
Vue
737 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import { ref, nextTick } from 'vue'
|
|
import { useUserStore } from '../stores/user'
|
|
|
|
const userStore = useUserStore()
|
|
const stockCode = 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 scrollToBottom = async () => {
|
|
await nextTick()
|
|
if (analysisContainer.value) {
|
|
analysisContainer.value.scrollTop = analysisContainer.value.scrollHeight
|
|
}
|
|
}
|
|
|
|
// 处理回车键
|
|
const handleKeyup = (event: KeyboardEvent) => {
|
|
if (event.key === 'Enter') {
|
|
handleAnalysis()
|
|
}
|
|
}
|
|
|
|
const handleAnalysis = async () => {
|
|
const code = stockCode.value.trim()
|
|
if (!code || isAnalyzing.value) return
|
|
|
|
// 验证股票代码格式
|
|
if (!/^\d{6}$/.test(code)) {
|
|
analysisContent.value = '<div class="error-message">请输入正确的6位股票代码</div>'
|
|
return
|
|
}
|
|
|
|
showInitialView.value = false
|
|
isAnalyzing.value = true
|
|
analysisContent.value = ''
|
|
currentThought.value = '准备开始分析...'
|
|
|
|
try {
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
}
|
|
if (userStore.authHeader) {
|
|
headers['Authorization'] = userStore.authHeader
|
|
}
|
|
|
|
const response = await fetch(`${apiBaseUrl}/adata/${code}/analysis`, {
|
|
method: 'POST',
|
|
headers,
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`)
|
|
}
|
|
|
|
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 = '开始分析...'
|
|
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 || '未知错误'
|
|
analysisContent.value = `<div class="error-message">分析过程中出现错误:${errorMessage}</div>`
|
|
currentThought.value = '分析过程出现错误'
|
|
await scrollToBottom()
|
|
break
|
|
}
|
|
} catch (e) {
|
|
console.error('解析响应数据出错:', e)
|
|
analysisContent.value = '<div class="error-message">解析响应数据时出错,请稍后重试</div>'
|
|
currentThought.value = '数据解析出错'
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('分析请求失败:', error)
|
|
analysisContent.value = '<div class="error-message">抱歉,分析请求失败,请稍后重试</div>'
|
|
currentThought.value = '请求失败'
|
|
} finally {
|
|
isAnalyzing.value = false
|
|
await scrollToBottom()
|
|
}
|
|
}
|
|
|
|
const resetView = () => {
|
|
showInitialView.value = true
|
|
stockCode.value = ''
|
|
analysisContent.value = ''
|
|
currentThought.value = ''
|
|
isAnalyzing.value = false
|
|
}
|
|
|
|
const clearInput = () => {
|
|
stockCode.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('复制失败')
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="stock-analysis-view">
|
|
<div class="content-container">
|
|
<!-- 初始视图 -->
|
|
<div v-if="showInitialView" class="initial-content">
|
|
<div class="header-section">
|
|
<h1 class="title">A股AI分析助理</h1>
|
|
<p class="description">通过 AI 技术,获取 A 股上市公司的深度分析报告</p>
|
|
</div>
|
|
|
|
<div class="search-section">
|
|
<div class="search-container" :class="{ 'is-analyzing': isAnalyzing }">
|
|
<div class="input-wrapper">
|
|
<div class="input-container" :class="{ 'has-input': stockCode }">
|
|
<div class="input-area">
|
|
<input
|
|
v-model="stockCode"
|
|
type="text"
|
|
class="search-input"
|
|
:class="{ 'is-selected': stockCode }"
|
|
placeholder="请输入6位股票代码"
|
|
maxlength="6"
|
|
@keyup="handleKeyup"
|
|
:disabled="isAnalyzing"
|
|
/>
|
|
<button
|
|
v-if="stockCode"
|
|
class="clear-button"
|
|
@click.stop="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="!stockCode || isAnalyzing"
|
|
>
|
|
{{ isAnalyzing ? '分析中...' : '开始分析' }}
|
|
</button>
|
|
</div>
|
|
</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">{{ stockCode }}</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="progress-dots">
|
|
<span></span>
|
|
<span></span>
|
|
<span></span>
|
|
</div>
|
|
<div class="status-text">
|
|
<div class="status-label">AI 正在分析</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="analysis-container"
|
|
ref="analysisContainer"
|
|
:class="{ 'fade-in': analysisContent }"
|
|
>
|
|
<div class="analysis-content" v-html="analysisContent"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* 基础样式 */
|
|
.stock-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;
|
|
min-height: 0;
|
|
justify-content: center;
|
|
}
|
|
|
|
.initial-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
margin-bottom: 15vh;
|
|
gap: 2rem;
|
|
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;
|
|
}
|
|
|
|
.search-container {
|
|
position: relative;
|
|
padding: 1.5rem;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.input-wrapper {
|
|
position: relative;
|
|
}
|
|
|
|
.input-container {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
width: 100%;
|
|
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%;
|
|
position: relative;
|
|
}
|
|
|
|
.search-input {
|
|
flex: 1;
|
|
border: none;
|
|
background: none;
|
|
padding: 0.5rem;
|
|
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 {
|
|
opacity: 1;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
/* 分析状态样式 */
|
|
.analysis-status {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 1rem;
|
|
text-align: center;
|
|
gap: 1rem;
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.progress-dots {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.progress-dots span {
|
|
width: 8px;
|
|
height: 8px;
|
|
background-color: var(--color-accent);
|
|
border-radius: 50%;
|
|
opacity: 0.3;
|
|
animation: pulse-dot 1s infinite;
|
|
}
|
|
|
|
.progress-dots span:nth-child(2) {
|
|
animation-delay: 0.2s;
|
|
}
|
|
|
|
.progress-dots span:nth-child(3) {
|
|
animation-delay: 0.4s;
|
|
}
|
|
|
|
.status-text {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.status-label {
|
|
font-weight: 500;
|
|
color: var(--color-accent);
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
/* 分析内容样式 */
|
|
.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 {
|
|
padding: 1.5rem;
|
|
line-height: 2;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
:deep(.error-message) {
|
|
margin: 1rem 0;
|
|
padding: 0.75rem 1rem;
|
|
background-color: rgba(255, 0, 0, 0.05);
|
|
border: 1px solid rgba(255, 0, 0, 0.1);
|
|
border-radius: var(--border-radius);
|
|
color: #ff4444;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
/* 动画 */
|
|
@keyframes pulse-dot {
|
|
0%,
|
|
100% {
|
|
transform: scale(1);
|
|
opacity: 0.3;
|
|
}
|
|
50% {
|
|
transform: scale(1.2);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
.fade-in {
|
|
animation: fadeIn 0.3s ease-out;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(-10px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
/* 响应式样式 */
|
|
@media (max-width: 768px) {
|
|
.initial-content {
|
|
margin-bottom: 12vh;
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.title {
|
|
font-size: 1.75rem;
|
|
}
|
|
|
|
.search-container {
|
|
padding: 1rem;
|
|
}
|
|
|
|
.search-input {
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.analysis-content {
|
|
padding: 1.25rem 1.5rem;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.analysis-view {
|
|
padding: 0.5rem;
|
|
margin-top: 4.5rem;
|
|
}
|
|
|
|
.analysis-header {
|
|
padding: 0.75rem;
|
|
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;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.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;
|
|
}
|
|
|
|
.analysis-content {
|
|
padding: 1rem;
|
|
font-size: 0.9rem;
|
|
line-height: 2;
|
|
}
|
|
|
|
.target-info .label {
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.target-info .value {
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.action-buttons {
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.action-button {
|
|
padding: 0.5rem;
|
|
font-size: 0.8rem;
|
|
}
|
|
}
|
|
|
|
/* 滚动条样式 */
|
|
:deep(*::-webkit-scrollbar) {
|
|
width: 6px;
|
|
height: 6px;
|
|
}
|
|
|
|
:deep(*::-webkit-scrollbar-track) {
|
|
background: transparent;
|
|
}
|
|
|
|
:deep(*::-webkit-scrollbar-thumb) {
|
|
background-color: rgba(125, 125, 125, 0.2);
|
|
border-radius: 3px;
|
|
}
|
|
|
|
:deep(*::-webkit-scrollbar-thumb:hover) {
|
|
background-color: rgba(125, 125, 125, 0.3);
|
|
}
|
|
</style>
|