feat: vue重构
This commit is contained in:
41
frontend/src/App.vue
Normal file
41
frontend/src/App.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<n-config-provider :theme="theme">
|
||||
<n-message-provider>
|
||||
<n-loading-bar-provider>
|
||||
<n-dialog-provider>
|
||||
<n-notification-provider>
|
||||
<StockAnalysisApp />
|
||||
</n-notification-provider>
|
||||
</n-dialog-provider>
|
||||
</n-loading-bar-provider>
|
||||
</n-message-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
NConfigProvider,
|
||||
NMessageProvider,
|
||||
NLoadingBarProvider,
|
||||
NDialogProvider,
|
||||
NNotificationProvider,
|
||||
} from 'naive-ui'
|
||||
import StockAnalysisApp from './components/StockAnalysisApp.vue'
|
||||
|
||||
// 主题设置 (默认使用亮色主题)
|
||||
const theme = ref<any>(null) // 可以切换为 darkTheme 以启用暗色模式
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
|
||||
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
background-color: #f6f6f6;
|
||||
}
|
||||
</style>
|
||||
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
128
frontend/src/components/AnnouncementBanner.vue
Normal file
128
frontend/src/components/AnnouncementBanner.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div v-if="showAnnouncement" class="announcement-container">
|
||||
<n-card class="announcement-card">
|
||||
<template #header>
|
||||
<div class="announcement-header">
|
||||
<n-icon size="18" :component="InformationCircleIcon" class="info-icon" />
|
||||
<span>系统公告</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="announcement-content" v-html="processedContent"></div>
|
||||
<div class="announcement-timer">{{ remainingTimeText }}</div>
|
||||
<template #action>
|
||||
<n-button quaternary circle size="small" @click="closeAnnouncement">
|
||||
<template #icon>
|
||||
<n-icon :component="CloseIcon" />
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { NCard, NIcon, NButton } from 'naive-ui';
|
||||
import { InformationCircleOutline as InformationCircleIcon } from '@vicons/ionicons5';
|
||||
import { Close as CloseIcon } from '@vicons/ionicons5';
|
||||
|
||||
const props = defineProps<{
|
||||
content: string;
|
||||
autoCloseTime?: number;
|
||||
}>();
|
||||
|
||||
const showAnnouncement = ref(true);
|
||||
const remainingTime = ref(props.autoCloseTime || 5);
|
||||
const timer = ref<number | null>(null);
|
||||
|
||||
const remainingTimeText = computed(() => {
|
||||
return `${remainingTime.value}秒后自动关闭`;
|
||||
});
|
||||
|
||||
const processedContent = computed(() => {
|
||||
// 处理文本中的URL
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
||||
return props.content.replace(
|
||||
urlRegex,
|
||||
'<a href="$1" target="_blank" class="announcement-link">$1</a>'
|
||||
);
|
||||
});
|
||||
|
||||
function closeAnnouncement() {
|
||||
showAnnouncement.value = false;
|
||||
if (timer.value !== null) {
|
||||
window.clearInterval(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateTimer() {
|
||||
if (remainingTime.value <= 1) {
|
||||
closeAnnouncement();
|
||||
} else {
|
||||
remainingTime.value--;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
timer.value = window.setInterval(updateTimer, 1000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer.value !== null) {
|
||||
window.clearInterval(timer.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.announcement-container {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
max-width: 24rem;
|
||||
z-index: 50;
|
||||
animation: fadeInDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
.announcement-card {
|
||||
border-left: 4px solid var(--n-primary-color);
|
||||
}
|
||||
|
||||
.announcement-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
color: var(--n-primary-color);
|
||||
}
|
||||
|
||||
.announcement-content {
|
||||
margin-bottom: 0.5rem;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.announcement-timer {
|
||||
font-size: 0.75rem;
|
||||
color: var(--n-text-color-disabled);
|
||||
}
|
||||
|
||||
.announcement-link {
|
||||
color: var(--n-primary-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@keyframes fadeInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
339
frontend/src/components/ApiConfigPanel.vue
Normal file
339
frontend/src/components/ApiConfigPanel.vue
Normal file
@@ -0,0 +1,339 @@
|
||||
<template>
|
||||
<div class="api-config-section">
|
||||
<n-button
|
||||
class="toggle-button"
|
||||
size="small"
|
||||
@click="toggleConfig"
|
||||
:quaternary="true"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon :component="expanded ? ChevronUpIcon : ChevronDownIcon" />
|
||||
</template>
|
||||
API配置 {{ expanded ? '收起' : '展开' }}
|
||||
</n-button>
|
||||
|
||||
<n-collapse-transition :show="expanded">
|
||||
<n-card class="api-config-card" content-style="padding: 0.75rem;">
|
||||
<n-alert title="OpenAI API配置" type="info" v-if="isApiInfoVisible">
|
||||
<template #icon>
|
||||
<n-icon :component="InformationCircleIcon" />
|
||||
</template>
|
||||
<p>您可以配置自己的API,也可以使用系统默认配置。API密钥仅在您的浏览器中使用,不会发送到服务器存储。</p>
|
||||
<div class="alert-actions">
|
||||
<n-button text @click="isApiInfoVisible = false">
|
||||
<template #icon>
|
||||
<n-icon :component="CloseIcon" />
|
||||
</template>
|
||||
</n-button>
|
||||
</div>
|
||||
</n-alert>
|
||||
|
||||
<n-grid :cols="24" :x-gap="12">
|
||||
<n-grid-item :span="14">
|
||||
<n-form-item label="API URL" path="apiUrl">
|
||||
<n-input
|
||||
v-model:value="apiConfig.apiUrl"
|
||||
placeholder="https://api.openai.com/v1/chat/completions"
|
||||
@update:value="handleConfigChange"
|
||||
/>
|
||||
<template #feedback>
|
||||
<span class="formatted-url">{{ formattedUrl }}</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item :span="10">
|
||||
<n-form-item label="API Key" path="apiKey">
|
||||
<n-input
|
||||
v-model:value="apiConfig.apiKey"
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
show-password-on="click"
|
||||
@update:value="handleConfigChange"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item :span="12">
|
||||
<n-form-item label="模型" path="apiModel">
|
||||
<n-input
|
||||
v-model:value="apiConfig.apiModel"
|
||||
placeholder="gpt-3.5-turbo"
|
||||
@update:value="handleConfigChange"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item :span="12">
|
||||
<n-form-item label="超时时间(秒)" path="apiTimeout">
|
||||
<n-input-number
|
||||
v-model:value="apiTimeout"
|
||||
placeholder="60"
|
||||
:min="1"
|
||||
:max="300"
|
||||
@update:value="handleTimeoutChange"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<div class="api-actions">
|
||||
<div class="api-save-option">
|
||||
<n-switch
|
||||
v-model:value="apiConfig.saveApiConfig"
|
||||
@update:value="handleConfigChange"
|
||||
/>
|
||||
<span class="save-label">保存配置到本地</span>
|
||||
</div>
|
||||
|
||||
<div class="api-buttons">
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="testingConnection"
|
||||
:disabled="!isConfigValid"
|
||||
@click="testConnection"
|
||||
>
|
||||
测试连接
|
||||
</n-button>
|
||||
|
||||
<n-button @click="resetConfig">
|
||||
重置
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-collapse-transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import {
|
||||
NButton,
|
||||
NIcon,
|
||||
NCard,
|
||||
NCollapseTransition,
|
||||
NGrid,
|
||||
NGridItem,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NInputNumber,
|
||||
NSwitch,
|
||||
NAlert,
|
||||
useMessage
|
||||
} from 'naive-ui';
|
||||
import {
|
||||
ChevronDown as ChevronDownIcon,
|
||||
ChevronUp as ChevronUpIcon,
|
||||
InformationCircleOutline as InformationCircleIcon,
|
||||
Close as CloseIcon
|
||||
} from '@vicons/ionicons5';
|
||||
import { apiService } from '@/services/api';
|
||||
import { saveApiConfigToLocalStorage, loadApiConfig } from '@/utils';
|
||||
import type { ApiConfig } from '@/types';
|
||||
|
||||
const props = defineProps<{
|
||||
defaultApiUrl?: string;
|
||||
defaultApiModel?: string;
|
||||
defaultApiTimeout?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:apiConfig', value: ApiConfig): void;
|
||||
}>();
|
||||
|
||||
const message = useMessage();
|
||||
const expanded = ref(false);
|
||||
const testingConnection = ref(false);
|
||||
const isApiInfoVisible = ref(true);
|
||||
|
||||
// API配置
|
||||
const apiConfig = ref<ApiConfig>({
|
||||
apiUrl: props.defaultApiUrl || '',
|
||||
apiKey: '',
|
||||
apiModel: props.defaultApiModel || 'gpt-3.5-turbo',
|
||||
apiTimeout: props.defaultApiTimeout || '60',
|
||||
saveApiConfig: false
|
||||
});
|
||||
|
||||
const apiTimeout = computed({
|
||||
get: () => parseInt(apiConfig.value.apiTimeout) || 60,
|
||||
set: (val: number) => {
|
||||
apiConfig.value.apiTimeout = val.toString();
|
||||
}
|
||||
});
|
||||
|
||||
const isConfigValid = computed(() => {
|
||||
return apiConfig.value.apiUrl && apiConfig.value.apiKey;
|
||||
});
|
||||
|
||||
const formattedUrl = computed(() => {
|
||||
return formatApiUrl(apiConfig.value.apiUrl);
|
||||
});
|
||||
|
||||
function toggleConfig() {
|
||||
expanded.value = !expanded.value;
|
||||
}
|
||||
|
||||
function handleConfigChange() {
|
||||
// 如果选择了保存配置,则自动保存
|
||||
if (apiConfig.value.saveApiConfig) {
|
||||
saveApiConfigToLocalStorage({
|
||||
apiUrl: apiConfig.value.apiUrl,
|
||||
apiKey: apiConfig.value.apiKey,
|
||||
apiModel: apiConfig.value.apiModel,
|
||||
apiTimeout: apiConfig.value.apiTimeout,
|
||||
saveApiConfig: true
|
||||
});
|
||||
}
|
||||
|
||||
// 向父组件发送更新事件
|
||||
emit('update:apiConfig', { ...apiConfig.value });
|
||||
}
|
||||
|
||||
function handleTimeoutChange(value: number | null) {
|
||||
if (value !== null) {
|
||||
apiConfig.value.apiTimeout = value.toString();
|
||||
handleConfigChange();
|
||||
}
|
||||
}
|
||||
|
||||
function formatApiUrl(url: string): string {
|
||||
if (!url) return '';
|
||||
|
||||
try {
|
||||
// 尝试解析URL
|
||||
const parsedUrl = new URL(url);
|
||||
return `${parsedUrl.origin}${parsedUrl.pathname}`;
|
||||
} catch (e) {
|
||||
// 如果URL格式错误,则返回原始字符串
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
if (!isConfigValid.value) {
|
||||
message.error('请填写完整的API配置信息');
|
||||
return;
|
||||
}
|
||||
|
||||
testingConnection.value = true;
|
||||
|
||||
try {
|
||||
const response = await apiService.testApiConnection({
|
||||
api_url: apiConfig.value.apiUrl,
|
||||
api_key: apiConfig.value.apiKey,
|
||||
api_model: apiConfig.value.apiModel,
|
||||
api_timeout: apiConfig.value.apiTimeout
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
message.success('API连接测试成功');
|
||||
|
||||
// 如果选择了保存配置,则保存
|
||||
if (apiConfig.value.saveApiConfig) {
|
||||
saveApiConfigToLocalStorage({
|
||||
apiUrl: apiConfig.value.apiUrl,
|
||||
apiKey: apiConfig.value.apiKey,
|
||||
apiModel: apiConfig.value.apiModel,
|
||||
apiTimeout: apiConfig.value.apiTimeout,
|
||||
saveApiConfig: true
|
||||
});
|
||||
}
|
||||
} else {
|
||||
message.error(`API连接测试失败: ${response.message}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(`测试连接出错: ${error.message || '未知错误'}`);
|
||||
} finally {
|
||||
testingConnection.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetConfig() {
|
||||
apiConfig.value = {
|
||||
apiUrl: props.defaultApiUrl || '',
|
||||
apiKey: '',
|
||||
apiModel: props.defaultApiModel || 'gpt-3.5-turbo',
|
||||
apiTimeout: props.defaultApiTimeout || '60',
|
||||
saveApiConfig: false
|
||||
};
|
||||
|
||||
// 清除本地存储
|
||||
if (window.localStorage) {
|
||||
localStorage.removeItem('apiConfig');
|
||||
}
|
||||
|
||||
message.success('已重置API配置');
|
||||
emit('update:apiConfig', { ...apiConfig.value });
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 加载保存的配置
|
||||
const savedConfig = loadApiConfig();
|
||||
|
||||
if (savedConfig.saveApiConfig) {
|
||||
apiConfig.value = {
|
||||
apiUrl: savedConfig.apiUrl || props.defaultApiUrl || '',
|
||||
apiKey: savedConfig.apiKey || '',
|
||||
apiModel: savedConfig.apiModel || props.defaultApiModel || 'gpt-3.5-turbo',
|
||||
apiTimeout: savedConfig.apiTimeout || props.defaultApiTimeout || '60',
|
||||
saveApiConfig: true
|
||||
};
|
||||
|
||||
// 通知父组件配置已加载
|
||||
emit('update:apiConfig', { ...apiConfig.value });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.api-config-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.api-config-card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.formatted-url {
|
||||
color: var(--n-text-color-info);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.alert-actions {
|
||||
margin-top: 0.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.api-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.api-save-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.save-label {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.api-buttons {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
133
frontend/src/components/MarketTimeDisplay.vue
Normal file
133
frontend/src/components/MarketTimeDisplay.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<n-card class="market-time-card">
|
||||
<n-grid :x-gap="16" :y-gap="16" :cols="gridCols">
|
||||
<!-- 当前时间 -->
|
||||
<n-grid-item>
|
||||
<div class="time-block">
|
||||
<p class="time-label">当前时间</p>
|
||||
<p class="current-time">{{ marketInfo.currentTime }}</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- A股状态 -->
|
||||
<n-grid-item>
|
||||
<div class="time-block">
|
||||
<p class="time-label">A股市场</p>
|
||||
<p class="market-status" :class="marketInfo.cnMarket.isOpen ? 'status-open' : 'status-closed'">
|
||||
{{ marketInfo.cnMarket.isOpen ? '交易中' : '已休市' }}
|
||||
</p>
|
||||
<p class="time-counter">{{ marketInfo.cnMarket.nextTime }}</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- 港股状态 -->
|
||||
<n-grid-item>
|
||||
<div class="time-block">
|
||||
<p class="time-label">港股市场</p>
|
||||
<p class="market-status" :class="marketInfo.hkMarket.isOpen ? 'status-open' : 'status-closed'">
|
||||
{{ marketInfo.hkMarket.isOpen ? '交易中' : '已休市' }}
|
||||
</p>
|
||||
<p class="time-counter">{{ marketInfo.hkMarket.nextTime }}</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- 美股状态 -->
|
||||
<n-grid-item>
|
||||
<div class="time-block">
|
||||
<p class="time-label">美股市场</p>
|
||||
<p class="market-status" :class="marketInfo.usMarket.isOpen ? 'status-open' : 'status-closed'">
|
||||
{{ marketInfo.usMarket.isOpen ? '交易中' : '已休市' }}
|
||||
</p>
|
||||
<p class="time-counter">{{ marketInfo.usMarket.nextTime }}</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { NCard, NGrid, NGridItem } from 'naive-ui';
|
||||
import { updateMarketTimeInfo } from '@/utils';
|
||||
import type { MarketTimeInfo } from '@/types';
|
||||
|
||||
const props = defineProps({
|
||||
isMobile: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const marketInfo = ref<MarketTimeInfo>({
|
||||
currentTime: '',
|
||||
cnMarket: { isOpen: false, nextTime: '' },
|
||||
hkMarket: { isOpen: false, nextTime: '' },
|
||||
usMarket: { isOpen: false, nextTime: '' }
|
||||
});
|
||||
|
||||
const gridCols = computed(() => {
|
||||
return props.isMobile ? 1 : 4;
|
||||
});
|
||||
|
||||
let intervalId: number | null = null;
|
||||
|
||||
function updateMarketTime() {
|
||||
marketInfo.value = updateMarketTimeInfo();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateMarketTime(); // 立即更新一次
|
||||
intervalId = window.setInterval(updateMarketTime, 1000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (intervalId !== null) {
|
||||
window.clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.market-time-card {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.time-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--n-text-color-3);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.current-time {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--n-text-color);
|
||||
}
|
||||
|
||||
.market-status {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.status-open {
|
||||
color: var(--n-success-color);
|
||||
}
|
||||
|
||||
.status-closed {
|
||||
color: var(--n-text-color-3);
|
||||
}
|
||||
|
||||
.time-counter {
|
||||
font-size: 0.75rem;
|
||||
color: var(--n-text-color-3);
|
||||
}
|
||||
</style>
|
||||
489
frontend/src/components/StockAnalysisApp.vue
Normal file
489
frontend/src/components/StockAnalysisApp.vue
Normal file
@@ -0,0 +1,489 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<!-- 公告栏 -->
|
||||
<AnnouncementBanner v-if="announcement" :content="announcement" :auto-close-time="5" />
|
||||
|
||||
<n-layout class="main-layout">
|
||||
<n-layout-content class="main-content">
|
||||
<n-page-header title="股票分析系统">
|
||||
<template #avatar>
|
||||
<n-icon :component="BarChartIcon" color="#2080f0" size="28" />
|
||||
</template>
|
||||
</n-page-header>
|
||||
|
||||
<!-- 市场时间显示 -->
|
||||
<MarketTimeDisplay />
|
||||
|
||||
<!-- API配置面板 -->
|
||||
<ApiConfigPanel
|
||||
:default-api-url="defaultApiUrl"
|
||||
:default-api-model="defaultApiModel"
|
||||
:default-api-timeout="defaultApiTimeout"
|
||||
@update:api-config="updateApiConfig"
|
||||
/>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<n-card class="analysis-container">
|
||||
<template #header>
|
||||
<div class="card-title">股票批量分析</div>
|
||||
</template>
|
||||
|
||||
<n-grid :cols="24" :x-gap="16" :y-gap="16">
|
||||
<!-- 左侧配置区域 -->
|
||||
<n-grid-item :span="24" :lg-span="8">
|
||||
<div class="config-section">
|
||||
<n-form-item label="选择市场类型">
|
||||
<n-select
|
||||
v-model:value="marketType"
|
||||
:options="marketOptions"
|
||||
@update:value="handleMarketTypeChange"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="股票搜索" v-if="marketType === 'US'">
|
||||
<StockSearch :market-type="marketType" @select="addSelectedStock" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="输入代码">
|
||||
<n-input
|
||||
v-model:value="stockCodes"
|
||||
type="textarea"
|
||||
placeholder="输入股票代码,多个代码用逗号、空格或换行分隔"
|
||||
:autosize="{ minRows: 3, maxRows: 6 }"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<div class="action-buttons">
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="isAnalyzing"
|
||||
:disabled="!stockCodes.trim()"
|
||||
@click="analyzeStocks"
|
||||
>
|
||||
{{ isAnalyzing ? '分析中...' : '开始分析' }}
|
||||
</n-button>
|
||||
|
||||
<n-button
|
||||
:disabled="analyzedStocks.length === 0"
|
||||
@click="copyAnalysisResults"
|
||||
>
|
||||
复制结果
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- 右侧结果区域 -->
|
||||
<n-grid-item :span="24" :lg-span="16">
|
||||
<div class="results-section">
|
||||
<template v-if="analyzedStocks.length === 0 && !isAnalyzing">
|
||||
<n-empty description="尚未分析股票" size="large">
|
||||
<template #icon>
|
||||
<n-icon :component="DocumentTextIcon" />
|
||||
</template>
|
||||
</n-empty>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<n-grid :cols="1" :x-gap="16" :y-gap="16" :lg-cols="2">
|
||||
<n-grid-item v-for="stock in analyzedStocks" :key="stock.code">
|
||||
<StockCard :stock="stock" />
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</template>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import {
|
||||
NLayout,
|
||||
NLayoutContent,
|
||||
NCard,
|
||||
NPageHeader,
|
||||
NIcon,
|
||||
NGrid,
|
||||
NGridItem,
|
||||
NFormItem,
|
||||
NSelect,
|
||||
NInput,
|
||||
NButton,
|
||||
NEmpty,
|
||||
useMessage
|
||||
} from 'naive-ui';
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import {
|
||||
BarChart as BarChartIcon,
|
||||
DocumentText as DocumentTextIcon
|
||||
} from '@vicons/ionicons5';
|
||||
|
||||
import AnnouncementBanner from './AnnouncementBanner.vue';
|
||||
import MarketTimeDisplay from './MarketTimeDisplay.vue';
|
||||
import ApiConfigPanel from './ApiConfigPanel.vue';
|
||||
import StockSearch from './StockSearch.vue';
|
||||
import StockCard from './StockCard.vue';
|
||||
|
||||
import { apiService } from '@/services/api';
|
||||
import type { StockInfo, ApiConfig, StreamInitMessage, StreamAnalysisUpdate } from '@/types';
|
||||
import { loadApiConfig } from '@/utils';
|
||||
|
||||
// 使用Naive UI的消息组件
|
||||
const message = useMessage();
|
||||
const { copy } = useClipboard();
|
||||
|
||||
// 从环境变量获取的默认配置
|
||||
const defaultApiUrl = ref('');
|
||||
const defaultApiModel = ref('gpt-3.5-turbo');
|
||||
const defaultApiTimeout = ref('60');
|
||||
const announcement = ref('');
|
||||
|
||||
// 股票分析配置
|
||||
const marketType = ref('A');
|
||||
const stockCodes = ref('');
|
||||
const isAnalyzing = ref(false);
|
||||
const analyzedStocks = ref<StockInfo[]>([]);
|
||||
|
||||
// API配置
|
||||
const apiConfig = ref<ApiConfig>({
|
||||
apiUrl: '',
|
||||
apiKey: '',
|
||||
apiModel: 'gpt-3.5-turbo',
|
||||
apiTimeout: '60',
|
||||
saveApiConfig: false
|
||||
});
|
||||
|
||||
// 市场选项
|
||||
const marketOptions = [
|
||||
{ label: 'A股', value: 'A' },
|
||||
{ label: '港股', value: 'HK' },
|
||||
{ label: '美股', value: 'US' }
|
||||
];
|
||||
|
||||
// 更新API配置
|
||||
function updateApiConfig(config: ApiConfig) {
|
||||
apiConfig.value = { ...config };
|
||||
}
|
||||
|
||||
// 处理市场类型变更
|
||||
function handleMarketTypeChange() {
|
||||
stockCodes.value = '';
|
||||
analyzedStocks.value = [];
|
||||
}
|
||||
|
||||
// 添加选择的股票
|
||||
function addSelectedStock(symbol: string) {
|
||||
if (stockCodes.value) {
|
||||
stockCodes.value += ', ' + symbol;
|
||||
} else {
|
||||
stockCodes.value = symbol;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理流式响应的数据
|
||||
function processStreamData(text: string) {
|
||||
try {
|
||||
// 尝试解析为JSON
|
||||
const data = JSON.parse(text);
|
||||
|
||||
// 判断是初始消息还是更新消息
|
||||
if (data.stream_type === 'single' || data.stream_type === 'batch') {
|
||||
// 初始消息
|
||||
handleStreamInit(data as StreamInitMessage);
|
||||
} else if (data.stock_code) {
|
||||
// 更新消息
|
||||
handleStreamUpdate(data as StreamAnalysisUpdate);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析流数据出错:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理流式初始化消息
|
||||
function handleStreamInit(data: StreamInitMessage) {
|
||||
if (data.stream_type === 'single' && data.stock_code) {
|
||||
// 单个股票分析
|
||||
analyzedStocks.value = [{
|
||||
code: data.stock_code,
|
||||
name: '',
|
||||
marketType: marketType.value,
|
||||
analysisStatus: 'waiting'
|
||||
}];
|
||||
} else if (data.stream_type === 'batch' && data.stock_codes) {
|
||||
// 批量分析
|
||||
analyzedStocks.value = data.stock_codes.map(code => ({
|
||||
code,
|
||||
name: '',
|
||||
marketType: marketType.value,
|
||||
analysisStatus: 'waiting'
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 处理流式更新消息
|
||||
function handleStreamUpdate(data: StreamAnalysisUpdate) {
|
||||
const stockIndex = analyzedStocks.value.findIndex(s => s.code === data.stock_code);
|
||||
|
||||
if (stockIndex >= 0) {
|
||||
const stock = { ...analyzedStocks.value[stockIndex] };
|
||||
|
||||
// 更新分析状态
|
||||
stock.analysisStatus = data.status;
|
||||
|
||||
// 如果有分析结果,则更新
|
||||
if (data.analysis !== undefined) {
|
||||
stock.analysis = data.analysis;
|
||||
}
|
||||
|
||||
// 如果有错误,则更新
|
||||
if (data.error !== undefined) {
|
||||
stock.error = data.error;
|
||||
}
|
||||
|
||||
// 更新股票名称、价格等信息
|
||||
if (data.name !== undefined) {
|
||||
stock.name = data.name;
|
||||
}
|
||||
|
||||
if (data.price !== undefined) {
|
||||
stock.price = data.price;
|
||||
}
|
||||
|
||||
if (data.change_percent !== undefined) {
|
||||
stock.changePercent = data.change_percent;
|
||||
}
|
||||
|
||||
if (data.market_value !== undefined) {
|
||||
stock.marketValue = data.market_value;
|
||||
}
|
||||
|
||||
// 更新数组中的股票信息
|
||||
analyzedStocks.value[stockIndex] = stock;
|
||||
}
|
||||
}
|
||||
|
||||
// 分析股票
|
||||
async function analyzeStocks() {
|
||||
if (!stockCodes.value.trim()) {
|
||||
message.warning('请输入股票代码');
|
||||
return;
|
||||
}
|
||||
|
||||
isAnalyzing.value = true;
|
||||
analyzedStocks.value = [];
|
||||
|
||||
// 解析股票代码
|
||||
const codes = stockCodes.value
|
||||
.split(/[,\s\n]+/)
|
||||
.map(code => code.trim())
|
||||
.filter(code => code);
|
||||
|
||||
if (codes.length === 0) {
|
||||
message.warning('未找到有效的股票代码');
|
||||
isAnalyzing.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 构建请求参数
|
||||
const requestData = {
|
||||
stock_codes: codes,
|
||||
market_type: marketType.value
|
||||
} as any;
|
||||
|
||||
// 添加自定义API配置
|
||||
if (apiConfig.value.apiUrl) {
|
||||
requestData.api_url = apiConfig.value.apiUrl;
|
||||
}
|
||||
|
||||
if (apiConfig.value.apiKey) {
|
||||
requestData.api_key = apiConfig.value.apiKey;
|
||||
}
|
||||
|
||||
if (apiConfig.value.apiModel) {
|
||||
requestData.api_model = apiConfig.value.apiModel;
|
||||
}
|
||||
|
||||
if (apiConfig.value.apiTimeout) {
|
||||
requestData.api_timeout = apiConfig.value.apiTimeout;
|
||||
}
|
||||
|
||||
// 发送分析请求
|
||||
const response = await fetch('/analyze', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error ${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 text = decoder.decode(value, { stream: true });
|
||||
buffer += text;
|
||||
|
||||
// 按行处理数据
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // 最后一行可能不完整,保留到下一次
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
processStreamData(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理最后可能剩余的数据
|
||||
if (buffer.trim()) {
|
||||
processStreamData(buffer);
|
||||
}
|
||||
|
||||
message.success('分析完成');
|
||||
} catch (error: any) {
|
||||
message.error(`分析出错: ${error.message || '未知错误'}`);
|
||||
console.error('分析股票时出错:', error);
|
||||
} finally {
|
||||
isAnalyzing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 复制分析结果
|
||||
async function copyAnalysisResults() {
|
||||
if (analyzedStocks.value.length === 0) {
|
||||
message.warning('没有可复制的分析结果');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 格式化分析结果
|
||||
const formattedResults = analyzedStocks.value
|
||||
.filter(stock => stock.analysisStatus === 'completed')
|
||||
.map(stock => {
|
||||
return `【${stock.code} ${stock.name || ''}】\n${stock.analysis || '无分析结果'}\n`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
if (!formattedResults) {
|
||||
message.warning('没有已完成的分析结果可复制');
|
||||
return;
|
||||
}
|
||||
|
||||
// 复制到剪贴板
|
||||
await copy(formattedResults);
|
||||
message.success('已复制分析结果到剪贴板');
|
||||
} catch (error) {
|
||||
message.error('复制失败,请手动复制');
|
||||
console.error('复制分析结果时出错:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 从本地存储恢复API配置
|
||||
function restoreLocalApiConfig() {
|
||||
const savedConfig = loadApiConfig();
|
||||
if (savedConfig && savedConfig.saveApiConfig) {
|
||||
apiConfig.value = {
|
||||
apiUrl: savedConfig.apiUrl || '',
|
||||
apiKey: savedConfig.apiKey || '',
|
||||
apiModel: savedConfig.apiModel || defaultApiModel.value,
|
||||
apiTimeout: savedConfig.apiTimeout || defaultApiTimeout.value,
|
||||
saveApiConfig: savedConfig.saveApiConfig
|
||||
};
|
||||
|
||||
// 通知父组件配置已更新
|
||||
updateApiConfig(apiConfig.value);
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时获取默认配置和公告
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// 从API获取配置信息
|
||||
const config = await apiService.getConfig();
|
||||
|
||||
if (config.default_api_url) {
|
||||
defaultApiUrl.value = config.default_api_url;
|
||||
}
|
||||
|
||||
if (config.default_api_model) {
|
||||
defaultApiModel.value = config.default_api_model;
|
||||
}
|
||||
|
||||
if (config.default_api_timeout) {
|
||||
defaultApiTimeout.value = config.default_api_timeout;
|
||||
}
|
||||
|
||||
if (config.announcement) {
|
||||
announcement.value = config.announcement;
|
||||
}
|
||||
|
||||
// 初始化后恢复本地保存的配置
|
||||
restoreLocalApiConfig();
|
||||
} catch (error) {
|
||||
console.error('获取默认配置时出错:', error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-container {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-layout {
|
||||
background-color: #f6f6f6;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.analysis-container {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.results-section {
|
||||
padding: 0.5rem;
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
207
frontend/src/components/StockCard.vue
Normal file
207
frontend/src/components/StockCard.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<n-card class="stock-card" :bordered="false" :class="{ 'is-analyzing': isAnalyzing }">
|
||||
<div class="card-header">
|
||||
<div class="stock-info">
|
||||
<div class="stock-code">{{ stock.code }}</div>
|
||||
<div class="stock-name">{{ stock.name || '加载中...' }}</div>
|
||||
</div>
|
||||
<div class="stock-price-info" v-if="stock.price !== undefined">
|
||||
<div class="stock-price">{{ stock.price.toFixed(2) }}</div>
|
||||
<div class="stock-change" :class="{
|
||||
'up': stock.changePercent && stock.changePercent > 0,
|
||||
'down': stock.changePercent && stock.changePercent < 0
|
||||
}">
|
||||
{{ formatChangePercent(stock.changePercent) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-divider />
|
||||
|
||||
<div class="card-content">
|
||||
<template v-if="stock.analysisStatus === 'waiting'">
|
||||
<div class="waiting-status">
|
||||
<n-spin size="small" />
|
||||
<span>等待分析...</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="stock.analysisStatus === 'analyzing'">
|
||||
<div class="analyzing-status">
|
||||
<n-spin size="small" />
|
||||
<span>正在分析...</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="stock.analysisStatus === 'error'">
|
||||
<div class="error-status">
|
||||
<n-icon :component="AlertCircleIcon" class="error-icon" />
|
||||
<span>分析出错: {{ stock.error || '未知错误' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="stock.analysisStatus === 'completed'">
|
||||
<div class="analysis-result" v-html="parsedAnalysis"></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="card-footer">
|
||||
<div class="market-value" v-if="stock.marketValue">
|
||||
市值: {{ formatMarketValue(stock.marketValue) }}
|
||||
</div>
|
||||
<div class="market-type">
|
||||
{{ getMarketName(stock.marketType) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { NCard, NDivider, NSpin, NIcon } from 'naive-ui';
|
||||
import { AlertCircleOutline as AlertCircleIcon } from '@vicons/ionicons5';
|
||||
import { parseMarkdown, formatMarketValue as formatMarketValueFn } from '@/utils';
|
||||
import type { StockInfo } from '@/types';
|
||||
|
||||
const props = defineProps<{
|
||||
stock: StockInfo;
|
||||
}>();
|
||||
|
||||
const isAnalyzing = computed(() => {
|
||||
return props.stock.analysisStatus === 'analyzing';
|
||||
});
|
||||
|
||||
const parsedAnalysis = computed(() => {
|
||||
if (props.stock.analysis) {
|
||||
return parseMarkdown(props.stock.analysis);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
function formatChangePercent(percent: number | undefined): string {
|
||||
if (percent === undefined) return '--';
|
||||
|
||||
const sign = percent > 0 ? '+' : '';
|
||||
return `${sign}${percent.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function formatMarketValue(value: number): string {
|
||||
return formatMarketValueFn(value);
|
||||
}
|
||||
|
||||
function getMarketName(marketType: string): string {
|
||||
const marketMap: Record<string, string> = {
|
||||
'A': 'A股',
|
||||
'HK': '港股',
|
||||
'US': '美股'
|
||||
};
|
||||
|
||||
return marketMap[marketType] || marketType;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stock-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.stock-card.is-analyzing {
|
||||
border-left: 3px solid var(--n-info-color);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stock-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stock-code {
|
||||
font-size: 1.125rem;
|
||||
font-weight: bold;
|
||||
color: var(--n-text-color);
|
||||
}
|
||||
|
||||
.stock-name {
|
||||
font-size: 0.875rem;
|
||||
color: var(--n-text-color-3);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.stock-price-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.stock-price {
|
||||
font-size: 1.125rem;
|
||||
font-weight: bold;
|
||||
color: var(--n-text-color);
|
||||
}
|
||||
|
||||
.stock-change {
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.up {
|
||||
color: var(--n-error-color);
|
||||
}
|
||||
|
||||
.down {
|
||||
color: var(--n-success-color);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
flex: 1;
|
||||
min-height: 100px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.waiting-status,
|
||||
.analyzing-status,
|
||||
.error-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--n-text-color-3);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: var(--n-error-color);
|
||||
}
|
||||
|
||||
.analysis-result {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.analysis-result :deep(p) {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.analysis-result :deep(ul) {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
color: var(--n-text-color-3);
|
||||
}
|
||||
</style>
|
||||
220
frontend/src/components/StockSearch.vue
Normal file
220
frontend/src/components/StockSearch.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div class="stock-search-container">
|
||||
<n-input
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="输入股票代码或名称搜索"
|
||||
@input="handleSearchInput"
|
||||
@blur="handleBlur"
|
||||
@focus="handleFocus"
|
||||
ref="searchInputRef"
|
||||
>
|
||||
<template #prefix>
|
||||
<n-icon :component="SearchIcon" />
|
||||
</template>
|
||||
</n-input>
|
||||
|
||||
<div class="search-results" v-show="showResults">
|
||||
<div v-if="loading" class="loading-results">
|
||||
<n-spin size="small" />
|
||||
<span>搜索中...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="results.length === 0 && searchKeyword" class="no-results">
|
||||
未找到相关股票
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<n-scrollbar style="max-height: 300px;">
|
||||
<div
|
||||
v-for="item in results"
|
||||
:key="item.symbol"
|
||||
class="search-result-item"
|
||||
@click="selectStock(item)"
|
||||
>
|
||||
<div class="result-symbol-name">
|
||||
<span class="result-symbol">{{ item.symbol }}</span>
|
||||
<span class="result-name">{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="result-meta">
|
||||
<span class="result-market">{{ item.market }}</span>
|
||||
<span v-if="item.marketValue" class="result-market-value">
|
||||
市值: {{ formatMarketValue(item.marketValue) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { NInput, NIcon, NSpin, NScrollbar } from 'naive-ui';
|
||||
import { Search as SearchIcon } from '@vicons/ionicons5';
|
||||
import { apiService } from '@/services/api';
|
||||
import { debounce, formatMarketValue as formatMarketValueFn } from '@/utils';
|
||||
import type { SearchResult } from '@/types';
|
||||
|
||||
const props = defineProps<{
|
||||
marketType: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', symbol: string): void;
|
||||
}>();
|
||||
|
||||
const searchKeyword = ref('');
|
||||
const results = ref<SearchResult[]>([]);
|
||||
const loading = ref(false);
|
||||
const showResults = ref(false);
|
||||
const searchInputRef = ref<HTMLElement | null>(null);
|
||||
|
||||
// 创建防抖搜索函数
|
||||
const debouncedSearch = debounce(async (keyword: string) => {
|
||||
if (!keyword) {
|
||||
results.value = [];
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
if (props.marketType === 'US') {
|
||||
// 美股搜索
|
||||
results.value = await apiService.searchUsStocks(keyword);
|
||||
} else {
|
||||
// 其他市场搜索 (后端需要实现对应的接口)
|
||||
results.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索股票时出错:', error);
|
||||
results.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}, 300);
|
||||
|
||||
function handleSearchInput() {
|
||||
showResults.value = true;
|
||||
debouncedSearch(searchKeyword.value);
|
||||
}
|
||||
|
||||
function selectStock(item: SearchResult) {
|
||||
emit('select', item.symbol);
|
||||
searchKeyword.value = '';
|
||||
showResults.value = false;
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
// 延迟隐藏,以便可以点击结果项
|
||||
setTimeout(() => {
|
||||
showResults.value = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
if (searchKeyword.value) {
|
||||
showResults.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function formatMarketValue(value: number): string {
|
||||
return formatMarketValueFn(value);
|
||||
}
|
||||
|
||||
// 点击外部时隐藏搜索结果
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
searchInputRef.value &&
|
||||
!searchInputRef.value.contains(event.target as Node)
|
||||
) {
|
||||
showResults.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stock-search-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
margin-top: 0.25rem;
|
||||
background-color: var(--n-color);
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid var(--n-border-color);
|
||||
}
|
||||
|
||||
.loading-results,
|
||||
.no-results {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
color: var(--n-text-color-3);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-result-item:hover {
|
||||
background-color: var(--n-color-hover);
|
||||
}
|
||||
|
||||
.result-symbol-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.result-symbol {
|
||||
font-weight: 500;
|
||||
color: var(--n-text-color);
|
||||
}
|
||||
|
||||
.result-name {
|
||||
font-size: 0.75rem;
|
||||
color: var(--n-text-color-3);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.result-market,
|
||||
.result-market-value {
|
||||
font-size: 0.75rem;
|
||||
color: var(--n-text-color-3);
|
||||
}
|
||||
|
||||
.result-market-value {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
6
frontend/src/main.ts
Normal file
6
frontend/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
app.mount('#app')
|
||||
59
frontend/src/services/api.ts
Normal file
59
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import axios from 'axios';
|
||||
import type { AnalyzeRequest, TestApiRequest, TestApiResponse, SearchResult } from '@/types';
|
||||
|
||||
// 在开发环境中前缀为空,因为已经在vite.config.ts中配置了代理
|
||||
const API_PREFIX = '';
|
||||
|
||||
export const apiService = {
|
||||
// 分析股票
|
||||
analyzeStocks: async (request: AnalyzeRequest) => {
|
||||
return axios.post(`${API_PREFIX}/analyze`, request, {
|
||||
responseType: 'stream'
|
||||
});
|
||||
},
|
||||
|
||||
// 测试API连接
|
||||
testApiConnection: async (request: TestApiRequest): Promise<TestApiResponse> => {
|
||||
try {
|
||||
const response = await axios.post(`${API_PREFIX}/test_api_connection`, request);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (error.response) {
|
||||
return error.response.data;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || '连接失败'
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// 搜索美股
|
||||
searchUsStocks: async (keyword: string): Promise<SearchResult[]> => {
|
||||
try {
|
||||
const response = await axios.get(`${API_PREFIX}/search_us_stocks`, {
|
||||
params: { keyword }
|
||||
});
|
||||
return response.data.results || [];
|
||||
} catch (error) {
|
||||
console.error('搜索美股时出错:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// 获取配置
|
||||
getConfig: async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_PREFIX}/config`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取配置时出错:', error);
|
||||
return {
|
||||
announcement: '',
|
||||
default_api_url: '',
|
||||
default_api_model: 'gpt-3.5-turbo',
|
||||
default_api_timeout: '60'
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
79
frontend/src/style.css
Normal file
79
frontend/src/style.css
Normal file
@@ -0,0 +1,79 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
80
frontend/src/types/index.ts
Normal file
80
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// API接口相关类型
|
||||
export interface ApiConfig {
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
apiModel: string;
|
||||
apiTimeout: string;
|
||||
saveApiConfig: boolean;
|
||||
}
|
||||
|
||||
export interface StockInfo {
|
||||
code: string;
|
||||
name: string;
|
||||
marketType: string;
|
||||
price?: number;
|
||||
changePercent?: number;
|
||||
marketValue?: number;
|
||||
analysis?: string;
|
||||
analysisStatus: 'waiting' | 'analyzing' | 'completed' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
symbol: string;
|
||||
name: string;
|
||||
market: string;
|
||||
marketValue?: number;
|
||||
}
|
||||
|
||||
export interface MarketStatus {
|
||||
isOpen: boolean;
|
||||
nextTime: string;
|
||||
}
|
||||
|
||||
export interface MarketTimeInfo {
|
||||
currentTime: string;
|
||||
cnMarket: MarketStatus;
|
||||
hkMarket: MarketStatus;
|
||||
usMarket: MarketStatus;
|
||||
}
|
||||
|
||||
// 分析请求和响应
|
||||
export interface AnalyzeRequest {
|
||||
stock_codes: string[];
|
||||
market_type: string;
|
||||
api_url?: string;
|
||||
api_key?: string;
|
||||
api_model?: string;
|
||||
api_timeout?: string;
|
||||
}
|
||||
|
||||
export interface TestApiRequest {
|
||||
api_url: string;
|
||||
api_key: string;
|
||||
api_model: string;
|
||||
api_timeout: string;
|
||||
}
|
||||
|
||||
export interface TestApiResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
status_code?: number;
|
||||
}
|
||||
|
||||
// 流式响应类型
|
||||
export interface StreamInitMessage {
|
||||
stream_type: 'single' | 'batch';
|
||||
stock_code?: string;
|
||||
stock_codes?: string[];
|
||||
}
|
||||
|
||||
export interface StreamAnalysisUpdate {
|
||||
stock_code: string;
|
||||
analysis?: string;
|
||||
status: 'analyzing' | 'completed' | 'error';
|
||||
error?: string;
|
||||
name?: string;
|
||||
price?: number;
|
||||
change_percent?: number;
|
||||
market_value?: number;
|
||||
}
|
||||
201
frontend/src/utils/index.ts
Normal file
201
frontend/src/utils/index.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import type { MarketTimeInfo } from '@/types';
|
||||
import { marked } from 'marked';
|
||||
|
||||
// 防抖函数
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: number | null = null;
|
||||
|
||||
return function(...args: Parameters<T>): void {
|
||||
const later = () => {
|
||||
timeout = null;
|
||||
func(...args);
|
||||
};
|
||||
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
timeout = window.setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
// 格式化市值
|
||||
export function formatMarketValue(value: number): string {
|
||||
if (!value) return '未知';
|
||||
|
||||
if (value >= 1000000000000) {
|
||||
return (value / 1000000000000).toFixed(2) + '万亿';
|
||||
} else if (value >= 100000000) {
|
||||
return (value / 100000000).toFixed(2) + '亿';
|
||||
} else if (value >= 10000) {
|
||||
return (value / 10000).toFixed(2) + '万';
|
||||
} else {
|
||||
return value.toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
// 解析Markdown
|
||||
export function parseMarkdown(text: string): string {
|
||||
try {
|
||||
const result = marked(text);
|
||||
if (typeof result === 'string') {
|
||||
return result;
|
||||
}
|
||||
return '';
|
||||
} catch (e) {
|
||||
console.error('解析Markdown出错:', e);
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新市场时间信息
|
||||
export function updateMarketTimeInfo(): MarketTimeInfo {
|
||||
const now = new Date();
|
||||
|
||||
// 当前时间
|
||||
const currentTime = now.toLocaleTimeString('zh-CN', { hour12: false });
|
||||
|
||||
// 中国时间
|
||||
const cnOptions = { timeZone: 'Asia/Shanghai', hour12: false } as Intl.DateTimeFormatOptions;
|
||||
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);
|
||||
|
||||
const cnNextTime = getNextTimeText(cnMarketOpen, cnHour, cnMinute, 9, 30, 15, 0);
|
||||
|
||||
// 港股市场状态(与A股相同时区)
|
||||
const hkMarketOpen = (cnHour === 9 && cnMinute >= 30) ||
|
||||
(cnHour === 10) || (cnHour === 11) ||
|
||||
(cnHour >= 13 && cnHour < 16);
|
||||
|
||||
const hkNextTime = getNextTimeText(hkMarketOpen, cnHour, cnMinute, 9, 30, 16, 0);
|
||||
|
||||
// 获取美国东部时间
|
||||
const usOptions = { timeZone: 'America/New_York', hour12: false } as Intl.DateTimeFormatOptions;
|
||||
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);
|
||||
|
||||
const usNextTime = getNextTimeText(usMarketOpen, usHour, usMinute, 9, 30, 16, 0);
|
||||
|
||||
return {
|
||||
currentTime,
|
||||
cnMarket: { isOpen: cnMarketOpen, nextTime: cnNextTime },
|
||||
hkMarket: { isOpen: hkMarketOpen, nextTime: hkNextTime },
|
||||
usMarket: { isOpen: usMarketOpen, nextTime: usNextTime }
|
||||
};
|
||||
}
|
||||
|
||||
// 辅助函数:获取距离下一次开/闭市的时间文本
|
||||
function getNextTimeText(
|
||||
isOpen: boolean,
|
||||
currentHour: number,
|
||||
currentMinute: number,
|
||||
openHour: number,
|
||||
openMinute: number,
|
||||
closeHour: number,
|
||||
closeMinute: number
|
||||
): string {
|
||||
if (isOpen) {
|
||||
// 计算距离收盘时间
|
||||
let timeToCloseMinutes = (closeHour - currentHour) * 60 + (closeMinute - currentMinute);
|
||||
|
||||
if (timeToCloseMinutes <= 0) {
|
||||
return '即将收盘';
|
||||
}
|
||||
|
||||
const hours = Math.floor(timeToCloseMinutes / 60);
|
||||
const minutes = timeToCloseMinutes % 60;
|
||||
|
||||
return `距离收盘还有 ${hours}小时${minutes}分钟`;
|
||||
} else {
|
||||
// 计算距离开盘时间
|
||||
let nextOpenHour = openHour;
|
||||
let nextOpenMinute = openMinute;
|
||||
let isNextDay = false;
|
||||
|
||||
if (currentHour >= closeHour) {
|
||||
// 已经过了今天的收盘时间,下一个开盘是明天
|
||||
isNextDay = true;
|
||||
} else if (currentHour < openHour || (currentHour === openHour && currentMinute < openMinute)) {
|
||||
// 还没到今天的开盘时间
|
||||
isNextDay = false;
|
||||
} else {
|
||||
// 当前处于盘中休息时间,下一个开盘时间是当天下午
|
||||
nextOpenHour = 13;
|
||||
nextOpenMinute = 0;
|
||||
}
|
||||
|
||||
let timeToOpenMinutes;
|
||||
|
||||
if (isNextDay) {
|
||||
timeToOpenMinutes = (24 - currentHour + nextOpenHour) * 60 + (nextOpenMinute - currentMinute);
|
||||
} else {
|
||||
timeToOpenMinutes = (nextOpenHour - currentHour) * 60 + (nextOpenMinute - currentMinute);
|
||||
}
|
||||
|
||||
if (timeToOpenMinutes <= 0) {
|
||||
return '即将开盘';
|
||||
}
|
||||
|
||||
const hours = Math.floor(timeToOpenMinutes / 60);
|
||||
const minutes = timeToOpenMinutes % 60;
|
||||
|
||||
return `距离开盘还有 ${hours}小时${minutes}分钟`;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存API配置到localStorage
|
||||
export function saveApiConfigToLocalStorage(config: Partial<Pick<
|
||||
{ apiUrl: string, apiKey: string, apiModel: string, apiTimeout: string, saveApiConfig: boolean },
|
||||
'apiUrl' | 'apiKey' | 'apiModel' | 'apiTimeout' | 'saveApiConfig'
|
||||
>>): void {
|
||||
if (window.localStorage) {
|
||||
localStorage.setItem('apiConfig', JSON.stringify(config));
|
||||
}
|
||||
}
|
||||
|
||||
// 从localStorage加载API配置
|
||||
export function loadApiConfig(): Partial<{
|
||||
apiUrl: string,
|
||||
apiKey: string,
|
||||
apiModel: string,
|
||||
apiTimeout: string,
|
||||
saveApiConfig: boolean
|
||||
}> {
|
||||
if (window.localStorage) {
|
||||
const saved = localStorage.getItem('apiConfig');
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch (e) {
|
||||
console.error('解析保存的API配置出错:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
apiUrl: '',
|
||||
apiKey: '',
|
||||
apiModel: '',
|
||||
apiTimeout: '',
|
||||
saveApiConfig: false
|
||||
};
|
||||
}
|
||||
|
||||
// 清除API配置
|
||||
export function clearApiConfig(): void {
|
||||
if (window.localStorage) {
|
||||
localStorage.removeItem('apiConfig');
|
||||
}
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user