This commit is contained in:
aaron 2025-05-19 00:17:39 +08:00
parent 155a2e5cba
commit 34ff339e7f
8 changed files with 428 additions and 2940 deletions

View File

@ -5,7 +5,7 @@ services:
build:
context: .
dockerfile: Dockerfile
image: tradus-web:1.2.9
image: tradus-web:1.3.0
container_name: tradus-web
ports:
- '6000:80'

View File

@ -302,19 +302,7 @@ onUnmounted(() => {
</svg>
<span class="agent-name">首页</span>
</RouterLink>
<RouterLink to="/crypto-analysis" class="agent-item" @click="showMobileMenu = false">
<svg
class="agent-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M7 12l5-5 5 5M7 17l5-5 5 5" />
</svg>
<span class="agent-name">加密货币分析</span>
</RouterLink>
<RouterLink to="/astock-analysis" class="agent-item" @click="showMobileMenu = false">
<RouterLink to="/ai-agents" class="agent-item" @click="showMobileMenu = false">
<svg
class="agent-icon"
viewBox="0 0 24 24"
@ -330,7 +318,7 @@ onUnmounted(() => {
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>
<span class="agent-name">A股分析</span>
<span class="agent-name">AI分析智能体</span>
</RouterLink>
</div>

View File

@ -20,12 +20,6 @@ const router = createRouter({
title: 'AI 助理团队',
},
},
{
path: '/ai-agent',
name: 'ai-agent',
component: () => import('../views/AIAgentView.vue'),
meta: { requiresVIP: true },
},
{
path: '/download',
name: 'download',
@ -42,21 +36,12 @@ const router = createRouter({
},
},
{
path: '/crypto-analysis',
name: 'crypto-analysis',
component: () => import('../views/CryptoAnalysisView.vue'),
path: '/analysis/:type',
name: 'universal-analysis',
component: () => import('../views/UniversalAnalysisView.vue'),
meta: {
requiresAuth: true,
title: '加密货币分析助理',
},
},
{
path: '/astock-analysis',
name: 'astock-analysis',
component: () => import('../views/AStockAnalysisView.vue'),
meta: {
requiresAuth: true,
title: 'A股分析助理',
title: '智能分析助理',
},
},
],

File diff suppressed because it is too large Load Diff

View File

@ -6,23 +6,23 @@ const router = useRouter()
const agents = [
{
id: 'crypto-analysis',
name: '加密货币AI分析助理',
description: '通过 AI 技术,获取加密货币的深度分析报告',
name: '加密货币分析智能体',
description: '获取加密货币的深度AI智能分析报告',
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 12l5-5 5 5M7 17l5-5 5 5"/>
</svg>`,
route: '/crypto-analysis',
route: '/analysis/crypto',
},
{
id: 'astock-analysis',
name: 'A股AI分析助理',
description: '通过 AI 技术,获取 A 股上市公司的深度分析报告',
name: 'A股股票分析智能体',
description: '获取A股上市公司的深度AI智能分析报告',
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>`,
route: '/astock-analysis',
route: '/analysis/stock',
},
]
@ -35,8 +35,8 @@ const navigateToAgent = (route: string) => {
<div class="ai-agents-view">
<div class="content-container">
<div class="header-section">
<h1 class="title">AI 助理团队</h1>
<p class="description">选择专业的 AI 助手获取精准的分析和建议</p>
<h1 class="title">AI 分析智能体</h1>
<p class="description">选择专业的 AI 分析智能体获取精准的分析和建议</p>
</div>
<div class="agents-grid">

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,7 @@ const openAuthModal = (mode: 'login' | 'register') => {
<div class="hero-content">
<h1 class="hero-title">
<span class="accent">tradus</span>
<span class="free-tag">Alpha</span>
<span class="free-tag">测试版</span>
</h1>
<p class="hero-subtitle">基于AI大语言模型的智能投研助理</p>
<div class="hero-actions" v-if="!isAuthenticated">

View File

@ -1,8 +1,14 @@
<script setup lang="ts">
import { ref, nextTick } from 'vue'
import { ref, computed, nextTick, watch } from 'vue'
import { useRoute } from 'vue-router'
import { http } from '../services/api'
const stockCode = ref('')
//
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)
@ -14,6 +20,74 @@ const copySuccess = ref(false)
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) {
@ -22,25 +96,26 @@ const scrollToBottom = async () => {
}
//
const handleKeyup = (event: KeyboardEvent) => {
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 = stockCode.value.trim()
const code = symbolCode.value.trim()
if (!code || isAnalyzing.value) return
//
if (!/^\d{6}$/.test(code)) {
//
if (isStockMode.value && !/^\d{6}$/.test(code)) {
setErrorMessage('请输入正确的6位股票代码')
return
}
@ -51,7 +126,19 @@ const handleAnalysis = async () => {
currentThought.value = '准备开始分析...'
try {
const response = await http.post(`${apiBaseUrl}/adata/${code}/analysis`, {})
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) {
//
@ -95,7 +182,16 @@ const handleAnalysis = async () => {
switch (data.event) {
case 'workflow_started':
currentThought.value = '开始分析...'
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':
@ -107,7 +203,6 @@ const handleAnalysis = async () => {
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()
@ -122,14 +217,13 @@ const handleAnalysis = async () => {
await scrollToBottom()
break
}
} catch (e) {
console.error('解析响应数据出错:', e)
} catch (error) {
console.error('解析响应数据出错:', error)
setErrorMessage('解析响应数据时出错,请稍后重试')
}
}
}
} catch (error) {
console.error('分析请求失败:', error)
} catch {
setErrorMessage('抱歉,分析请求失败,请稍后重试')
} finally {
isAnalyzing.value = false
@ -139,14 +233,15 @@ const handleAnalysis = async () => {
const resetView = () => {
showInitialView.value = true
stockCode.value = ''
symbolCode.value = ''
analysisContent.value = ''
currentThought.value = ''
isAnalyzing.value = false
}
//
const clearInput = () => {
stockCode.value = ''
symbolCode.value = ''
analysisContent.value = ''
currentThought.value = ''
}
@ -168,37 +263,50 @@ const copyAnalysis = async () => {
console.error('复制失败')
}
}
//
const handleCommonAnalysis = (item: { code: string }) => {
symbolCode.value = item.code
handleAnalysis()
}
//
watch(
() => route.params.type,
() => {
resetView()
},
)
</script>
<template>
<div class="stock-analysis-view">
<div class="universal-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>
<h1 class="title">{{ pageTitle }}</h1>
<p class="description">{{ pageDescription }}</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-container">
<div class="input-area">
<input
v-model="stockCode"
v-model="symbolCode"
type="text"
class="search-input"
:class="{ 'is-selected': stockCode }"
placeholder="请输入6位股票代码"
maxlength="6"
@keyup="handleKeyup"
:placeholder="inputPlaceholder"
@keydown="handleKeydown"
:disabled="isAnalyzing"
:maxlength="isStockMode ? 6 : undefined"
/>
<button
v-if="stockCode"
v-if="symbolCode.trim() || analysisContent"
class="clear-button"
@click.stop="clearInput"
@click="clearInput"
:disabled="isAnalyzing"
>
<svg
@ -216,8 +324,8 @@ const copyAnalysis = async () => {
</div>
<button
class="analyze-button"
@click="() => handleAnalysis()"
:disabled="!stockCode || isAnalyzing"
@click="handleAnalysis"
:disabled="isAnalyzing || !symbolCode.trim()"
>
{{ isAnalyzing ? '分析中...' : '开始分析' }}
</button>
@ -225,14 +333,34 @@ const copyAnalysis = async () => {
</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">{{ stockCode }}</span>
<span class="label">正在分析</span>
<span class="value">{{ symbolCode.toUpperCase() }}</span>
</div>
<div class="action-buttons" v-if="!isAnalyzing">
<button class="action-button" @click="resetView">
@ -271,7 +399,8 @@ const copyAnalysis = async () => {
<span></span>
</div>
<div class="status-text">
<div class="status-label">AI 正在分析</div>
<!-- <div class="status-label">Agent 正在分析</div> -->
<div class="thought-text" v-if="currentThought">{{ currentThought }}</div>
</div>
</div>
@ -293,8 +422,7 @@ const copyAnalysis = async () => {
</template>
<style scoped>
/* 基础样式 */
.stock-analysis-view {
.universal-analysis-view {
min-height: 100vh;
height: 100vh;
background-color: var(--color-bg-primary);
@ -311,7 +439,7 @@ const copyAnalysis = async () => {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
justify-content: center;
}
@ -320,7 +448,7 @@ const copyAnalysis = async () => {
flex-direction: column;
align-items: center;
margin-bottom: 15vh;
gap: 2rem;
gap: 1.5rem;
transition: all 0.5s ease;
}
@ -343,11 +471,11 @@ const copyAnalysis = async () => {
transition: all 0.5s ease;
}
/* 搜索区域样式 */
.search-section {
width: 100%;
max-width: 800px;
transition: all 0.5s ease;
padding: 0 1.5rem;
}
.search-container {
@ -358,6 +486,7 @@ const copyAnalysis = async () => {
.input-wrapper {
position: relative;
margin-bottom: 0;
}
.input-container {
@ -365,6 +494,7 @@ const copyAnalysis = async () => {
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);
@ -377,14 +507,14 @@ const copyAnalysis = async () => {
display: flex;
align-items: center;
width: 100%;
position: relative;
gap: 0.5rem;
}
.search-input {
flex: 1;
border: none;
background: none;
padding: 0.5rem;
padding: 0;
font-size: 0.95rem;
color: var(--color-text-primary);
outline: none;
@ -405,10 +535,15 @@ const copyAnalysis = async () => {
transition: all 0.2s ease;
}
.clear-button:hover {
.clear-button:hover:not(:disabled) {
opacity: 1;
}
.clear-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.clear-button svg {
width: 16px;
height: 16px;
@ -455,6 +590,100 @@ const copyAnalysis = async () => {
flex-shrink: 0;
}
.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;
}
.thought-text {
font-size: 0.9rem;
color: var(--color-text-secondary);
}
.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;
@ -517,96 +746,73 @@ const copyAnalysis = async () => {
height: 16px;
}
/* 分析状态样式 */
.analysis-status {
/* 快速分析列表样式 */
.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;
flex-direction: row;
justify-content: space-between;
align-items: center;
justify-content: center;
padding: 1rem;
text-align: center;
gap: 1rem;
margin-top: 1rem;
margin-bottom: 0.75rem;
}
.progress-dots {
.section-title {
font-size: 0.9rem;
color: var(--color-text-secondary);
}
.common-analysis-list {
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;
flex-wrap: wrap;
gap: 0.5rem;
}
.status-label {
font-weight: 500;
.common-analysis-item {
padding: 0.35rem 0.75rem;
font-size: 0.9rem;
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);
border: 1px solid var(--color-accent);
border-radius: var(--border-radius);
cursor: pointer;
transition: all 0.2s ease;
}
.analysis-content {
padding: 1.5rem;
line-height: 2;
white-space: pre-wrap;
word-break: break-word;
color: var(--color-text-primary);
.common-analysis-item:hover:not(:disabled) {
background-color: var(--color-accent);
color: white;
}
.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: '⚠️ ';
.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);
}
}
@keyframes pulse-dot {
0%,
100% {
@ -619,43 +825,10 @@ const copyAnalysis = async () => {
}
}
.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;
.universal-analysis-view {
padding: 0.75rem;
}
.analysis-view {
@ -664,7 +837,7 @@ const copyAnalysis = async () => {
}
.analysis-header {
padding: 0.75rem;
padding: 1rem;
flex-direction: column;
gap: 0.75rem;
align-items: flex-start;
@ -682,9 +855,81 @@ const copyAnalysis = async () => {
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;
}
}
@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;
@ -707,20 +952,6 @@ const copyAnalysis = async () => {
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;
}
@ -729,24 +960,26 @@ const copyAnalysis = async () => {
padding: 0.5rem;
font-size: 0.8rem;
}
}
/* 滚动条样式 */
:deep(*::-webkit-scrollbar) {
width: 6px;
height: 6px;
}
.search-container {
padding: 0.75rem;
}
:deep(*::-webkit-scrollbar-track) {
background: transparent;
}
.search-section {
padding: 0 0.75rem;
}
:deep(*::-webkit-scrollbar-thumb) {
background-color: rgba(125, 125, 125, 0.2);
border-radius: 3px;
}
.common-analysis-section {
padding: 0 0.75rem;
}
:deep(*::-webkit-scrollbar-thumb:hover) {
background-color: rgba(125, 125, 125, 0.3);
.common-analysis-container {
padding: 0 0.75rem;
}
.common-analysis-item {
font-size: 0.85rem;
padding: 0.3rem 0.6rem;
}
}
</style>