feat: 优化前端显示&修复若干bug

This commit is contained in:
CaasianVale
2025-03-07 03:33:18 +08:00
parent ff5b820a57
commit 4c115cf325
29 changed files with 3726 additions and 1209 deletions

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}