feat: 优化前端显示&修复若干bug
This commit is contained in:
@@ -5,16 +5,17 @@
|
||||
size="small"
|
||||
@click="toggleConfig"
|
||||
:quaternary="true"
|
||||
:type="expanded ? 'primary' : 'default'"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon :component="expanded ? ChevronUpIcon : ChevronDownIcon" />
|
||||
</template>
|
||||
API配置 {{ expanded ? '收起' : '展开' }}
|
||||
<span class="toggle-text">API配置 {{ expanded ? '收起' : '展开' }}</span>
|
||||
</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">
|
||||
<n-card class="api-config-card" :bordered="false">
|
||||
<n-alert title="OpenAI API配置" type="info" v-if="isApiInfoVisible" class="api-info-alert">
|
||||
<template #icon>
|
||||
<n-icon :component="InformationCircleIcon" />
|
||||
</template>
|
||||
@@ -28,21 +29,32 @@
|
||||
</div>
|
||||
</n-alert>
|
||||
|
||||
<n-grid :cols="24" :x-gap="12">
|
||||
<n-grid-item :span="14">
|
||||
<n-grid :cols="24" :x-gap="16" :y-gap="16">
|
||||
<n-grid-item :span="24" :lg-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"
|
||||
/>
|
||||
round
|
||||
>
|
||||
<template #prefix>
|
||||
<n-icon :component="GlobeIcon" />
|
||||
</template>
|
||||
</n-input>
|
||||
<template #feedback>
|
||||
<span class="formatted-url">{{ formattedUrl }}</span>
|
||||
<div class="url-feedback">
|
||||
<span class="formatted-url">实际请求地址: {{ formattedUrl }}</span>
|
||||
<div class="url-tips">
|
||||
<div>提示: URL以/结尾将忽略v1路径</div>
|
||||
<div>URL以#结尾将使用原始地址</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item :span="10">
|
||||
<n-grid-item :span="24" :lg-span="10">
|
||||
<n-form-item label="API Key" path="apiKey">
|
||||
<n-input
|
||||
v-model:value="apiConfig.apiKey"
|
||||
@@ -50,21 +62,63 @@
|
||||
placeholder="sk-..."
|
||||
show-password-on="click"
|
||||
@update:value="handleConfigChange"
|
||||
/>
|
||||
round
|
||||
>
|
||||
<template #prefix>
|
||||
<n-icon :component="KeyIcon" />
|
||||
</template>
|
||||
</n-input>
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item :span="12">
|
||||
<n-grid-item :span="12" :lg-span="12">
|
||||
<n-form-item label="模型" path="apiModel">
|
||||
<n-input
|
||||
v-model:value="apiConfig.apiModel"
|
||||
placeholder="gpt-3.5-turbo"
|
||||
<n-input
|
||||
v-model:value="apiConfig.apiModel"
|
||||
placeholder="输入或选择模型名称"
|
||||
@update:value="handleConfigChange"
|
||||
/>
|
||||
round
|
||||
>
|
||||
<template #prefix>
|
||||
<n-icon :component="CodeIcon" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<n-dropdown
|
||||
trigger="click"
|
||||
:options="modelOptions"
|
||||
@select="selectModel"
|
||||
placement="bottom-end"
|
||||
>
|
||||
<n-button quaternary circle size="small" class="model-dropdown-btn">
|
||||
<template #icon>
|
||||
<n-icon :component="ChevronDownIcon" />
|
||||
</template>
|
||||
</n-button>
|
||||
</n-dropdown>
|
||||
</template>
|
||||
</n-input>
|
||||
<template #feedback>
|
||||
<div class="model-suggestions">
|
||||
<div class="model-tip">您可以直接输入模型名称,或点击右侧按钮从下拉菜单选择</div>
|
||||
<span>常用模型:</span>
|
||||
<div class="model-chips">
|
||||
<n-tag
|
||||
v-for="model in commonModels"
|
||||
:key="model.key"
|
||||
size="small"
|
||||
round
|
||||
clickable
|
||||
@click="selectModel(model.key)"
|
||||
>
|
||||
{{ model.label }}
|
||||
</n-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item :span="12">
|
||||
<n-grid-item :span="12" :lg-span="12">
|
||||
<n-form-item label="超时时间(秒)" path="apiTimeout">
|
||||
<n-input-number
|
||||
v-model:value="apiTimeout"
|
||||
@@ -72,7 +126,20 @@
|
||||
:min="1"
|
||||
:max="300"
|
||||
@update:value="handleTimeoutChange"
|
||||
/>
|
||||
:show-button="false"
|
||||
class="timeout-input"
|
||||
>
|
||||
<template #suffix>
|
||||
<div class="timeout-controls">
|
||||
<n-button size="tiny" quaternary @click="decreaseTimeout">
|
||||
<template #icon><n-icon :component="RemoveIcon" /></template>
|
||||
</n-button>
|
||||
<n-button size="tiny" quaternary @click="increaseTimeout">
|
||||
<template #icon><n-icon :component="AddIcon" /></template>
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-input-number>
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
@@ -92,22 +159,36 @@
|
||||
:loading="testingConnection"
|
||||
:disabled="!isConfigValid"
|
||||
@click="testConnection"
|
||||
round
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon :component="CheckmarkIcon" />
|
||||
</template>
|
||||
测试连接
|
||||
</n-button>
|
||||
|
||||
<n-button @click="resetConfig">
|
||||
<n-button @click="resetConfig" round>
|
||||
<template #icon>
|
||||
<n-icon :component="RefreshIcon" />
|
||||
</template>
|
||||
重置
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-divider v-if="connectionStatus" style="margin: 16px 0 12px" />
|
||||
|
||||
<div v-if="connectionStatus" class="connection-status" :class="connectionStatus.type">
|
||||
<n-icon :component="connectionStatus.icon" class="status-icon" />
|
||||
<span class="status-message">{{ connectionStatus.message }}</span>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-collapse-transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted, reactive } from 'vue';
|
||||
import {
|
||||
NButton,
|
||||
NIcon,
|
||||
@@ -120,13 +201,25 @@ import {
|
||||
NInputNumber,
|
||||
NSwitch,
|
||||
NAlert,
|
||||
NDivider,
|
||||
NDropdown,
|
||||
NTag,
|
||||
useMessage
|
||||
} from 'naive-ui';
|
||||
import {
|
||||
ChevronDown as ChevronDownIcon,
|
||||
ChevronUp as ChevronUpIcon,
|
||||
InformationCircleOutline as InformationCircleIcon,
|
||||
Close as CloseIcon
|
||||
Close as CloseIcon,
|
||||
Globe as GlobeIcon,
|
||||
Key as KeyIcon,
|
||||
CheckmarkCircleOutline as CheckmarkIcon,
|
||||
RefreshOutline as RefreshIcon,
|
||||
AddOutline as AddIcon,
|
||||
RemoveOutline as RemoveIcon,
|
||||
CheckmarkCircle as SuccessIcon,
|
||||
CloseCircle as ErrorIcon,
|
||||
CodeSlashOutline as CodeIcon
|
||||
} from '@vicons/ionicons5';
|
||||
import { apiService } from '@/services/api';
|
||||
import { saveApiConfigToLocalStorage, loadApiConfig } from '@/utils';
|
||||
@@ -147,11 +240,41 @@ const expanded = ref(false);
|
||||
const testingConnection = ref(false);
|
||||
const isApiInfoVisible = ref(true);
|
||||
|
||||
// 连接状态
|
||||
const connectionStatus = ref<{
|
||||
type: 'success' | 'error';
|
||||
message: string;
|
||||
icon: any;
|
||||
} | null>(null);
|
||||
|
||||
// 模型选项
|
||||
const modelOptions = [
|
||||
{ label: 'GPT-3.5', key: 'gpt-3.5-turbo' },
|
||||
{ label: 'GPT-4o', key: 'gpt-4o' },
|
||||
{ label: 'DeepSeek V3', key: 'deepseek-chat' },
|
||||
{ label: 'DeepSeek R1', key: 'deepseek-reasoner' },
|
||||
{ label: 'Claude 3.5 Sonnet', key: 'claude-3-5-sonnet' },
|
||||
{ label: 'Claude 3.5 Sonnet 20241022', key: 'claude-3-5-sonnet-20241022' },
|
||||
{ label: 'Gemini 1.5 Pro', key: 'gemini-1.5-pro' },
|
||||
{ label: 'Gemini 1.5 Flash', key: 'gemini-1.5-flash' },
|
||||
{ label: 'Gemini 2.0 Pro', key: 'gemini-2.0-pro' },
|
||||
{ label: 'Gemini 2.0 Flash', key: 'gemini-2.0-flash' }
|
||||
];
|
||||
|
||||
// 常用模型(用于快速选择)
|
||||
const commonModels = [
|
||||
{ label: 'GPT-3.5', key: 'gpt-3.5-turbo' },
|
||||
{ label: 'GPT-4o', key: 'gpt-4o' },
|
||||
{ label: 'Claude 3.5', key: 'claude-3-5-sonnet' },
|
||||
{ label: 'Gemini 2.0', key: 'gemini-2.0-flash' },
|
||||
{ label: 'DeepSeek V3', key: 'deepseek-chat' },
|
||||
];
|
||||
|
||||
// API配置
|
||||
const apiConfig = ref<ApiConfig>({
|
||||
apiUrl: props.defaultApiUrl || '',
|
||||
apiKey: '',
|
||||
apiModel: props.defaultApiModel || 'gpt-3.5-turbo',
|
||||
apiModel: props.defaultApiModel || '',
|
||||
apiTimeout: props.defaultApiTimeout || '60',
|
||||
saveApiConfig: false
|
||||
});
|
||||
@@ -176,6 +299,8 @@ function toggleConfig() {
|
||||
}
|
||||
|
||||
function handleConfigChange() {
|
||||
console.log('API配置变更:', apiConfig.value);
|
||||
|
||||
// 如果选择了保存配置,则自动保存
|
||||
if (apiConfig.value.saveApiConfig) {
|
||||
saveApiConfigToLocalStorage({
|
||||
@@ -198,13 +323,32 @@ function handleTimeoutChange(value: number | null) {
|
||||
}
|
||||
}
|
||||
|
||||
function increaseTimeout() {
|
||||
if (apiTimeout.value < 300) {
|
||||
apiTimeout.value += 10;
|
||||
handleTimeoutChange(apiTimeout.value);
|
||||
}
|
||||
}
|
||||
|
||||
function decreaseTimeout() {
|
||||
if (apiTimeout.value > 10) {
|
||||
apiTimeout.value -= 10;
|
||||
handleTimeoutChange(apiTimeout.value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatApiUrl(url: string): string {
|
||||
if (!url) return '';
|
||||
|
||||
try {
|
||||
// 尝试解析URL
|
||||
const parsedUrl = new URL(url);
|
||||
return `${parsedUrl.origin}${parsedUrl.pathname}`;
|
||||
// 使用与后端一致的URL格式化逻辑
|
||||
if (url.endsWith('/')) {
|
||||
return `${url}chat/completions`;
|
||||
} else if (url.endsWith('#')) {
|
||||
return url.replace('#', '');
|
||||
} else {
|
||||
return `${url}/v1/chat/completions`;
|
||||
}
|
||||
} catch (e) {
|
||||
// 如果URL格式错误,则返回原始字符串
|
||||
return url;
|
||||
@@ -218,6 +362,7 @@ async function testConnection() {
|
||||
}
|
||||
|
||||
testingConnection.value = true;
|
||||
connectionStatus.value = null;
|
||||
|
||||
try {
|
||||
const response = await apiService.testApiConnection({
|
||||
@@ -229,6 +374,11 @@ async function testConnection() {
|
||||
|
||||
if (response.success) {
|
||||
message.success('API连接测试成功');
|
||||
connectionStatus.value = {
|
||||
type: 'success',
|
||||
message: '连接成功!API配置有效。',
|
||||
icon: SuccessIcon
|
||||
};
|
||||
|
||||
// 如果选择了保存配置,则保存
|
||||
if (apiConfig.value.saveApiConfig) {
|
||||
@@ -242,9 +392,19 @@ async function testConnection() {
|
||||
}
|
||||
} else {
|
||||
message.error(`API连接测试失败: ${response.message}`);
|
||||
connectionStatus.value = {
|
||||
type: 'error',
|
||||
message: `连接失败: ${response.message}`,
|
||||
icon: ErrorIcon
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(`测试连接出错: ${error.message || '未知错误'}`);
|
||||
connectionStatus.value = {
|
||||
type: 'error',
|
||||
message: `连接错误: ${error.message || '未知错误'}`,
|
||||
icon: ErrorIcon
|
||||
};
|
||||
} finally {
|
||||
testingConnection.value = false;
|
||||
}
|
||||
@@ -254,7 +414,7 @@ function resetConfig() {
|
||||
apiConfig.value = {
|
||||
apiUrl: props.defaultApiUrl || '',
|
||||
apiKey: '',
|
||||
apiModel: props.defaultApiModel || 'gpt-3.5-turbo',
|
||||
apiModel: props.defaultApiModel || '',
|
||||
apiTimeout: props.defaultApiTimeout || '60',
|
||||
saveApiConfig: false
|
||||
};
|
||||
@@ -264,10 +424,18 @@ function resetConfig() {
|
||||
localStorage.removeItem('apiConfig');
|
||||
}
|
||||
|
||||
connectionStatus.value = null;
|
||||
message.success('已重置API配置');
|
||||
emit('update:apiConfig', { ...apiConfig.value });
|
||||
}
|
||||
|
||||
// 选择模型
|
||||
function selectModel(key: string) {
|
||||
console.log('选择模型:', key);
|
||||
apiConfig.value.apiModel = key;
|
||||
handleConfigChange();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 加载保存的配置
|
||||
const savedConfig = loadApiConfig();
|
||||
@@ -276,7 +444,7 @@ onMounted(() => {
|
||||
apiConfig.value = {
|
||||
apiUrl: savedConfig.apiUrl || props.defaultApiUrl || '',
|
||||
apiKey: savedConfig.apiKey || '',
|
||||
apiModel: savedConfig.apiModel || props.defaultApiModel || 'gpt-3.5-turbo',
|
||||
apiModel: savedConfig.apiModel || props.defaultApiModel || '',
|
||||
apiTimeout: savedConfig.apiTimeout || props.defaultApiTimeout || '60',
|
||||
saveApiConfig: true
|
||||
};
|
||||
@@ -289,25 +457,58 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.api-config-section {
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
border-radius: 16px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.toggle-button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.toggle-text {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.api-config-card {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
background: linear-gradient(to bottom, rgba(240, 240, 245, 0.5), rgba(250, 250, 252, 0.8));
|
||||
padding: 16px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.api-info-alert {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.url-feedback {
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.formatted-url {
|
||||
color: var(--n-text-color-info);
|
||||
font-size: 0.85rem;
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
.url-tips {
|
||||
color: var(--n-text-color-info);
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.alert-actions {
|
||||
@@ -319,21 +520,123 @@ onMounted(() => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.api-save-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.save-label {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.api-buttons {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.timeout-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.timeout-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
margin-top: 8px;
|
||||
font-weight: 500;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.connection-status.success {
|
||||
background-color: rgba(24, 160, 88, 0.1);
|
||||
color: var(--n-success-color);
|
||||
}
|
||||
|
||||
.connection-status.error {
|
||||
background-color: rgba(208, 48, 80, 0.1);
|
||||
color: var(--n-error-color);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.api-actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.api-buttons {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.model-suggestions {
|
||||
margin-top: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--n-text-color-3);
|
||||
}
|
||||
|
||||
.model-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.model-chips :deep(.n-tag) {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.model-chips :deep(.n-tag:hover) {
|
||||
background-color: rgba(32, 128, 240, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.model-tip {
|
||||
margin-bottom: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--n-text-color-3);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.model-dropdown-btn {
|
||||
background-color: rgba(32, 128, 240, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.model-dropdown-btn:hover {
|
||||
background-color: rgba(32, 128, 240, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
</style>
|
||||
|
||||
536
frontend/src/components/LoginPage.vue
Normal file
536
frontend/src/components/LoginPage.vue
Normal file
@@ -0,0 +1,536 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-background">
|
||||
<div class="login-shape shape1"></div>
|
||||
<div class="login-shape shape2"></div>
|
||||
<div class="login-shape shape3"></div>
|
||||
<div class="login-shape shape4"></div>
|
||||
<div class="login-shape shape5"></div>
|
||||
<div class="login-particle particle1"></div>
|
||||
<div class="login-particle particle2"></div>
|
||||
<div class="login-particle particle3"></div>
|
||||
<div class="login-particle particle4"></div>
|
||||
<div class="login-particle particle5"></div>
|
||||
<div class="login-particle particle6"></div>
|
||||
</div>
|
||||
|
||||
<n-card class="login-card" :bordered="false">
|
||||
<div class="login-header">
|
||||
<div class="login-logo">
|
||||
<n-icon :component="BarChartIcon" color="#2080f0" size="36" class="logo-icon" />
|
||||
</div>
|
||||
<h1 class="login-title">股票AI分析系统</h1>
|
||||
<p class="login-subtitle">使用AI技术分析股票市场趋势</p>
|
||||
</div>
|
||||
|
||||
<n-form
|
||||
ref="formRef"
|
||||
:model="formValue"
|
||||
:rules="rules"
|
||||
label-placement="left"
|
||||
label-width="0"
|
||||
require-mark-placement="right-hanging"
|
||||
class="login-form"
|
||||
>
|
||||
<n-form-item path="password">
|
||||
<n-input
|
||||
v-model:value="formValue.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
@keyup.enter="handleLogin"
|
||||
size="large"
|
||||
class="login-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<n-icon :component="LockClosedIcon" />
|
||||
</template>
|
||||
</n-input>
|
||||
</n-form-item>
|
||||
|
||||
<div class="login-button-container">
|
||||
<n-button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
class="login-button"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登 录' }}
|
||||
</n-button>
|
||||
</div>
|
||||
</n-form>
|
||||
|
||||
<div class="login-footer">
|
||||
<n-text depth="3">© {{ new Date().getFullYear() }} 股票AI分析系统</n-text>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, h } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
NCard,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NButton,
|
||||
NIcon,
|
||||
NText,
|
||||
useMessage,
|
||||
useNotification
|
||||
} from 'naive-ui';
|
||||
import type { FormInst, FormRules } from 'naive-ui';
|
||||
import {
|
||||
BarChartOutline as BarChartIcon,
|
||||
LockClosedOutline as LockClosedIcon,
|
||||
NotificationsOutline as NotificationsIcon
|
||||
} from '@vicons/ionicons5';
|
||||
import { apiService } from '@/services/api';
|
||||
import type { LoginRequest } from '@/types';
|
||||
|
||||
const message = useMessage();
|
||||
const notification = useNotification();
|
||||
const router = useRouter();
|
||||
const formRef = ref<FormInst | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
const formValue = reactive({
|
||||
password: ''
|
||||
});
|
||||
|
||||
const rules: FormRules = {
|
||||
password: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入密码'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// 显示系统公告
|
||||
const showAnnouncement = (content: string) => {
|
||||
if (!content) return;
|
||||
|
||||
notification.info({
|
||||
title: '系统公告',
|
||||
content: () => h('div', { style: 'display: flex; align-items: center;' }, [
|
||||
h(NIcon, { component: NotificationsIcon, style: 'margin-right: 8px; font-size: 18px;' }),
|
||||
h('span', null, content)
|
||||
]),
|
||||
duration: 10000,
|
||||
keepAliveOnHover: true,
|
||||
closable: true
|
||||
});
|
||||
};
|
||||
|
||||
// 页面加载时检查是否已登录并获取系统公告
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// 获取系统配置
|
||||
const config = await apiService.getConfig();
|
||||
if (config.announcement) {
|
||||
showAnnouncement(config.announcement);
|
||||
}
|
||||
|
||||
// 不重复检查是否需要登录,因为路由守卫已经做了这个检查
|
||||
// 直接检查是否已登录
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
return; // 没有token,停留在登录页
|
||||
}
|
||||
|
||||
const isAuthenticated = await apiService.checkAuth();
|
||||
console.log('登录页面认证检查结果:', isAuthenticated);
|
||||
|
||||
if (isAuthenticated) {
|
||||
// 已登录,跳转到主页
|
||||
console.log('已登录,跳转到主页');
|
||||
router.push('/');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('认证检查或获取配置失败:', error);
|
||||
}
|
||||
});
|
||||
|
||||
const handleLogin = () => {
|
||||
formRef.value?.validate(async (errors) => {
|
||||
if (errors) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const loginRequest: LoginRequest = {
|
||||
password: formValue.password
|
||||
};
|
||||
|
||||
const response = await apiService.login(loginRequest);
|
||||
|
||||
if (response.access_token) {
|
||||
message.success('登录成功');
|
||||
// 登录成功后跳转到主页
|
||||
router.push('/');
|
||||
} else {
|
||||
message.error(response.message || '登录失败');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('登录失败:', error);
|
||||
message.error(error.message || '登录失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes float {
|
||||
0% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px) rotate(5deg);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes floatParticle {
|
||||
0% {
|
||||
transform: translateY(0) translateX(0);
|
||||
}
|
||||
25% {
|
||||
transform: translateY(-15px) translateX(15px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(0) translateX(30px);
|
||||
}
|
||||
75% {
|
||||
transform: translateY(15px) translateX(15px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0) translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-background {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-shape {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
animation: pulse 8s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.shape1 {
|
||||
width: 50vw;
|
||||
height: 50vw;
|
||||
max-width: 600px;
|
||||
max-height: 600px;
|
||||
background: linear-gradient(135deg, rgba(32, 128, 240, 0.2) 0%, rgba(32, 128, 240, 0.1) 100%);
|
||||
top: -15%;
|
||||
right: -10%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.shape2 {
|
||||
width: 60vw;
|
||||
height: 60vw;
|
||||
max-width: 800px;
|
||||
max-height: 800px;
|
||||
background: linear-gradient(135deg, rgba(32, 128, 240, 0.1) 0%, rgba(32, 128, 240, 0.05) 100%);
|
||||
bottom: -30%;
|
||||
left: -15%;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.shape3 {
|
||||
width: 30vw;
|
||||
height: 30vw;
|
||||
max-width: 400px;
|
||||
max-height: 400px;
|
||||
background: linear-gradient(135deg, rgba(32, 128, 240, 0.15) 0%, rgba(32, 128, 240, 0.05) 100%);
|
||||
top: 20%;
|
||||
right: 15%;
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
.shape4 {
|
||||
width: 25vw;
|
||||
height: 25vw;
|
||||
max-width: 300px;
|
||||
max-height: 300px;
|
||||
background: linear-gradient(135deg, rgba(32, 128, 240, 0.1) 0%, rgba(32, 128, 240, 0.05) 100%);
|
||||
top: 60%;
|
||||
left: 10%;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
.shape5 {
|
||||
width: 15vw;
|
||||
height: 15vw;
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
background: linear-gradient(135deg, rgba(32, 128, 240, 0.15) 0%, rgba(32, 128, 240, 0.1) 100%);
|
||||
top: 30%;
|
||||
left: 20%;
|
||||
animation-delay: 3s;
|
||||
}
|
||||
|
||||
.login-particle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(255, 255, 255, 0.6);
|
||||
animation: floatParticle 15s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.particle1 {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
top: 20%;
|
||||
left: 30%;
|
||||
animation-duration: 20s;
|
||||
}
|
||||
|
||||
.particle2 {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
top: 40%;
|
||||
left: 70%;
|
||||
animation-duration: 25s;
|
||||
}
|
||||
|
||||
.particle3 {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
top: 70%;
|
||||
left: 40%;
|
||||
animation-duration: 18s;
|
||||
}
|
||||
|
||||
.particle4 {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
top: 30%;
|
||||
left: 60%;
|
||||
animation-duration: 22s;
|
||||
}
|
||||
|
||||
.particle5 {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
top: 60%;
|
||||
left: 20%;
|
||||
animation-duration: 15s;
|
||||
}
|
||||
|
||||
.particle6 {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
top: 80%;
|
||||
left: 80%;
|
||||
animation-duration: 30s;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 420px;
|
||||
max-width: 90%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1;
|
||||
padding: 30px;
|
||||
animation: fadeIn 0.8s ease-out;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-card:hover {
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
animation: float 6s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0 0 8px;
|
||||
background: linear-gradient(90deg, #2080f0, #44a4ff);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
animation: fadeIn 0.8s ease-out 0.2s both;
|
||||
}
|
||||
|
||||
.login-input {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.login-input:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.login-button-container {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 20px;
|
||||
animation: fadeIn 0.8s ease-out 0.4s both;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 2px;
|
||||
transition: all 0.3s ease;
|
||||
background: linear-gradient(90deg, #2080f0, #44a4ff);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(32, 128, 240, 0.3);
|
||||
background: linear-gradient(90deg, #1c72d9, #3b9aff);
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
padding: 16px 0 0;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
margin-top: 20px;
|
||||
animation: fadeIn 0.8s ease-out 0.6s both;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
width: 90%;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
height: 44px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 移动设备上的背景形状调整 */
|
||||
.shape1 {
|
||||
width: 70vw;
|
||||
height: 70vw;
|
||||
top: -30%;
|
||||
right: -20%;
|
||||
}
|
||||
|
||||
.shape2 {
|
||||
width: 80vw;
|
||||
height: 80vw;
|
||||
bottom: -40%;
|
||||
left: -30%;
|
||||
}
|
||||
|
||||
.shape3 {
|
||||
width: 50vw;
|
||||
height: 50vw;
|
||||
top: 50%;
|
||||
right: -20%;
|
||||
}
|
||||
|
||||
.shape4, .shape5 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-particle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -13,9 +13,16 @@
|
||||
<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>
|
||||
<div class="market-status" :class="marketInfo.cnMarket.isOpen ? 'status-open' : 'status-closed'">
|
||||
<n-tag v-if="marketInfo.cnMarket.isOpen" type="success" size="medium" round>
|
||||
<template #icon><n-icon size="18"><pulse-icon /></n-icon></template>
|
||||
交易中
|
||||
</n-tag>
|
||||
<n-tag v-else type="default" size="medium" round>
|
||||
<template #icon><n-icon size="18"><time-icon /></n-icon></template>
|
||||
已休市
|
||||
</n-tag>
|
||||
</div>
|
||||
<p class="time-counter">{{ marketInfo.cnMarket.nextTime }}</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
@@ -24,9 +31,16 @@
|
||||
<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>
|
||||
<div class="market-status" :class="marketInfo.hkMarket.isOpen ? 'status-open' : 'status-closed'">
|
||||
<n-tag v-if="marketInfo.hkMarket.isOpen" type="success" size="medium" round>
|
||||
<template #icon><n-icon size="18"><pulse-icon /></n-icon></template>
|
||||
交易中
|
||||
</n-tag>
|
||||
<n-tag v-else type="default" size="medium" round>
|
||||
<template #icon><n-icon size="18"><time-icon /></n-icon></template>
|
||||
已休市
|
||||
</n-tag>
|
||||
</div>
|
||||
<p class="time-counter">{{ marketInfo.hkMarket.nextTime }}</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
@@ -35,9 +49,16 @@
|
||||
<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>
|
||||
<div class="market-status" :class="marketInfo.usMarket.isOpen ? 'status-open' : 'status-closed'">
|
||||
<n-tag v-if="marketInfo.usMarket.isOpen" type="success" size="medium" round>
|
||||
<template #icon><n-icon size="18"><pulse-icon /></n-icon></template>
|
||||
交易中
|
||||
</n-tag>
|
||||
<n-tag v-else type="default" size="medium" round>
|
||||
<template #icon><n-icon size="18"><time-icon /></n-icon></template>
|
||||
已休市
|
||||
</n-tag>
|
||||
</div>
|
||||
<p class="time-counter">{{ marketInfo.usMarket.nextTime }}</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
@@ -47,7 +68,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { NCard, NGrid, NGridItem } from 'naive-ui';
|
||||
import { NCard, NGrid, NGridItem, NTag, NIcon } from 'naive-ui';
|
||||
import {
|
||||
PulseOutline as PulseIcon,
|
||||
TimeOutline as TimeIcon
|
||||
} from '@vicons/ionicons5';
|
||||
import { updateMarketTimeInfo } from '@/utils';
|
||||
import type { MarketTimeInfo } from '@/types';
|
||||
|
||||
@@ -91,6 +116,7 @@ onBeforeUnmount(() => {
|
||||
<style scoped>
|
||||
.market-time-card {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.time-block {
|
||||
@@ -98,36 +124,65 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
font-size: 0.875rem;
|
||||
font-size: 1rem;
|
||||
color: var(--n-text-color-3);
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.current-time {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: bold;
|
||||
color: var(--n-text-color);
|
||||
}
|
||||
|
||||
.market-status {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.status-open {
|
||||
color: var(--n-success-color);
|
||||
.market-status :deep(.n-tag) {
|
||||
padding: 0 12px;
|
||||
height: 32px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.status-closed {
|
||||
color: var(--n-text-color-3);
|
||||
.market-status :deep(.n-tag__icon) {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.status-open :deep(.n-tag) {
|
||||
background-color: rgba(var(--success-color), 0.15);
|
||||
border: 1px solid var(--n-success-color);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-closed :deep(.n-tag) {
|
||||
background-color: rgba(var(--n-text-color-3), 0.1);
|
||||
}
|
||||
|
||||
.time-counter {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--n-text-color-3);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--success-color), 0.4);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px rgba(var(--success-color), 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--success-color), 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
<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 />
|
||||
@@ -24,9 +16,6 @@
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<n-card class="analysis-container">
|
||||
<template #header>
|
||||
<div class="card-title">股票批量分析</div>
|
||||
</template>
|
||||
|
||||
<n-grid :cols="24" :x-gap="16" :y-gap="16">
|
||||
<!-- 左侧配置区域 -->
|
||||
@@ -152,7 +141,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { ref, onMounted, onBeforeUnmount, h } from 'vue';
|
||||
import {
|
||||
NLayout,
|
||||
NLayoutContent,
|
||||
@@ -167,6 +156,7 @@ import {
|
||||
NButton,
|
||||
NEmpty,
|
||||
useMessage,
|
||||
useNotification,
|
||||
NSpace,
|
||||
NText,
|
||||
NDataTable,
|
||||
@@ -177,10 +167,10 @@ import { useClipboard } from '@vueuse/core'
|
||||
import {
|
||||
BarChartOutline as BarChartIcon,
|
||||
DocumentTextOutline as DocumentTextIcon,
|
||||
DownloadOutline as DownloadIcon
|
||||
DownloadOutline as DownloadIcon,
|
||||
NotificationsOutline as NotificationsIcon
|
||||
} from '@vicons/ionicons5';
|
||||
|
||||
import AnnouncementBanner from './AnnouncementBanner.vue';
|
||||
import MarketTimeDisplay from './MarketTimeDisplay.vue';
|
||||
import ApiConfigPanel from './ApiConfigPanel.vue';
|
||||
import StockSearch from './StockSearch.vue';
|
||||
@@ -189,14 +179,16 @@ import StockCard from './StockCard.vue';
|
||||
import { apiService } from '@/services/api';
|
||||
import type { StockInfo, ApiConfig, StreamInitMessage, StreamAnalysisUpdate } from '@/types';
|
||||
import { loadApiConfig } from '@/utils';
|
||||
import { validateMultipleStockCodes, MarketType } from '@/utils/stockValidator';
|
||||
|
||||
// 使用Naive UI的消息组件
|
||||
// 使用Naive UI的组件API
|
||||
const message = useMessage();
|
||||
const notification = useNotification();
|
||||
const { copy } = useClipboard();
|
||||
|
||||
// 从环境变量获取的默认配置
|
||||
const defaultApiUrl = ref('');
|
||||
const defaultApiModel = ref('gpt-3.5-turbo');
|
||||
const defaultApiModel = ref('');
|
||||
const defaultApiTimeout = ref('60');
|
||||
const announcement = ref('');
|
||||
|
||||
@@ -211,11 +203,27 @@ const displayMode = ref<'card' | 'table'>('card');
|
||||
const apiConfig = ref<ApiConfig>({
|
||||
apiUrl: '',
|
||||
apiKey: '',
|
||||
apiModel: 'gpt-3.5-turbo',
|
||||
apiModel: '',
|
||||
apiTimeout: '60',
|
||||
saveApiConfig: false
|
||||
});
|
||||
|
||||
// 显示系统公告
|
||||
const showAnnouncement = (content: string) => {
|
||||
if (!content) return;
|
||||
|
||||
notification.info({
|
||||
title: '系统公告',
|
||||
content: () => h('div', { style: 'display: flex; align-items: center;' }, [
|
||||
h(NIcon, { component: NotificationsIcon, style: 'margin-right: 8px; font-size: 18px;' }),
|
||||
h('span', null, content)
|
||||
]),
|
||||
duration: 0, // 设置为0表示不会自动关闭
|
||||
keepAliveOnHover: true,
|
||||
closable: true
|
||||
});
|
||||
};
|
||||
|
||||
// 市场选项
|
||||
const marketOptions = [
|
||||
{ label: 'A股', value: 'A' },
|
||||
@@ -255,12 +263,33 @@ const stockTableColumns = ref<DataTableColumns<StockInfo>>([
|
||||
return row.price !== undefined ? row.price.toFixed(2) : '--';
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '涨跌额',
|
||||
key: 'price_change',
|
||||
width: 100,
|
||||
render(row: StockInfo) {
|
||||
if (row.price_change === undefined) return '--';
|
||||
const sign = row.price_change > 0 ? '+' : '';
|
||||
return `${sign}${row.price_change.toFixed(2)}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '涨跌幅',
|
||||
key: 'changePercent',
|
||||
width: 100,
|
||||
render(row: StockInfo) {
|
||||
if (row.changePercent === undefined) return '--';
|
||||
if (row.changePercent === undefined) {
|
||||
// 如果没有changePercent但有price_change和price,尝试计算
|
||||
if (row.price_change !== undefined && row.price !== undefined) {
|
||||
const basePrice = row.price - row.price_change;
|
||||
if (basePrice !== 0) {
|
||||
const calculatedPercent = (row.price_change / basePrice) * 100;
|
||||
const sign = calculatedPercent > 0 ? '+' : '';
|
||||
return `${sign}${calculatedPercent.toFixed(2)}%`;
|
||||
}
|
||||
}
|
||||
return '--';
|
||||
}
|
||||
const sign = row.changePercent > 0 ? '+' : '';
|
||||
return `${sign}${row.changePercent.toFixed(2)}%`;
|
||||
}
|
||||
@@ -335,7 +364,9 @@ const stockTableColumns = ref<DataTableColumns<StockInfo>>([
|
||||
key: 'analysis',
|
||||
ellipsis: {
|
||||
tooltip: true
|
||||
}
|
||||
},
|
||||
width: 300,
|
||||
className: 'analysis-cell'
|
||||
}
|
||||
]);
|
||||
|
||||
@@ -393,14 +424,14 @@ function processStreamData(text: string) {
|
||||
message.success(`分析完成,共扫描 ${data.total_scanned} 只股票,符合条件 ${data.total_matched} 只`);
|
||||
|
||||
// 将所有分析中的股票状态更新为已完成
|
||||
analyzedStocks.value.forEach((stock, index) => {
|
||||
analyzedStocks.value = analyzedStocks.value.map(stock => {
|
||||
if (stock.analysisStatus === 'analyzing') {
|
||||
const updatedStock = {
|
||||
return {
|
||||
...stock,
|
||||
analysisStatus: 'completed' as const
|
||||
};
|
||||
analyzedStocks.value[index] = updatedStock;
|
||||
}
|
||||
return stock;
|
||||
});
|
||||
|
||||
isAnalyzing.value = false;
|
||||
@@ -438,6 +469,23 @@ function handleStreamUpdate(data: StreamAnalysisUpdate) {
|
||||
if (stockIndex >= 0) {
|
||||
const stock = { ...analyzedStocks.value[stockIndex] };
|
||||
|
||||
// 确保所有数值类型的字段都有默认值
|
||||
stock.price = data.price ?? stock.price ?? undefined;
|
||||
stock.price_change = data.price_change ?? stock.price_change ?? undefined;
|
||||
// 使用change_percent作为涨跌幅
|
||||
stock.changePercent = data.change_percent ?? stock.changePercent ?? undefined;
|
||||
stock.marketValue = data.market_value ?? stock.marketValue ?? undefined;
|
||||
stock.score = data.score ?? stock.score ?? undefined;
|
||||
stock.rsi = data.rsi ?? stock.rsi ?? undefined;
|
||||
|
||||
// 如果没有change_percent但有price_change和price,尝试计算changePercent
|
||||
if (stock.changePercent === undefined && stock.price_change !== undefined && stock.price !== undefined) {
|
||||
const basePrice = stock.price - stock.price_change;
|
||||
if (basePrice !== 0) {
|
||||
stock.changePercent = (stock.price_change / basePrice) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新分析状态
|
||||
if (data.status) {
|
||||
stock.analysisStatus = data.status;
|
||||
@@ -450,13 +498,7 @@ function handleStreamUpdate(data: StreamAnalysisUpdate) {
|
||||
|
||||
// 处理AI分析片段
|
||||
if (data.ai_analysis_chunk !== undefined) {
|
||||
// 如果之前没有分析内容,则初始化
|
||||
if (!stock.analysis) {
|
||||
stock.analysis = '';
|
||||
}
|
||||
// 追加新的分析片段
|
||||
stock.analysis += data.ai_analysis_chunk;
|
||||
// 确保分析状态为正在分析
|
||||
stock.analysis = (stock.analysis || '') + data.ai_analysis_chunk;
|
||||
stock.analysisStatus = 'analyzing';
|
||||
}
|
||||
|
||||
@@ -466,41 +508,15 @@ function handleStreamUpdate(data: StreamAnalysisUpdate) {
|
||||
stock.analysisStatus = '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;
|
||||
}
|
||||
|
||||
// 添加新字段的处理
|
||||
if (data.score !== undefined) {
|
||||
stock.score = data.score;
|
||||
}
|
||||
|
||||
if (data.recommendation !== undefined) {
|
||||
stock.recommendation = data.recommendation;
|
||||
}
|
||||
|
||||
if (data.price_change !== undefined) {
|
||||
stock.price_change = data.price_change;
|
||||
}
|
||||
|
||||
if (data.rsi !== undefined) {
|
||||
stock.rsi = data.rsi;
|
||||
}
|
||||
|
||||
// 添加技术指标字段的处理
|
||||
if (data.ma_trend !== undefined) {
|
||||
stock.ma_trend = data.ma_trend;
|
||||
}
|
||||
@@ -513,12 +529,11 @@ function handleStreamUpdate(data: StreamAnalysisUpdate) {
|
||||
stock.volume_status = data.volume_status;
|
||||
}
|
||||
|
||||
// 添加分析日期字段的处理
|
||||
if (data.analysis_date !== undefined) {
|
||||
stock.analysis_date = data.analysis_date;
|
||||
}
|
||||
|
||||
// 更新数组中的股票信息
|
||||
// 使用Vue的响应式API更新数组
|
||||
analyzedStocks.value[stockIndex] = stock;
|
||||
}
|
||||
}
|
||||
@@ -530,9 +545,6 @@ async function analyzeStocks() {
|
||||
return;
|
||||
}
|
||||
|
||||
isAnalyzing.value = true;
|
||||
analyzedStocks.value = [];
|
||||
|
||||
// 解析股票代码
|
||||
const codes = stockCodes.value
|
||||
.split(/[,\s\n]+/)
|
||||
@@ -541,14 +553,38 @@ async function analyzeStocks() {
|
||||
|
||||
if (codes.length === 0) {
|
||||
message.warning('未找到有效的股票代码');
|
||||
isAnalyzing.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 去除重复的股票代码
|
||||
const uniqueCodes = Array.from(new Set(codes));
|
||||
|
||||
// 检查是否有重复代码被移除
|
||||
if (uniqueCodes.length < codes.length) {
|
||||
message.info(`已自动去除${codes.length - uniqueCodes.length}个重复的股票代码`);
|
||||
}
|
||||
|
||||
// 在前端验证股票代码
|
||||
const marketTypeEnum = marketType.value as keyof typeof MarketType;
|
||||
const invalidCodes = validateMultipleStockCodes(
|
||||
uniqueCodes,
|
||||
MarketType[marketTypeEnum]
|
||||
);
|
||||
|
||||
// 如果有无效代码,显示错误信息并返回
|
||||
if (invalidCodes.length > 0) {
|
||||
const errorMessages = invalidCodes.map(item => item.errorMessage).join('\n');
|
||||
message.error(`股票代码验证失败:${errorMessages}`);
|
||||
return;
|
||||
}
|
||||
|
||||
isAnalyzing.value = true;
|
||||
analyzedStocks.value = [];
|
||||
|
||||
try {
|
||||
// 构建请求参数
|
||||
const requestData = {
|
||||
stock_codes: codes,
|
||||
stock_codes: uniqueCodes,
|
||||
market_type: marketType.value
|
||||
} as any;
|
||||
|
||||
@@ -579,7 +615,10 @@ async function analyzeStocks() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error ${response.status}`);
|
||||
if (response.status === 404) {
|
||||
throw new Error('服务器接口未找到,请检查服务是否正常运行');
|
||||
}
|
||||
throw new Error(`服务器响应错误: ${response.status}`);
|
||||
}
|
||||
|
||||
// 处理流式响应
|
||||
@@ -608,22 +647,39 @@ async function analyzeStocks() {
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
processStreamData(line);
|
||||
try {
|
||||
processStreamData(line);
|
||||
} catch (e: Error | unknown) {
|
||||
console.error('处理数据流时出错:', e);
|
||||
message.error(`处理数据时出错: ${e instanceof Error ? e.message : '未知错误'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理最后可能剩余的数据
|
||||
if (buffer.trim()) {
|
||||
processStreamData(buffer);
|
||||
try {
|
||||
processStreamData(buffer);
|
||||
} catch (e: Error | unknown) {
|
||||
console.error('处理最后的数据块时出错:', e);
|
||||
message.error(`处理数据时出错: ${e instanceof Error ? e.message : '未知错误'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 注意:不再需要在这里更新状态,因为已经在processStreamData中处理了scan_completed消息
|
||||
|
||||
message.success('分析完成');
|
||||
} catch (error: any) {
|
||||
message.error(`分析出错: ${error.message || '未知错误'}`);
|
||||
let errorMessage = '分析出错: ';
|
||||
if (error.message.includes('404')) {
|
||||
errorMessage += '服务器接口未找到,请确保后端服务正常运行';
|
||||
} else {
|
||||
errorMessage += error.message || '未知错误';
|
||||
}
|
||||
message.error(errorMessage);
|
||||
console.error('分析股票时出错:', error);
|
||||
|
||||
// 清空分析状态
|
||||
analyzedStocks.value = [];
|
||||
} finally {
|
||||
isAnalyzing.value = false;
|
||||
}
|
||||
@@ -673,7 +729,7 @@ async function copyAnalysisResults() {
|
||||
|
||||
if (stock.price_change !== undefined) {
|
||||
const sign = stock.price_change > 0 ? '+' : '';
|
||||
result += `价格变动: ${sign}${stock.price_change.toFixed(2)}\n`;
|
||||
result += `涨跌额: ${sign}${stock.price_change.toFixed(2)}\n`;
|
||||
}
|
||||
|
||||
if (stock.ma_trend) {
|
||||
@@ -867,6 +923,8 @@ onMounted(async () => {
|
||||
|
||||
if (config.announcement) {
|
||||
announcement.value = config.announcement;
|
||||
// 使用通知显示公告
|
||||
showAnnouncement(config.announcement);
|
||||
}
|
||||
|
||||
// 初始化后恢复本地保存的配置
|
||||
@@ -880,16 +938,24 @@ onMounted(async () => {
|
||||
<style scoped>
|
||||
.app-container {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.main-layout {
|
||||
background-color: #f6f6f6;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@@ -922,8 +988,9 @@ onMounted(async () => {
|
||||
|
||||
.n-data-table .analysis-cell {
|
||||
max-width: 300px;
|
||||
white-space: nowrap;
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,57 @@
|
||||
<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>
|
||||
<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': calculatedChangePercent && calculatedChangePercent > 0,
|
||||
'down': calculatedChangePercent && calculatedChangePercent < 0
|
||||
}">
|
||||
涨跌幅: {{ formatChangePercent(calculatedChangePercent) }}
|
||||
<div class="header-main">
|
||||
<div class="header-left">
|
||||
<div class="stock-info">
|
||||
<div class="stock-code">{{ stock.code }}</div>
|
||||
<div class="stock-name" v-if="stock.name">{{ stock.name }}</div>
|
||||
</div>
|
||||
<div class="stock-price-info" v-if="stock.price !== undefined">
|
||||
<div class="stock-price">
|
||||
<span class="label">当前价格:</span>
|
||||
<span class="value">{{ stock.price.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="stock-change" :class="{
|
||||
'up': calculatedChangePercent && calculatedChangePercent > 0,
|
||||
'down': calculatedChangePercent && calculatedChangePercent < 0
|
||||
}">
|
||||
<span class="label">涨跌幅:</span>
|
||||
<span class="value">{{ formatChangePercent(calculatedChangePercent) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<n-button
|
||||
size="small"
|
||||
v-if="stock.analysisStatus === 'completed'"
|
||||
@click="copyStockAnalysis"
|
||||
class="copy-button"
|
||||
type="primary"
|
||||
secondary
|
||||
round
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon><CopyOutline /></n-icon>
|
||||
</template>
|
||||
复制结果
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="analysis-status" v-if="stock.analysisStatus !== 'completed'">
|
||||
<n-tag
|
||||
:type="getStatusType"
|
||||
size="small"
|
||||
round
|
||||
:bordered="false"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<component :is="getStatusIcon" />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ getStatusText }}
|
||||
</n-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,7 +89,7 @@
|
||||
'up': stock.price_change > 0,
|
||||
'down': stock.price_change < 0
|
||||
}">{{ formatPriceChange(stock.price_change) }}</div>
|
||||
<div class="indicator-label">价格变动</div>
|
||||
<div class="indicator-label">涨跌额</div>
|
||||
</div>
|
||||
|
||||
<div class="indicator-item" v-if="stock.ma_trend">
|
||||
@@ -78,28 +118,17 @@
|
||||
<n-divider />
|
||||
|
||||
<div class="card-content">
|
||||
<template v-if="stock.analysisStatus === 'waiting'">
|
||||
<div class="waiting-status">
|
||||
<n-spin size="small" />
|
||||
<span>等待分析...</span>
|
||||
<template v-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 === 'analyzing'">
|
||||
<div class="analyzing-status">
|
||||
<n-spin size="small" />
|
||||
<span>正在分析...</span>
|
||||
</div>
|
||||
<div class="analysis-result analysis-streaming" v-if="parsedAnalysis" v-html="parsedAnalysis" :key="analysisContentKey"></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 analysis-completed" v-html="parsedAnalysis"></div>
|
||||
</template>
|
||||
@@ -110,10 +139,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, ref } from 'vue';
|
||||
import { NCard, NDivider, NSpin, NIcon, NTag } from 'naive-ui';
|
||||
import { NCard, NDivider, NSpin, NIcon, NTag, NButton, useMessage } from 'naive-ui';
|
||||
import {
|
||||
AlertCircleOutline as AlertCircleIcon,
|
||||
CalendarOutline
|
||||
CalendarOutline,
|
||||
CopyOutline,
|
||||
HourglassOutline,
|
||||
ReloadOutline
|
||||
} from '@vicons/ionicons5';
|
||||
import { parseMarkdown, formatMarketValue as formatMarketValueFn } from '@/utils';
|
||||
import type { StockInfo } from '@/types';
|
||||
@@ -214,22 +246,24 @@ function formatChangePercent(percent: number | undefined): string {
|
||||
return `${sign}${percent.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function formatPriceChange(change: number): string {
|
||||
function formatPriceChange(change: number | undefined | null): string {
|
||||
if (change === undefined || change === null) return '--';
|
||||
const sign = change > 0 ? '+' : '';
|
||||
return `${sign}${change.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatMarketValue(value: number): string {
|
||||
function formatMarketValue(value: number | undefined | null): string {
|
||||
if (value === undefined || value === null) return '--';
|
||||
return formatMarketValueFn(value);
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
function formatDate(dateStr: string | undefined | null): string {
|
||||
if (!dateStr) return '--';
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) {
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
return date.toISOString().split('T')[0];
|
||||
} catch (e) {
|
||||
return dateStr;
|
||||
@@ -308,6 +342,105 @@ function getChineseVolumeStatus(status: string): string {
|
||||
|
||||
return statusMap[status] || status;
|
||||
}
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
// 添加复制功能
|
||||
async function copyStockAnalysis() {
|
||||
if (!props.stock.analysis) {
|
||||
message.warning('暂无分析结果可复制');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let result = `【${props.stock.code} ${props.stock.name || ''}】\n`;
|
||||
|
||||
// 添加分析日期
|
||||
if (props.stock.analysis_date) {
|
||||
result += `分析日期: ${formatDate(props.stock.analysis_date)}\n`;
|
||||
}
|
||||
|
||||
// 添加评分和推荐信息
|
||||
if (props.stock.score !== undefined) {
|
||||
result += `评分: ${props.stock.score}\n`;
|
||||
}
|
||||
|
||||
if (props.stock.recommendation) {
|
||||
result += `推荐: ${props.stock.recommendation}\n`;
|
||||
}
|
||||
|
||||
// 添加技术指标信息
|
||||
if (props.stock.rsi !== undefined) {
|
||||
result += `RSI: ${props.stock.rsi.toFixed(2)}\n`;
|
||||
}
|
||||
|
||||
if (props.stock.price_change !== undefined) {
|
||||
const sign = props.stock.price_change > 0 ? '+' : '';
|
||||
result += `涨跌额: ${sign}${props.stock.price_change.toFixed(2)}\n`;
|
||||
}
|
||||
|
||||
if (props.stock.ma_trend) {
|
||||
result += `均线趋势: ${getChineseTrend(props.stock.ma_trend)}\n`;
|
||||
}
|
||||
|
||||
if (props.stock.macd_signal) {
|
||||
result += `MACD信号: ${getChineseSignal(props.stock.macd_signal)}\n`;
|
||||
}
|
||||
|
||||
if (props.stock.volume_status) {
|
||||
result += `成交量: ${getChineseVolumeStatus(props.stock.volume_status)}\n`;
|
||||
}
|
||||
|
||||
// 添加分析结果
|
||||
result += `\n${props.stock.analysis}\n`;
|
||||
|
||||
await navigator.clipboard.writeText(result);
|
||||
message.success('已复制分析结果到剪贴板');
|
||||
} catch (error) {
|
||||
message.error('复制失败,请手动复制');
|
||||
console.error('复制分析结果时出错:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加状态相关的计算属性
|
||||
const getStatusType = computed(() => {
|
||||
switch (props.stock.analysisStatus) {
|
||||
case 'waiting':
|
||||
return 'default';
|
||||
case 'analyzing':
|
||||
return 'info';
|
||||
case 'error':
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
});
|
||||
|
||||
const getStatusIcon = computed(() => {
|
||||
switch (props.stock.analysisStatus) {
|
||||
case 'waiting':
|
||||
return HourglassOutline;
|
||||
case 'analyzing':
|
||||
return ReloadOutline;
|
||||
case 'error':
|
||||
return AlertCircleIcon;
|
||||
default:
|
||||
return HourglassOutline;
|
||||
}
|
||||
});
|
||||
|
||||
const getStatusText = computed(() => {
|
||||
switch (props.stock.analysisStatus) {
|
||||
case 'waiting':
|
||||
return '等待分析';
|
||||
case 'analyzing':
|
||||
return '正在分析';
|
||||
case 'error':
|
||||
return '分析出错';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -324,42 +457,167 @@ function getChineseVolumeStatus(status: string): string {
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px 8px 8px;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.09);
|
||||
position: relative;
|
||||
background: linear-gradient(to bottom, rgba(240, 240, 245, 0.3), transparent);
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stock-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.stock-code {
|
||||
font-size: 1.125rem;
|
||||
font-weight: bold;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--n-text-color);
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.stock-name {
|
||||
font-size: 0.875rem;
|
||||
color: var(--n-text-color-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.stock-price-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px dashed rgba(0, 0, 0, 0.09);
|
||||
}
|
||||
|
||||
.stock-price {
|
||||
.stock-price, .stock-change {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.stock-price .label,
|
||||
.stock-change .label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--n-text-color-3);
|
||||
}
|
||||
|
||||
.stock-price .value {
|
||||
font-size: 1.125rem;
|
||||
font-weight: bold;
|
||||
font-weight: 600;
|
||||
color: var(--n-text-color);
|
||||
}
|
||||
|
||||
.stock-change {
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
.stock-change .value {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.up .value {
|
||||
color: var(--n-error-color);
|
||||
background-color: rgba(208, 48, 80, 0.08);
|
||||
}
|
||||
|
||||
.down .value {
|
||||
color: var(--n-success-color);
|
||||
background-color: rgba(24, 160, 88, 0.08);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.copy-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.analysis-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.analysis-status :deep(.n-tag) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 10px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.analysis-status :deep(.n-tag .n-icon) {
|
||||
margin-right: 4px;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.copy-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.analysis-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.analysis-status :deep(.n-tag) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.analysis-status :deep(.n-tag .n-icon) {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.up .value {
|
||||
color: var(--n-error-color);
|
||||
}
|
||||
|
||||
.down .value {
|
||||
color: var(--n-success-color);
|
||||
}
|
||||
|
||||
.stock-summary {
|
||||
@@ -509,15 +767,16 @@ function getChineseVolumeStatus(status: string): string {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.waiting-status,
|
||||
.analyzing-status,
|
||||
.error-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--n-text-color-3);
|
||||
color: var(--n-error-color);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
margin: 0.75rem 1rem;
|
||||
padding: 0.5rem;
|
||||
background-color: rgba(208, 48, 80, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
@@ -536,6 +795,9 @@ function getChineseVolumeStatus(status: string): string {
|
||||
overflow-y: auto;
|
||||
word-break: break-word;
|
||||
hyphens: auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
scrollbar-width: thin; /* Firefox */
|
||||
@@ -660,6 +922,8 @@ function getChineseVolumeStatus(status: string): string {
|
||||
overflow-x: auto;
|
||||
margin: 0.75rem 0;
|
||||
border-left: 3px solid #2080f0;
|
||||
max-width: 100%;
|
||||
white-space: pre-wrap; /* 允许代码块自动换行 */
|
||||
}
|
||||
|
||||
.analysis-result :deep(pre code) {
|
||||
@@ -684,20 +948,23 @@ function getChineseVolumeStatus(status: string): string {
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
table-layout: fixed; /* 固定表格布局 */
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.analysis-result :deep(th), .analysis-result :deep(td) {
|
||||
padding: 0.6rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.analysis-result :deep(th) {
|
||||
background-color: rgba(32, 128, 240, 0.1);
|
||||
color: #2080f0;
|
||||
font-weight: 600;
|
||||
padding: 0.6rem;
|
||||
text-align: left;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.analysis-result :deep(td) {
|
||||
padding: 0.6rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.analysis-result :deep(tr:nth-child(even)) {
|
||||
@@ -760,4 +1027,14 @@ function getChineseVolumeStatus(status: string): string {
|
||||
color: #36ad6a;
|
||||
border-bottom: 1px solid #36ad6a;
|
||||
}
|
||||
|
||||
/* 优化图片样式 */
|
||||
.analysis-result :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 0.75rem auto;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -68,7 +68,7 @@ const searchKeyword = ref('');
|
||||
const results = ref<SearchResult[]>([]);
|
||||
const loading = ref(false);
|
||||
const showResults = ref(false);
|
||||
const searchInputRef = ref<HTMLElement | null>(null);
|
||||
const searchInputRef = ref<any>(null);
|
||||
|
||||
// 创建防抖搜索函数
|
||||
const debouncedSearch = debounce(async (keyword: string) => {
|
||||
@@ -83,7 +83,9 @@ const debouncedSearch = debounce(async (keyword: string) => {
|
||||
try {
|
||||
if (props.marketType === 'US') {
|
||||
// 美股搜索
|
||||
results.value = await apiService.searchUsStocks(keyword);
|
||||
const searchResults = await apiService.searchUsStocks(keyword);
|
||||
// 限制只显示前10个结果
|
||||
results.value = searchResults.slice(0, 10);
|
||||
} else {
|
||||
// 其他市场搜索 (后端需要实现对应的接口)
|
||||
results.value = [];
|
||||
@@ -128,7 +130,7 @@ function formatMarketValue(value: number): string {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
searchInputRef.value &&
|
||||
!searchInputRef.value.contains(event.target as Node)
|
||||
!searchInputRef.value.$el.contains(event.target as Node)
|
||||
) {
|
||||
showResults.value = false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user