This commit is contained in:
aaron 2025-05-14 17:40:02 +08:00
parent 5393559171
commit b6a9e72500
3 changed files with 1652 additions and 0 deletions

View File

@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import DownloadView from '../views/DownloadView.vue'
import StockAnalysisView from '../views/StockAnalysisView.vue'
import { useUserStore } from '../stores/user'
const router = createRouter({
@ -45,6 +46,24 @@ const router = createRouter({
component: DownloadView,
meta: { standalone: true },
},
{
path: '/stock-analysis',
name: 'stock-analysis',
component: StockAnalysisView,
meta: {
requiresAuth: true,
title: '股票分析专家',
},
},
{
path: '/crypto-analysis',
name: 'crypto-analysis',
component: () => import('../views/CryptoAnalysisView.vue'),
meta: {
requiresAuth: true,
title: '加密货币分析专家',
},
},
],
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,512 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useUserStore } from '../stores/user'
interface StockOption {
stock_code: string
short_name: string
exchange: string
list_date: string
}
const userStore = useUserStore()
const searchQuery = ref('')
const selectedStock = ref<StockOption | null>(null)
const isAnalyzing = ref(false)
const analysisResult = ref(null)
const showDropdown = ref(false)
const isSearching = ref(false)
const stockOptions = ref<StockOption[]>([])
// APIURL
const apiBaseUrl =
import.meta.env.MODE === 'development' ? 'http://127.0.0.1:8000' : 'https://api.ibtc.work'
//
watch(searchQuery, async (newQuery) => {
const query = newQuery.trim()
if (!query || query.length < 2) {
stockOptions.value = []
showDropdown.value = false
selectedStock.value = null
return
}
isSearching.value = true
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (userStore.authHeader) {
headers['Authorization'] = userStore.authHeader
}
const response = await fetch(
`${apiBaseUrl}/adata/stock/search?key=${encodeURIComponent(query)}`,
{
method: 'GET',
headers,
},
)
if (!response.ok) {
throw new Error('搜索请求失败')
}
const data = await response.json()
stockOptions.value = data
showDropdown.value = true
} catch (error) {
console.error('搜索股票失败:', error)
stockOptions.value = []
} finally {
isSearching.value = false
}
})
const selectStock = (stock: StockOption) => {
selectedStock.value = stock
searchQuery.value = `${stock.stock_code} - ${stock.short_name}`
showDropdown.value = false
}
const handleSearch = async () => {
if (!selectedStock.value) {
return
}
isAnalyzing.value = true
try {
// TODO:
// const response = await fetch('API_URL', {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json'
// },
// body: JSON.stringify({
// stockCode: selectedStock.value.stock_code,
// market: selectedStock.value.exchange
// })
// })
// analysisResult.value = await response.json()
// API
await new Promise((resolve) => setTimeout(resolve, 1000))
} catch (error) {
console.error('分析请求失败:', error)
} finally {
isAnalyzing.value = false
}
}
//
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (!target.closest('.search-container')) {
showDropdown.value = false
}
}
//
const formatListDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('zh-CN')
}
</script>
<template>
<div class="stock-analysis-view" @click="handleClickOutside">
<div class="content-container">
<div class="header-section">
<h1 class="title">股票分析专家</h1>
<p class="description">输入股票代码或名称至少2个字符获取AI驱动的专业分析报告</p>
</div>
<div class="main-section">
<div class="search-section">
<div class="search-container">
<div class="input-wrapper">
<input
v-model="searchQuery"
type="text"
class="search-input"
placeholder="请输入股票代码或名称600519 或 贵州茅台)"
@focus="showDropdown = searchQuery.length >= 2 && stockOptions.length > 0"
/>
<div class="search-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</div>
<!-- 加载指示器 -->
<div v-if="isSearching" class="loading-indicator">
<div class="spinner"></div>
</div>
</div>
<!-- 股票选项下拉框 -->
<div v-if="showDropdown && stockOptions.length > 0" class="stock-options">
<div
v-for="stock in stockOptions"
:key="stock.stock_code"
class="stock-option"
@click="selectStock(stock)"
>
<div class="stock-code">{{ stock.stock_code }}</div>
<div class="stock-name">{{ stock.short_name }}</div>
<div class="stock-market">{{ stock.exchange }}</div>
</div>
</div>
<!-- 无搜索结果提示 -->
<div
v-if="
showDropdown && searchQuery.length >= 2 && stockOptions.length === 0 && !isSearching
"
class="no-results"
>
未找到匹配的股票
</div>
<button
class="analyze-button"
@click="handleSearch"
:disabled="isAnalyzing || !selectedStock"
>
<span v-if="!isAnalyzing">AI分析</span>
<span v-else class="analyzing">
<span class="dot-animation">分析中</span>
</span>
</button>
</div>
</div>
<div v-if="selectedStock && !analysisResult" class="selected-stock-info">
<div class="stock-card">
<div class="stock-header">
<h3>{{ selectedStock.short_name }}</h3>
<span class="stock-code">{{ selectedStock.stock_code }}</span>
</div>
<div class="stock-details">
<div class="detail-item">
<span class="detail-label">交易所</span>
<span class="detail-value">{{ selectedStock.exchange }}</span>
</div>
<div class="detail-item">
<span class="detail-label">上市日期</span>
<span class="detail-value">{{ formatListDate(selectedStock.list_date) }}</span>
</div>
</div>
</div>
</div>
<div class="result-section" v-if="analysisResult">
<div class="analysis-card">
<h2>AI分析报告</h2>
<div class="analysis-content">
<!-- 这里将根据API返回的数据结构进行调整 -->
<p>分析结果将在这里显示</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.stock-analysis-view {
min-height: 100vh;
background-color: var(--color-bg-primary);
padding: 2rem 1rem;
}
.content-container {
max-width: 1200px;
margin: 0 auto;
}
.header-section {
text-align: center;
margin-bottom: 3rem;
padding: 2rem 0;
}
.title {
font-size: 2.5rem;
font-weight: 700;
color: var(--color-text-primary);
margin-bottom: 1rem;
}
.description {
font-size: 1.1rem;
color: var(--color-text-secondary);
}
.main-section {
max-width: 800px;
margin: 0 auto;
}
.search-section {
margin-bottom: 2rem;
}
.search-container {
position: relative;
background-color: var(--color-bg-secondary);
padding: 1.5rem;
border-radius: var(--border-radius);
border: 1px solid var(--color-border);
}
.input-wrapper {
position: relative;
margin-bottom: 1rem;
}
.search-input {
width: 100%;
padding: 0.75rem 2.5rem 0.75rem 1rem;
font-size: 1rem;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
transition: all 0.2s ease;
}
.search-input:focus {
outline: none;
border-color: var(--color-accent);
}
.search-icon {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-secondary);
}
.loading-indicator {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--color-accent);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.stock-options {
position: absolute;
top: 100%;
left: 1.5rem;
right: 1.5rem;
background-color: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
margin-top: 0.5rem;
max-height: 300px;
overflow-y: auto;
z-index: 10;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.stock-option {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
cursor: pointer;
transition: background-color 0.2s ease;
}
.stock-option:hover {
background-color: var(--color-bg-secondary);
}
.stock-code {
font-weight: 500;
margin-right: 1rem;
color: var(--color-accent);
}
.stock-name {
flex: 1;
color: var(--color-text-primary);
}
.stock-market {
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.analyze-button {
width: 100%;
padding: 0.75rem;
font-size: 1rem;
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;
}
.selected-stock-info {
margin-bottom: 2rem;
}
.stock-card {
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
padding: 1.5rem;
}
.stock-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.stock-header h3 {
margin: 0;
font-size: 1.2rem;
color: var(--color-text-primary);
}
.stock-details {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.detail-item {
display: flex;
align-items: center;
}
.detail-label {
color: var(--color-text-secondary);
margin-right: 0.5rem;
min-width: 5rem;
}
.detail-value {
color: var(--color-text-primary);
}
.analyzing {
display: inline-flex;
align-items: center;
}
.dot-animation {
position: relative;
}
.dot-animation::after {
content: '...';
position: absolute;
animation: dots 1.5s infinite;
margin-left: 2px;
}
@keyframes dots {
0%,
20% {
content: '.';
}
40%,
60% {
content: '..';
}
80%,
100% {
content: '...';
}
}
.no-results {
padding: 1rem;
text-align: center;
color: var(--color-text-secondary);
background-color: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
margin-top: 0.5rem;
}
/* 响应式设计 */
@media (max-width: 768px) {
.stock-analysis-view {
padding: 1rem;
}
.title {
font-size: 2rem;
}
.search-container {
padding: 1rem;
}
.stock-options {
left: 1rem;
right: 1rem;
}
}
@media (max-width: 480px) {
.header-section {
padding: 1rem 0;
}
.stock-option {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.stock-code {
margin-right: 0;
}
}
</style>