Compare commits

...

10 Commits

Author SHA1 Message Date
史悦
4c6e03a59f 更新代码并修改远程仓库地址 2025-09-02 13:53:24 +08:00
兰志宏
9471480c89 Merge from Cassianvale: feat: 适配手机端 2025-03-11 18:09:37 +08:00
兰志宏
399207c4ce fix: ts build error 2025-03-11 17:42:54 +08:00
兰志宏
b157fbaab7 fix: 基金搜索、 LOF 基金查询、 搜索市值显示 2025-03-11 17:22:19 +08:00
Cassianvale
64deb4dfa0 feat: 适配手机端 2025-03-11 17:17:44 +08:00
Cassianvale
d22f39bae2 feat: 时间卡片适配移动端 2025-03-11 14:00:58 +08:00
Cassianvale
f86f004181 chore: 清理未使用变量 2025-03-11 13:09:51 +08:00
Cassianvale
a4a93973d2 feat: 优化手机端显示效果 2025-03-11 13:05:42 +08:00
Cassianvale
c6165c2587 Create test_akshare.py 2025-03-11 10:13:39 +08:00
Cassianvale
0ea9acfbb7 chore: 清理未使用变量 2025-03-11 10:13:32 +08:00
32 changed files with 2616 additions and 207 deletions

View File

@@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(docker build:*)",
"Bash(npm install)",
"Bash(mkdir:*)",
"Bash(docker run:*)",
"Bash(docker logs:*)",
"Bash(npm run build:*)",
"Bash(docker stop:*)",
"Bash(docker rm:*)",
"Bash(docker tag:*)",
"Bash(docker push:*)",
"Bash(docker login:*)"
],
"deny": []
}
}

2
.gitignore vendored
View File

@@ -21,6 +21,7 @@ build_upload.log
*.spec *.spec
*.zip *.zip
.env .env
.env.*
# frontend # frontend
node_modules/ node_modules/
@@ -38,6 +39,7 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.vite
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/

View File

@@ -1,5 +1,5 @@
# 阶段一: 构建Vue前端 # 阶段一: 构建Vue前端
FROM node:18-alpine as frontend-builder FROM node:18-alpine AS frontend-builder
# 设置工作目录 # 设置工作目录
WORKDIR /app/frontend WORKDIR /app/frontend
@@ -13,18 +13,21 @@ RUN npm ci
# 复制前端源代码 # 复制前端源代码
COPY frontend/ ./ COPY frontend/ ./
# 确保node_modules中的可执行文件有正确权限
RUN chmod +x node_modules/.bin/*
# 构建前端应用 # 构建前端应用
RUN npm run build RUN npm run build
# 阶段二: 构建Python后端 # 阶段二: 构建Python后端
FROM python:3.10-slim as backend-builder FROM python:3.10-slim AS backend-builder
# 设置工作目录 # 设置工作目录
WORKDIR /app WORKDIR /app
# 安装系统依赖和构建依赖 # 安装系统依赖和构建依赖
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
libgl1-mesa-glx \ libgl1 \
ca-certificates \ ca-certificates \
build-essential \ build-essential \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -43,7 +46,7 @@ WORKDIR /app
# 安装运行时依赖 # 安装运行时依赖
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
libgl1-mesa-glx \ libgl1 \
ca-certificates \ ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

29
Dockerfile.backend Normal file
View File

@@ -0,0 +1,29 @@
# 简化版本:只构建后端,不包含前端
FROM python:3.10-slim
# 设置工作目录
WORKDIR /app
# 复制requirements.txt
COPY requirements.txt .
# 安装Python依赖
RUN pip install --no-cache-dir -r requirements.txt
# 复制后端代码
COPY *.py ./
COPY services/ ./services/
COPY utils/ ./utils/
# 创建数据目录和日志目录
RUN mkdir -p data logs
# 暴露端口
EXPOSE 8888
# 设置环境变量
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
# 启动应用
CMD ["python", "web_server.py"]

32
Dockerfile.full Normal file
View File

@@ -0,0 +1,32 @@
# 完整版本:包含前端和后端
FROM python:3.10-slim
# 设置工作目录
WORKDIR /app
# 复制requirements.txt
COPY requirements.txt .
# 安装Python依赖
RUN pip install --no-cache-dir -r requirements.txt
# 复制后端代码
COPY *.py ./
COPY services/ ./services/
COPY utils/ ./utils/
# 复制前端构建产物
COPY frontend/dist/ ./frontend/dist/
# 创建数据目录和日志目录
RUN mkdir -p data logs
# 暴露端口
EXPOSE 8888
# 设置环境变量
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
# 启动应用
CMD ["python", "web_server.py"]

27
docker-compose.local.yml Normal file
View File

@@ -0,0 +1,27 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: stock-scanner-app-local
ports:
- "8999:8888"
environment:
- API_KEY=${API_KEY}
- API_URL=${API_URL}
- API_MODEL=${API_MODEL}
- API_TIMEOUT=${API_TIMEOUT}
- LOGIN_PASSWORD=${LOGIN_PASSWORD}
- ANNOUNCEMENT_TEXT=${ANNOUNCEMENT_TEXT}
volumes:
- ./logs:/app/logs
- ./data:/app/data
restart: unless-stopped
networks:
- stock-scanner-network
networks:
stock-scanner-network:
driver: bridge

View File

@@ -7,6 +7,7 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
platform: linux/amd64
container_name: stock-scanner-app container_name: stock-scanner-app
ports: ports:
- "8888:8888" - "8888:8888"

View File

@@ -0,0 +1,3 @@
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
</head>

View File

@@ -0,0 +1,551 @@
/* 移动端通用样式优化 */
/* 标准断点变量 -- 通过CSS变量实现统一断点管理 */
:root {
--mobile-xs-breakpoint: 480px; /* 小型手机设备 */
--mobile-sm-breakpoint: 576px; /* 普通手机设备 */
--mobile-md-breakpoint: 768px; /* 平板和大型手机 */
}
/* ===== 基础移动端组件 ===== */
/* 增大触摸目标区域 */
.mobile-touch-target {
min-height: 44px; /* 推荐的最小触摸目标尺寸 */
min-width: 44px;
}
/* 优化触摸反馈效果 */
.mobile-touch-feedback {
transition: all 0.2s ease-in-out;
}
.mobile-touch-feedback:active {
transform: scale(0.96);
opacity: 0.8;
}
/* 移动端表单元素优化 */
.mobile-input {
font-size: 16px !important; /* 防止iOS自动缩放 */
line-height: 1.2;
padding: 12px !important;
}
.mobile-select {
height: 44px !important;
}
/* 响应式容器 */
.mobile-container {
width: 100%;
padding: 0 16px;
box-sizing: border-box;
}
/* ===== 移动端布局类 ===== */
/* 全宽容器 */
.mobile-full-width {
width: 100% !important;
max-width: 100% !important;
margin-left: 0 !important;
margin-right: 0 !important;
box-sizing: border-box !important;
}
/* 移动端内容容器 */
.mobile-content-container {
padding: 0.75rem !important;
width: 100% !important;
box-sizing: border-box !important;
}
/* 底部操作区固定 */
.mobile-action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 8px 16px;
background-color: var(--n-color);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
z-index: 100;
display: flex;
justify-content: center;
gap: 8px;
}
.mobile-action-bar-spacer {
height: 60px; /* 预留底部空间 */
}
/* 底部背景延伸 */
.mobile-bottom-extend {
position: relative;
padding-bottom: env(safe-area-inset-bottom, 0);
margin-bottom: -1px; /* 防止底部出现缝隙 */
}
/* ===== 移动端UI元素 ===== */
/* 移动端友好的卡片样式 */
.mobile-card {
border-radius: 12px !important;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08) !important;
}
/* 移动端卡片边距优化 */
.mobile-card-spacing {
margin: 0.5rem 0 !important;
border-radius: 0.75rem !important;
}
/* 移动端阴影优化 - 更轻微的阴影效果 */
.mobile-shadow {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08) !important;
}
/* 移动端列表样式优化 */
.mobile-list-item {
padding: 12px !important;
}
/* ===== 特殊效果类 ===== */
/* 边框优化 */
.mobile-border-fix {
border-width: 1px !important;
border-style: solid;
box-sizing: border-box;
}
/* 确保右侧边框在移动端正确显示 */
.mobile-right-border {
position: relative;
}
.mobile-right-border::after {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 1px;
background-color: var(--n-border-color, rgba(0, 0, 0, 0.1));
pointer-events: none;
}
/* 可滑动区域提示 */
.mobile-scrollable-hint {
position: relative;
}
.mobile-scrollable-hint::after {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 24px;
background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.8));
pointer-events: none;
}
/* 表格横向滚动指示器 */
.mobile-table-scroll-indicator {
position: relative;
}
.mobile-table-scroll-indicator::after {
content: '←→';
position: absolute;
bottom: 10px;
right: 10px;
color: rgba(32, 128, 240, 0.6);
font-size: 14px;
pointer-events: none;
z-index: 2;
animation: fadeInOut 2s infinite;
}
@keyframes fadeInOut {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}
/* ===== 响应式文本 ===== */
/* 自适应字体大小 */
@media (max-width: 480px) {
.mobile-adaptive-text {
font-size: 14px;
}
.mobile-adaptive-heading {
font-size: 18px;
}
}
/* ===== 网格系统优化 ===== */
/* 网格布局基础类 */
.mobile-grid {
width: 100% !important;
max-width: 100% !important;
}
.mobile-grid-item {
width: 100% !important;
max-width: 100% !important;
padding: 0 !important;
}
/* ===== API配置面板专用类 ===== */
.mobile-connection-status {
padding: 0.75rem !important;
border-radius: 0.5rem !important;
margin-top: 0.75rem !important;
}
.mobile-api-config-section {
margin-bottom: 1.5rem !important;
width: 100% !important;
}
.mobile-api-config-card {
padding: 0.5rem !important;
border-radius: 0.625rem !important;
margin-bottom: 1rem !important;
}
.mobile-api-actions {
display: flex;
width: 100%;
}
.mobile-api-buttons {
display: flex;
gap: 8px;
}
.mobile-api-info-alert {
padding: 0.75rem !important;
margin-bottom: 0.75rem !important;
border-radius: 0.5rem !important;
}
.mobile-model-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.mobile-toggle-text {
font-size: 0.875rem;
}
.mobile-api-save-option {
display: flex;
align-items: center;
width: 100%;
}
.mobile-api-button {
height: 36px;
min-width: 40%;
display: flex;
align-items: center;
justify-content: center;
}
.mobile-toggle-button {
font-weight: 500;
transition: all 0.3s ease;
border-radius: 16px;
padding: 4px 12px;
}
/* ===== StockCard专用类 ===== */
.mobile-stock-card {
width: 100% !important;
box-sizing: border-box !important;
border-radius: 0.75rem !important;
overflow: hidden !important;
margin-bottom: 0.75rem;
}
.mobile-card-header {
padding: 0.75rem !important;
border-bottom: 1px solid rgba(0, 0, 0, 0.05) !important;
}
.mobile-card-content {
padding: 0.75rem !important;
}
/* ===== StockSearch专用类 ===== */
.mobile-search-results {
max-width: 100%;
width: 100%;
border-radius: 0.75rem;
border: 1px solid var(--n-border-color, rgba(0, 0, 0, 0.1));
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.mobile-search-result-item {
padding: 0.625rem 0.875rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
min-height: 44px; /* 确保触摸区域足够大 */
}
.mobile-result-name {
max-width: 170px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 小屏幕适配 */
@media (max-width: 480px) {
.mobile-result-name {
max-width: 120px;
}
.mobile-search-result-item {
padding: 0.5rem 0.75rem;
}
.mobile-search-results {
border-radius: 0.625rem;
border: 1px solid rgba(0, 0, 0, 0.08);
}
}
/* ===== MarketTimeDisplay专用类 ===== */
.mobile-market-time-card {
padding: 0.5rem;
margin-bottom: 1rem;
min-height: 180px; /* 移动端下的最小高度 */
}
.mobile-time-block {
padding: 0.625rem;
margin-bottom: 0.75rem; /* 增加底部外边距 */
}
.mobile-current-time {
font-size: 1.5rem;
}
.mobile-time-label {
font-size: 0.9375rem;
margin-bottom: 0.5rem;
}
.mobile-status-tag {
min-width: 100px; /* 减小移动端下的最小宽度 */
height: 36px !important;
font-size: 0.875rem; /* 减小字体大小 */
}
.mobile-time-counter {
font-size: 0.75rem; /* 减小字体大小 */
margin-top: 0.375rem;
}
/* 小屏幕特殊适配 */
@media (max-width: 480px) {
.mobile-market-time-card {
padding: 0.375rem;
min-height: 160px; /* 小屏幕下的最小高度 */
}
.mobile-time-block {
padding: 0.5rem;
margin-bottom: 1rem; /* 增加小屏幕下的底部外边距 */
}
.mobile-current-time {
font-size: 1.25rem;
}
.mobile-time-label {
font-size: 0.875rem;
}
.mobile-time-counter {
font-size: 0.75rem;
}
.mobile-status-tag {
min-width: 90px; /* 进一步减小最小宽度 */
font-size: 0.8125rem;
padding: 0 12px !important; /* 减小内边距 */
}
}
/* ===== 媒体查询部分 ===== */
/* 平板和大型手机 - 768px以下 */
@media (max-width: 768px) {
.mobile-content-container {
padding: 0.75rem !important;
}
.mobile-api-actions {
flex-direction: column !important;
align-items: flex-start !important;
}
.mobile-api-buttons {
width: 100% !important;
justify-content: space-between !important;
margin-top: 0.75rem !important;
}
.mobile-api-config-card {
padding: 0.75rem !important;
width: 100% !important;
box-sizing: border-box !important;
border-radius: 0.75rem !important;
overflow: hidden !important;
border: 1px solid rgba(0, 0, 0, 0.08) !important;
}
.mobile-url-feedback {
flex-direction: column !important;
width: 100% !important;
}
/* 网格优化 */
:deep(.n-grid) {
gap: 12px !important;
margin-bottom: 12px !important;
}
:deep(.n-grid-item) {
margin-bottom: 12px !important;
}
}
/* 普通手机设备 - 480px以下 */
@media (max-width: 480px) {
.mobile-content-container {
padding: 0.5rem !important;
}
.mobile-api-config-section {
padding-bottom: 15px !important;
}
.mobile-api-config-card {
padding: 0.5rem !important;
min-height: 80px !important;
}
/* 小屏幕网格优化 */
.mobile-grid-small {
width: 100% !important;
max-width: 100% !important;
gap: 8px !important;
}
.mobile-grid-item-small {
padding: 0 !important;
margin-bottom: 8px !important;
}
:deep(.n-grid) {
gap: 6px !important;
}
:deep(.n-grid-item) {
padding: 0 !important;
margin-bottom: 6px !important;
}
:deep(.n-grid-item) > * {
margin-bottom: 6px !important;
}
.mobile-form-item {
margin-bottom: 8px !important;
}
.mobile-api-buttons-small {
flex-wrap: wrap !important;
gap: 0.5rem !important;
}
.mobile-api-button {
flex: 1 !important;
}
.mobile-toggle-button {
width: 100% !important;
}
.mobile-api-info-alert-small {
padding: 0.5rem !important;
margin-bottom: 0.5rem !important;
font-size: 0.75rem !important;
}
.mobile-model-tag {
font-size: 0.75rem !important;
padding: 0 0.5rem !important;
}
}
/* ===== AnnouncementBanner专用类 ===== */
.mobile-announcement-container {
max-width: 100%;
margin-left: 0.5rem;
margin-right: 0.5rem;
}
.mobile-announcement-header {
font-size: 0.9375rem;
}
.mobile-announcement-content {
font-size: 0.875rem;
}
.mobile-announcement-timer {
font-size: 0.75rem;
}
.mobile-login-announcement {
top: 0.75rem;
right: 0.75rem;
left: 0.75rem;
}
/* 小屏幕适配 */
@media (max-width: 480px) {
.mobile-announcement-container {
top: 0.25rem;
right: 0.25rem;
left: 0.25rem;
max-width: calc(100% - 0.5rem);
}
.mobile-announcement-header {
font-size: 0.875rem;
}
.mobile-announcement-content {
font-size: 0.8125rem;
}
.mobile-announcement-timer {
font-size: 0.6875rem;
}
}

View File

@@ -1,16 +1,16 @@
<template> <template>
<div v-if="showAnnouncement" class="announcement-container"> <div v-if="showAnnouncement" class="announcement-container mobile-announcement-container" :class="{ 'login-page-announcement mobile-login-announcement': isLoginPage }">
<n-card class="announcement-card"> <n-card class="announcement-card mobile-card" :class="{ 'login-card-style': isLoginPage }">
<template #header> <template #header>
<div class="announcement-header"> <div class="announcement-header mobile-announcement-header">
<n-icon size="18" :component="InformationCircleIcon" class="info-icon" /> <n-icon size="18" :component="InformationCircleIcon" class="info-icon" />
<span>系统公告</span> <span>系统公告</span>
</div> </div>
</template> </template>
<div class="announcement-content" v-html="processedContent"></div> <div class="announcement-content mobile-announcement-content" v-html="processedContent"></div>
<div class="announcement-timer">{{ remainingTimeText }}</div> <div class="announcement-timer mobile-announcement-timer">{{ remainingTimeText }}</div>
<template #action> <template #action>
<n-button quaternary circle size="small" @click="closeAnnouncement"> <n-button quaternary circle size="small" @click="closeAnnouncement" class="mobile-touch-target">
<template #icon> <template #icon>
<n-icon :component="CloseIcon" /> <n-icon :component="CloseIcon" />
</template> </template>
@@ -25,12 +25,20 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { NCard, NIcon, NButton } from 'naive-ui'; import { NCard, NIcon, NButton } from 'naive-ui';
import { InformationCircleOutline as InformationCircleIcon } from '@vicons/ionicons5'; import { InformationCircleOutline as InformationCircleIcon } from '@vicons/ionicons5';
import { Close as CloseIcon } from '@vicons/ionicons5'; import { Close as CloseIcon } from '@vicons/ionicons5';
import { useRoute } from 'vue-router';
const props = defineProps<{ const props = defineProps<{
content: string; content: string;
autoCloseTime?: number; autoCloseTime?: number;
}>(); }>();
const emit = defineEmits<{
(e: 'close'): void;
}>();
const route = useRoute();
const isLoginPage = computed(() => route.path === '/login');
const showAnnouncement = ref(true); const showAnnouncement = ref(true);
const remainingTime = ref(props.autoCloseTime || 5); const remainingTime = ref(props.autoCloseTime || 5);
const timer = ref<number | null>(null); const timer = ref<number | null>(null);
@@ -54,6 +62,7 @@ function closeAnnouncement() {
window.clearInterval(timer.value); window.clearInterval(timer.value);
timer.value = null; timer.value = null;
} }
emit('close');
} }
function updateTimer() { function updateTimer() {
@@ -87,6 +96,11 @@ onBeforeUnmount(() => {
.announcement-card { .announcement-card {
border-left: 4px solid var(--n-primary-color); border-left: 4px solid var(--n-primary-color);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-radius: 0.5rem;
overflow: hidden;
backdrop-filter: blur(10px);
background-color: rgba(255, 255, 255, 0.9);
} }
.announcement-header { .announcement-header {
@@ -125,4 +139,19 @@ onBeforeUnmount(() => {
transform: translateY(0); transform: translateY(0);
} }
} }
/* 登录页面适配 */
.login-page-announcement {
z-index: 1000;
top: 1.5rem;
right: 1.5rem;
}
.login-card-style {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(10px);
border-left: 4px solid #2080f0;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
}
</style> </style>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="api-config-section"> <div class="api-config-section mobile-api-config-section">
<n-button <n-button
class="toggle-button" class="toggle-button mobile-touch-target mobile-toggle-button"
size="small" size="small"
@click="toggleConfig" @click="toggleConfig"
:quaternary="true" :quaternary="true"
@@ -10,12 +10,12 @@
<template #icon> <template #icon>
<n-icon :component="expanded ? ChevronUpIcon : ChevronDownIcon" /> <n-icon :component="expanded ? ChevronUpIcon : ChevronDownIcon" />
</template> </template>
<span class="toggle-text">API配置 {{ expanded ? '收起' : '展开' }}</span> <span class="toggle-text mobile-toggle-text">API配置 {{ expanded ? '收起' : '展开' }}</span>
</n-button> </n-button>
<n-collapse-transition :show="expanded"> <n-collapse-transition :show="expanded">
<n-card class="api-config-card" :bordered="false"> <n-card class="api-config-card mobile-card mobile-shadow mobile-api-config-card" :bordered="false">
<n-alert title="OpenAI API配置" type="info" v-if="isApiInfoVisible" class="api-info-alert"> <n-alert title="OpenAI API配置" type="info" v-if="isApiInfoVisible" class="api-info-alert mobile-api-info-alert mobile-api-info-alert-small">
<template #icon> <template #icon>
<n-icon :component="InformationCircleIcon" /> <n-icon :component="InformationCircleIcon" />
</template> </template>
@@ -29,9 +29,9 @@
</div> </div>
</n-alert> </n-alert>
<n-grid :cols="24" :x-gap="16" :y-gap="16"> <n-grid :cols="24" :x-gap="16" :y-gap="16" responsive="screen" class="mobile-grid mobile-grid-small">
<n-grid-item :span="24" :lg-span="14"> <n-grid-item :span="24" :md-span="14" :lg-span="14" class="mobile-grid-item mobile-grid-item-small">
<n-form-item label="API URL" path="apiUrl"> <n-form-item label="API URL" path="apiUrl" class="mobile-form-item">
<n-input <n-input
v-model:value="apiConfig.apiUrl" v-model:value="apiConfig.apiUrl"
placeholder="https://api.openai.com/v1/chat/completions" placeholder="https://api.openai.com/v1/chat/completions"
@@ -43,7 +43,7 @@
</template> </template>
</n-input> </n-input>
<template #feedback> <template #feedback>
<div class="url-feedback"> <div class="url-feedback mobile-url-feedback">
<span class="formatted-url">实际请求地址: {{ formattedUrl }}</span> <span class="formatted-url">实际请求地址: {{ formattedUrl }}</span>
<div class="url-tips"> <div class="url-tips">
<div>提示: URL以/结尾将忽略v1路径</div> <div>提示: URL以/结尾将忽略v1路径</div>
@@ -54,8 +54,8 @@
</n-form-item> </n-form-item>
</n-grid-item> </n-grid-item>
<n-grid-item :span="24" :lg-span="10"> <n-grid-item :span="24" :md-span="10" :lg-span="10" class="mobile-grid-item mobile-grid-item-small">
<n-form-item label="API Key" path="apiKey"> <n-form-item label="API Key" path="apiKey" class="mobile-form-item">
<n-input <n-input
v-model:value="apiConfig.apiKey" v-model:value="apiConfig.apiKey"
type="password" type="password"
@@ -71,8 +71,8 @@
</n-form-item> </n-form-item>
</n-grid-item> </n-grid-item>
<n-grid-item :span="12" :lg-span="12"> <n-grid-item :span="12" :md-span="12" :lg-span="12" class="mobile-grid-item mobile-grid-item-small">
<n-form-item label="模型" path="apiModel"> <n-form-item label="模型" path="apiModel" class="mobile-form-item">
<n-input <n-input
v-model:value="apiConfig.apiModel" v-model:value="apiConfig.apiModel"
placeholder="输入或选择模型名称" placeholder="输入或选择模型名称"
@@ -101,7 +101,7 @@
<div class="model-suggestions"> <div class="model-suggestions">
<div class="model-tip">您可以直接输入模型名称或点击右侧按钮从下拉菜单选择</div> <div class="model-tip">您可以直接输入模型名称或点击右侧按钮从下拉菜单选择</div>
<span>常用模型:</span> <span>常用模型:</span>
<div class="model-chips"> <div class="model-chips mobile-model-chips">
<n-tag <n-tag
v-for="model in commonModels" v-for="model in commonModels"
:key="model.key" :key="model.key"
@@ -109,6 +109,7 @@
round round
clickable clickable
@click="selectModel(model.key)" @click="selectModel(model.key)"
class="mobile-model-tag"
> >
{{ model.label }} {{ model.label }}
</n-tag> </n-tag>
@@ -118,8 +119,8 @@
</n-form-item> </n-form-item>
</n-grid-item> </n-grid-item>
<n-grid-item :span="12" :lg-span="12"> <n-grid-item :span="12" :md-span="12" :lg-span="12" class="mobile-grid-item mobile-grid-item-small">
<n-form-item label="超时时间(秒)" path="apiTimeout"> <n-form-item label="超时时间(秒)" path="apiTimeout" class="mobile-form-item">
<n-input-number <n-input-number
v-model:value="apiTimeout" v-model:value="apiTimeout"
placeholder="60" placeholder="60"
@@ -144,13 +145,14 @@
</n-grid-item> </n-grid-item>
</n-grid> </n-grid>
<div class="api-actions"> <div class="api-actions mobile-api-actions">
<div class="api-save-option"> <div class="api-save-option mobile-api-save-option">
<n-button <n-button
tertiary tertiary
size="small" size="small"
@click="saveConfig" @click="saveConfig"
round round
class="mobile-api-save-option-button"
> >
<template #icon> <template #icon>
<n-icon :component="SaveIcon" /> <n-icon :component="SaveIcon" />
@@ -159,13 +161,14 @@
</n-button> </n-button>
</div> </div>
<div class="api-buttons"> <div class="api-buttons mobile-api-buttons mobile-api-buttons-small">
<n-button <n-button
type="primary" type="primary"
:loading="testingConnection" :loading="testingConnection"
:disabled="!isConfigValid" :disabled="!isConfigValid"
@click="testConnection" @click="testConnection"
round round
class="mobile-api-button"
> >
<template #icon> <template #icon>
<n-icon :component="CheckmarkIcon" /> <n-icon :component="CheckmarkIcon" />
@@ -173,7 +176,7 @@
测试连接 测试连接
</n-button> </n-button>
<n-button @click="resetConfig" round> <n-button @click="resetConfig" round class="mobile-api-button">
<template #icon> <template #icon>
<n-icon :component="RefreshIcon" /> <n-icon :component="RefreshIcon" />
</template> </template>
@@ -184,7 +187,7 @@
<n-divider v-if="connectionStatus" style="margin: 16px 0 12px" /> <n-divider v-if="connectionStatus" style="margin: 16px 0 12px" />
<div v-if="connectionStatus" class="connection-status" :class="connectionStatus.type"> <div v-if="connectionStatus" class="connection-status mobile-connection-status" :class="connectionStatus.type">
<n-icon :component="connectionStatus.icon" class="status-icon" /> <n-icon :component="connectionStatus.icon" class="status-icon" />
<span class="status-message">{{ connectionStatus.message }}</span> <span class="status-message">{{ connectionStatus.message }}</span>
</div> </div>
@@ -465,8 +468,9 @@ onMounted(() => {
<style scoped> <style scoped>
.api-config-section { .api-config-section {
margin-bottom: 1.5rem; margin-bottom: 2rem;
position: relative; position: relative;
padding-bottom: 10px;
} }
.toggle-button { .toggle-button {
@@ -487,12 +491,14 @@ onMounted(() => {
} }
.api-config-card { .api-config-card {
margin-bottom: 1rem; margin-bottom: 1.5rem;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); 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)); background: linear-gradient(to bottom, rgba(240, 240, 245, 0.5), rgba(250, 250, 252, 0.8));
padding: 16px; padding: 16px;
transition: all 0.3s ease; transition: all 0.3s ease;
overflow: visible;
min-height: 100px;
} }
.api-info-alert { .api-info-alert {
@@ -608,18 +614,6 @@ onMounted(() => {
to { opacity: 1; transform: translateY(0); } 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 { .model-suggestions {
margin-top: 6px; margin-top: 6px;
font-size: 0.75rem; font-size: 0.75rem;

View File

@@ -1,5 +1,13 @@
<template> <template>
<div class="login-container"> <div class="login-container">
<!-- 公告横幅 -->
<AnnouncementBanner
v-if="announcement && showAnnouncementBanner"
:content="announcement"
:auto-close-time="5"
@close="handleAnnouncementClose"
/>
<div class="login-background"> <div class="login-background">
<div class="login-shape shape1"></div> <div class="login-shape shape1"></div>
<div class="login-shape shape2"></div> <div class="login-shape shape2"></div>
@@ -69,7 +77,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, h } from 'vue'; import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { import {
NCard, NCard,
@@ -80,22 +88,22 @@ import {
NIcon, NIcon,
NText, NText,
useMessage, useMessage,
useNotification
} from 'naive-ui'; } from 'naive-ui';
import type { FormInst, FormRules } from 'naive-ui'; import type { FormInst, FormRules } from 'naive-ui';
import { import {
BarChartOutline as BarChartIcon, BarChartOutline as BarChartIcon,
LockClosedOutline as LockClosedIcon, LockClosedOutline as LockClosedIcon,
NotificationsOutline as NotificationsIcon
} from '@vicons/ionicons5'; } from '@vicons/ionicons5';
import { apiService } from '@/services/api'; import { apiService } from '@/services/api';
import type { LoginRequest } from '@/types'; import type { LoginRequest } from '@/types';
import AnnouncementBanner from '@/components/AnnouncementBanner.vue';
const message = useMessage(); const message = useMessage();
const notification = useNotification();
const router = useRouter(); const router = useRouter();
const formRef = ref<FormInst | null>(null); const formRef = ref<FormInst | null>(null);
const loading = ref(false); const loading = ref(false);
const announcement = ref('');
const showAnnouncementBanner = ref(true);
const formValue = reactive({ const formValue = reactive({
password: '' password: ''
@@ -114,16 +122,14 @@ const rules: FormRules = {
const showAnnouncement = (content: string) => { const showAnnouncement = (content: string) => {
if (!content) return; if (!content) return;
notification.info({ // 使用AnnouncementBanner组件显示公告
title: '系统公告', announcement.value = content;
content: () => h('div', { style: 'display: flex; align-items: center;' }, [ showAnnouncementBanner.value = true;
h(NIcon, { component: NotificationsIcon, style: 'margin-right: 8px; font-size: 18px;' }), };
h('span', null, content)
]), // 处理公告关闭事件
duration: 10000, const handleAnnouncementClose = () => {
keepAliveOnHover: true, showAnnouncementBanner.value = false;
closable: true
});
}; };
// 页面加载时检查是否已登录并获取系统公告 // 页面加载时检查是否已登录并获取系统公告
@@ -265,6 +271,11 @@ html, body {
overflow: hidden; overflow: hidden;
} }
/* 确保公告在登录页面上方显示 */
:deep(.announcement-container) {
z-index: 100;
}
.login-background { .login-background {
position: absolute; position: absolute;
width: 100%; width: 100%;

View File

@@ -1,65 +1,95 @@
<template> <template>
<n-card class="market-time-card"> <n-card class="market-time-card mobile-card mobile-shadow mobile-market-time-card">
<n-grid :x-gap="16" :y-gap="16" :cols="gridCols"> <n-grid :x-gap="16" :y-gap="16" cols="1 s:2 m:4" responsive="screen">
<!-- 当前时间 --> <!-- 当前时间 -->
<n-grid-item> <n-grid-item>
<div class="time-block"> <div class="time-block current-time-block mobile-time-block">
<p class="time-label">当前时间</p> <p class="time-label mobile-time-label">当前时间</p>
<p class="current-time">{{ marketInfo.currentTime }}</p> <p class="current-time mobile-current-time">{{ marketInfo.currentTime }}</p>
</div> </div>
</n-grid-item> </n-grid-item>
<!-- A股状态 --> <!-- A股状态 -->
<n-grid-item> <n-grid-item>
<div class="time-block"> <div class="time-block market-block mobile-time-block" :class="{'market-open-block mobile-market-open-block': marketInfo.cnMarket.isOpen, 'market-closed-block mobile-market-closed-block': !marketInfo.cnMarket.isOpen}">
<p class="time-label">A股市场</p> <p class="time-label mobile-time-label">A股市场</p>
<div class="market-status" :class="marketInfo.cnMarket.isOpen ? 'status-open' : 'status-closed'"> <div class="market-status" :class="marketInfo.cnMarket.isOpen ? 'status-open' : 'status-closed'">
<n-tag v-if="marketInfo.cnMarket.isOpen" type="success" size="medium" round> <n-tag v-if="marketInfo.cnMarket.isOpen" type="success" size="medium" round class="status-tag mobile-touch-target mobile-status-tag">
<template #icon><n-icon size="18"><pulse-icon /></n-icon></template> <template #icon><n-icon size="18"><pulse-icon /></n-icon></template>
交易中 交易中
</n-tag> </n-tag>
<n-tag v-else type="default" size="medium" round> <n-tag v-else type="default" size="medium" round class="status-tag mobile-touch-target mobile-status-tag">
<template #icon><n-icon size="18"><time-icon /></n-icon></template> <template #icon><n-icon size="18"><time-icon /></n-icon></template>
已休市 已休市
</n-tag> </n-tag>
</div> </div>
<p class="time-counter">{{ marketInfo.cnMarket.nextTime }}</p> <p class="time-counter mobile-time-counter">{{ marketInfo.cnMarket.nextTime }}</p>
<div class="market-progress-container">
<div class="market-progress-bar"
:class="marketInfo.cnMarket.isOpen ? 'progress-open' : 'progress-closed'"
:style="{ width: marketInfo.cnMarket.progressPercentage + '%' }">
</div>
<div class="progress-markers" :class="{'reverse-markers': !marketInfo.cnMarket.isOpen}">
<div class="progress-marker" :class="marketInfo.cnMarket.isOpen ? 'start' : 'end'">开盘</div>
<div class="progress-marker" :class="marketInfo.cnMarket.isOpen ? 'end' : 'start'">收盘</div>
</div>
</div>
</div> </div>
</n-grid-item> </n-grid-item>
<!-- 港股状态 --> <!-- 港股状态 -->
<n-grid-item> <n-grid-item>
<div class="time-block"> <div class="time-block market-block" :class="{'market-open-block': marketInfo.hkMarket.isOpen, 'market-closed-block': !marketInfo.hkMarket.isOpen}">
<p class="time-label">港股市场</p> <p class="time-label">港股市场</p>
<div class="market-status" :class="marketInfo.hkMarket.isOpen ? 'status-open' : 'status-closed'"> <div class="market-status" :class="marketInfo.hkMarket.isOpen ? 'status-open' : 'status-closed'">
<n-tag v-if="marketInfo.hkMarket.isOpen" type="success" size="medium" round> <n-tag v-if="marketInfo.hkMarket.isOpen" type="success" size="medium" round class="status-tag mobile-touch-target">
<template #icon><n-icon size="18"><pulse-icon /></n-icon></template> <template #icon><n-icon size="18"><pulse-icon /></n-icon></template>
交易中 交易中
</n-tag> </n-tag>
<n-tag v-else type="default" size="medium" round> <n-tag v-else type="default" size="medium" round class="status-tag mobile-touch-target">
<template #icon><n-icon size="18"><time-icon /></n-icon></template> <template #icon><n-icon size="18"><time-icon /></n-icon></template>
已休市 已休市
</n-tag> </n-tag>
</div> </div>
<p class="time-counter">{{ marketInfo.hkMarket.nextTime }}</p> <p class="time-counter">{{ marketInfo.hkMarket.nextTime }}</p>
<div class="market-progress-container">
<div class="market-progress-bar"
:class="marketInfo.hkMarket.isOpen ? 'progress-open' : 'progress-closed'"
:style="{ width: marketInfo.hkMarket.progressPercentage + '%' }">
</div>
<div class="progress-markers" :class="{'reverse-markers': !marketInfo.hkMarket.isOpen}">
<div class="progress-marker" :class="marketInfo.hkMarket.isOpen ? 'start' : 'end'">开盘</div>
<div class="progress-marker" :class="marketInfo.hkMarket.isOpen ? 'end' : 'start'">收盘</div>
</div>
</div>
</div> </div>
</n-grid-item> </n-grid-item>
<!-- 美股状态 --> <!-- 美股状态 -->
<n-grid-item> <n-grid-item>
<div class="time-block"> <div class="time-block market-block" :class="{'market-open-block': marketInfo.usMarket.isOpen, 'market-closed-block': !marketInfo.usMarket.isOpen}">
<p class="time-label">美股市场</p> <p class="time-label">美股市场</p>
<div class="market-status" :class="marketInfo.usMarket.isOpen ? 'status-open' : 'status-closed'"> <div class="market-status" :class="marketInfo.usMarket.isOpen ? 'status-open' : 'status-closed'">
<n-tag v-if="marketInfo.usMarket.isOpen" type="success" size="medium" round> <n-tag v-if="marketInfo.usMarket.isOpen" type="success" size="medium" round class="status-tag mobile-touch-target">
<template #icon><n-icon size="18"><pulse-icon /></n-icon></template> <template #icon><n-icon size="18"><pulse-icon /></n-icon></template>
交易中 交易中
</n-tag> </n-tag>
<n-tag v-else type="default" size="medium" round> <n-tag v-else type="default" size="medium" round class="status-tag mobile-touch-target">
<template #icon><n-icon size="18"><time-icon /></n-icon></template> <template #icon><n-icon size="18"><time-icon /></n-icon></template>
已休市 已休市
</n-tag> </n-tag>
</div> </div>
<p class="time-counter">{{ marketInfo.usMarket.nextTime }}</p> <p class="time-counter">{{ marketInfo.usMarket.nextTime }}</p>
<div class="market-progress-container">
<div class="market-progress-bar"
:class="marketInfo.usMarket.isOpen ? 'progress-open' : 'progress-closed'"
:style="{ width: marketInfo.usMarket.progressPercentage + '%' }">
</div>
<div class="progress-markers" :class="{'reverse-markers': !marketInfo.usMarket.isOpen}">
<div class="progress-marker" :class="marketInfo.usMarket.isOpen ? 'start' : 'end'">开盘</div>
<div class="progress-marker" :class="marketInfo.usMarket.isOpen ? 'end' : 'start'">收盘</div>
</div>
</div>
</div> </div>
</n-grid-item> </n-grid-item>
</n-grid> </n-grid>
@@ -67,21 +97,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; import { ref, onMounted, onBeforeUnmount } from 'vue';
import { NCard, NGrid, NGridItem, NTag, NIcon } from 'naive-ui'; import { NCard, NGrid, NGridItem, NTag, NIcon } from 'naive-ui';
import { import {
PulseOutline as PulseIcon, PulseOutline as PulseIcon,
TimeOutline as TimeIcon TimeOutline as TimeIcon,
} from '@vicons/ionicons5'; } from '@vicons/ionicons5';
import { updateMarketTimeInfo } from '@/utils'; import { updateMarketTimeInfo } from '@/utils';
import type { MarketTimeInfo } from '@/types'; import type { MarketTimeInfo, MarketStatus } from '@/types';
const props = defineProps({
isMobile: {
type: Boolean,
default: false
}
});
const marketInfo = ref<MarketTimeInfo>({ const marketInfo = ref<MarketTimeInfo>({
currentTime: '', currentTime: '',
@@ -90,14 +113,116 @@ const marketInfo = ref<MarketTimeInfo>({
usMarket: { isOpen: false, nextTime: '' } usMarket: { isOpen: false, nextTime: '' }
}); });
const gridCols = computed(() => {
return props.isMobile ? 1 : 4;
});
let intervalId: number | null = null; let intervalId: number | null = null;
function updateMarketTime() { function updateMarketTime() {
marketInfo.value = updateMarketTimeInfo(); const baseInfo = updateMarketTimeInfo();
// 计算各市场的进度百分比
marketInfo.value = {
currentTime: baseInfo.currentTime,
cnMarket: {
...baseInfo.cnMarket,
progressPercentage: calculateProgressPercentage(baseInfo.cnMarket)
},
hkMarket: {
...baseInfo.hkMarket,
progressPercentage: calculateProgressPercentage(baseInfo.hkMarket)
},
usMarket: {
...baseInfo.usMarket,
progressPercentage: calculateProgressPercentage(baseInfo.usMarket)
}
};
}
// 计算进度百分比的函数
function calculateProgressPercentage(market: MarketStatus): number {
// 从nextTime中提取时间信息来计算进度
const timeText = market.nextTime;
// 如果没有时间文本返回默认值50%
if (!timeText) return 50;
try {
// 特殊情况处理
if (timeText.includes("已休市") || timeText.includes("已闭市")) {
return market.isOpen ? 100 : 0; // 休市状态开市时为100%休市时为0%
}
if (timeText.includes("即将开市") || timeText.includes("即将开盘")) {
return market.isOpen ? 5 : 95; // 即将开市开市时为5%休市时为95%
}
// 提取小时和分钟,支持多种格式
let hours = 0;
let minutes = 0;
// 匹配"XX小时XX分钟"格式
const hourMinuteMatch = timeText.match(/(\d+)\s*小时\s*(\d+)\s*分钟/);
if (hourMinuteMatch) {
hours = parseInt(hourMinuteMatch[1]);
minutes = parseInt(hourMinuteMatch[2]);
} else {
// 单独匹配小时和分钟
const hourMatch = timeText.match(/(\d+)\s*小时/);
const minuteMatch = timeText.match(/(\d+)\s*分钟/);
hours = hourMatch ? parseInt(hourMatch[1]) : 0;
minutes = minuteMatch ? parseInt(minuteMatch[1]) : 0;
}
// 总分钟数
const totalMinutes = hours * 60 + minutes;
// 根据市场类型设置不同的交易时长
let tradingMinutes = 240; // 默认交易时长4小时
let nonTradingMinutes = 1200; // 默认非交易时长20小时
// 根据市场调整时长
if (timeText.includes("A股") || timeText.includes("沪深") ||
(!timeText.includes("港股") && !timeText.includes("美股"))) {
tradingMinutes = 240; // A股交易4小时
nonTradingMinutes = 1200; // 非交易20小时
} else if (timeText.includes("港股")) {
tradingMinutes = 390; // 港股交易6.5小时
nonTradingMinutes = 1050; // 非交易17.5小时
} else if (timeText.includes("美股")) {
tradingMinutes = 390; // 美股交易6.5小时
nonTradingMinutes = 1050; // 非交易17.5小时
}
// 根据市场状态计算进度
if (market.isOpen) {
// 市场开市状态 - 从开盘到收盘方向
if (timeText.includes("距离收市") || timeText.includes("距离闭市") ||
timeText.includes("距离休市") || timeText.includes("距离收盘")) {
// 计算已经交易的时间比例
const tradedMinutes = tradingMinutes - totalMinutes;
const percentage = (tradedMinutes / tradingMinutes) * 100;
return Math.max(0, Math.min(100, percentage));
} else {
// 处理交易开始阶段但没有明确提示的情况
return 5; // 开盘初期设为5%
}
} else {
// 市场休市状态 - 从收盘到开盘方向
if (timeText.includes("距离开市") || timeText.includes("距离开盘")) {
// 计算接近开盘的时间比例
const closedMinutes = nonTradingMinutes - totalMinutes;
const percentage = (closedMinutes / nonTradingMinutes) * 100;
// 反转比例0% 表示刚刚休市100% 表示即将开盘
return Math.max(0, Math.min(100, 100 - percentage));
} else {
// 处理休市开始阶段但没有明确提示的情况
return 5; // 刚休市设为5%
}
}
} catch (error) {
console.error("计算市场进度时出错:", error);
// 出错时返回默认值
return market.isOpen ? 50 : 5;
}
} }
onMounted(() => { onMounted(() => {
@@ -116,7 +241,22 @@ onBeforeUnmount(() => {
<style scoped> <style scoped>
.market-time-card { .market-time-card {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
padding: 0.5rem; padding: 0.75rem;
border-radius: 0.75rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
background: linear-gradient(to bottom, rgba(250, 250, 252, 0.8), rgba(245, 245, 250, 0.5));
min-height: 200px; /* 确保卡片有最小高度 */
}
/* 确保网格布局在各种屏幕尺寸下正确显示 */
:deep(.n-grid) {
justify-content: center;
width: 100%;
}
:deep(.n-grid-item) {
display: flex;
justify-content: center;
} }
.time-block { .time-block {
@@ -124,7 +264,39 @@ onBeforeUnmount(() => {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
text-align: center; text-align: center;
padding: 0.5rem; padding: 0.75rem;
border-radius: 0.625rem;
transition: all 0.3s ease;
height: 100%;
box-sizing: border-box;
max-width: 100%; /* 确保不超过父容器宽度 */
}
.current-time-block {
background-color: rgba(32, 128, 240, 0.05);
border: 1px solid rgba(32, 128, 240, 0.1);
max-width: 360px; /* 限制当前时间块的最大宽度 */
width: 100%; /* 确保响应式 */
margin: 0 auto; /* 居中显示 */
}
.market-block {
position: relative;
overflow: hidden;
border: 1px solid transparent;
max-width: 360px; /* 限制市场块的最大宽度 */
width: 100%; /* 确保响应式 */
margin: 0 auto; /* 居中显示 */
}
.market-open-block {
background-color: rgba(24, 160, 88, 0.05);
border-color: rgba(24, 160, 88, 0.1);
}
.market-closed-block {
background-color: rgba(128, 128, 128, 0.05);
border-color: rgba(128, 128, 128, 0.1);
} }
.time-label { .time-label {
@@ -145,13 +317,20 @@ onBeforeUnmount(() => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 32px; min-height: 36px;
width: 100%;
flex-wrap: wrap; /* 允许内容在必要时换行 */
} }
.market-status :deep(.n-tag) { .status-tag {
padding: 0 12px; padding: 0 16px !important;
height: 32px; height: 36px !important;
font-size: 1rem; font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
min-width: 100px;
max-width: 100%; /* 确保不超过父容器宽度 */
} }
.market-status :deep(.n-tag__icon) { .market-status :deep(.n-tag__icon) {
@@ -159,30 +338,299 @@ onBeforeUnmount(() => {
} }
.status-open :deep(.n-tag) { .status-open :deep(.n-tag) {
background-color: rgba(var(--success-color), 0.15); background-color: rgba(24, 160, 88, 0.15);
border: 1px solid var(--n-success-color); border: 1px solid var(--n-success-color);
animation: pulse 2s infinite; animation: pulse 2s infinite;
} }
.status-closed :deep(.n-tag) { .status-closed :deep(.n-tag) {
background-color: rgba(var(--n-text-color-3), 0.1); background-color: rgba(128, 128, 128, 0.1);
border: 1px solid rgba(128, 128, 128, 0.3);
} }
.time-counter { .time-counter {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--n-text-color-3); color: var(--n-text-color-3);
margin-top: 0.5rem; margin-top: 0.5rem;
width: 100%; /* 确保文本容器占满宽度 */
text-align: center; /* 文本居中 */
white-space: nowrap; /* 防止文本换行 */
overflow: hidden; /* 隐藏溢出内容 */
text-overflow: ellipsis; /* 显示省略号 */
}
/* 进度条样式 */
.market-progress-container {
width: 100%;
height: 6px;
background-color: rgba(200, 200, 200, 0.3);
border-radius: 3px;
margin-top: 0.75rem;
overflow: visible;
position: relative;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(200, 200, 200, 0.4);
max-width: 100%; /* 确保不超过父容器宽度 */
}
.market-progress-bar {
height: 100%;
border-radius: 2px;
transition: width 0.5s ease;
position: relative;
}
.market-progress-bar::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg,
rgba(255, 255, 255, 0.15) 0%,
rgba(255, 255, 255, 0.4) 50%,
rgba(255, 255, 255, 0.15) 100%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
.progress-open {
background-color: rgba(24, 160, 88, 0.9);
box-shadow: 0 0 8px rgba(24, 160, 88, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.3);
border: 1px solid rgba(24, 160, 88, 1);
}
.progress-closed {
background-color: rgba(100, 100, 100, 0.8);
box-shadow: 0 0 5px rgba(100, 100, 100, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.2);
border: 1px solid rgba(80, 80, 80, 1);
}
/* 进度条标记 */
.progress-markers {
position: absolute;
top: -20px;
left: 0;
width: 100%;
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--n-text-color-2);
padding: 0 2px;
font-weight: 500;
text-shadow: 0 1px 1px rgba(255, 255, 255, 0.7);
box-sizing: border-box; /* 确保内边距不会增加宽度 */
}
/* 反向标记(休市状态) */
.reverse-markers {
flex-direction: row-reverse;
}
.progress-marker {
position: relative;
white-space: nowrap; /* 防止文本换行 */
max-width: 45%; /* 限制宽度,防止重叠 */
overflow: hidden; /* 隐藏溢出内容 */
text-overflow: ellipsis; /* 显示省略号 */
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
} }
@keyframes pulse { @keyframes pulse {
0% { 0% {
box-shadow: 0 0 0 0 rgba(var(--success-color), 0.4); box-shadow: 0 0 0 0 rgba(24, 160, 88, 0.4);
} }
70% { 70% {
box-shadow: 0 0 0 6px rgba(var(--success-color), 0); box-shadow: 0 0 0 6px rgba(24, 160, 88, 0);
} }
100% { 100% {
box-shadow: 0 0 0 0 rgba(var(--success-color), 0); box-shadow: 0 0 0 0 rgba(24, 160, 88, 0);
} }
} }
/* 移动端适配 */
@media (max-width: 768px) {
.market-time-card {
padding: 0.5rem;
margin-bottom: 1rem;
min-height: 180px; /* 移动端下的最小高度 */
}
.time-block {
padding: 0.625rem;
margin-bottom: 0.75rem; /* 增加底部外边距 */
}
.current-time {
font-size: 1.5rem;
}
.time-label {
font-size: 0.9375rem;
margin-bottom: 0.5rem;
}
.status-tag {
min-width: 100px; /* 减小移动端下的最小宽度 */
height: 36px !important;
font-size: 0.875rem; /* 减小字体大小 */
}
.time-counter {
font-size: 0.75rem; /* 减小字体大小 */
margin-top: 0.375rem;
}
/* 增强视觉层次 */
.market-open-block::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background-color: var(--n-success-color);
border-radius: 2px;
}
.market-closed-block::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background-color: rgba(128, 128, 128, 0.5);
border-radius: 2px;
}
.market-progress-container {
height: 5px;
margin-top: 0.5rem;
border-width: 1px;
}
.progress-markers {
top: -18px; /* 调整位置 */
font-size: 0.6875rem;
}
.progress-marker {
max-width: 40%; /* 移动端下进一步限制宽度 */
}
.progress-marker.start::before,
.progress-marker.end::before {
top: -10px;
height: 6px;
}
/* 增强移动端进度条可见性 */
.progress-open {
background-color: rgba(24, 160, 88, 1);
box-shadow: 0 0 6px rgba(24, 160, 88, 0.6);
}
.progress-closed {
background-color: rgba(90, 90, 90, 0.9);
box-shadow: 0 0 4px rgba(90, 90, 90, 0.5);
}
.current-time-block {
max-width: 360px; /* 移动端下的最大宽度 */
}
.market-block {
max-width: 360px; /* 移动端下的最大宽度 */
}
}
/* 小屏幕手机适配 */
@media (max-width: 480px) {
.market-time-card {
padding: 0.375rem;
min-height: 160px; /* 小屏幕下的最小高度 */
}
.time-block {
padding: 0.5rem;
margin-bottom: 1rem; /* 增加小屏幕下的底部外边距 */
}
.current-time {
font-size: 1.25rem;
}
.time-label {
font-size: 0.875rem;
}
.time-counter {
font-size: 0.75rem;
}
.status-tag {
min-width: 90px; /* 进一步减小最小宽度 */
font-size: 0.8125rem;
padding: 0 12px !important; /* 减小内边距 */
}
/* 确保边框在小屏幕上清晰可见 */
.time-block {
border-width: 1px !important;
}
.market-progress-container {
height: 4px;
margin-top: 0.375rem;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.1);
}
.progress-markers {
top: -16px; /* 调整位置 */
font-size: 0.625rem;
}
.progress-marker {
max-width: 35%; /* 小屏幕下进一步限制宽度 */
}
.progress-marker.start::before,
.progress-marker.end::before {
top: -8px;
height: 5px;
}
/* 进一步增强小屏幕进度条可见性 */
.market-progress-container {
border-width: 1px;
}
.progress-open, .progress-closed {
border-width: 0;
}
.current-time-block {
max-width: 300px; /* 小屏幕下的最大宽度 */
}
.market-block {
max-width: 300px; /* 小屏幕下的最大宽度 */
}
}
/* 确保API配置面板有足够的空间 */
.n-collapse {
margin-bottom: 16px; /* 添加底部间距 */
padding-bottom: 8px; /* 增加内边距底部 */
}
</style> </style>

View File

@@ -1,10 +1,18 @@
<template> <template>
<div class="app-container"> <div class="app-container mobile-bottom-extend">
<!-- 公告横幅 -->
<AnnouncementBanner
v-if="announcement && showAnnouncementBanner"
:content="announcement"
:auto-close-time="5"
@close="handleAnnouncementClose"
/>
<n-layout class="main-layout"> <n-layout class="main-layout">
<n-layout-content class="main-content"> <n-layout-content class="main-content mobile-content-container">
<!-- 市场时间显示 --> <!-- 市场时间显示 -->
<MarketTimeDisplay /> <MarketTimeDisplay :is-mobile="isMobile" />
<!-- API配置面板 --> <!-- API配置面板 -->
<ApiConfigPanel <ApiConfigPanel
@@ -15,11 +23,11 @@
/> />
<!-- 主要内容 --> <!-- 主要内容 -->
<n-card class="analysis-container"> <n-card class="analysis-container mobile-card mobile-card-spacing mobile-shadow">
<n-grid :cols="24" :x-gap="16" :y-gap="16"> <n-grid cols="1 xl:24" :x-gap="16" :y-gap="16" responsive="screen">
<!-- 左侧配置区域 --> <!-- 左侧配置区域 -->
<n-grid-item :span="24" :lg-span="8"> <n-grid-item span="1 xl:8">
<div class="config-section"> <div class="config-section">
<n-form-item label="选择市场类型"> <n-form-item label="选择市场类型">
<n-select <n-select
@@ -29,7 +37,7 @@
/> />
</n-form-item> </n-form-item>
<n-form-item label="股票搜索" v-if="marketType === 'US'"> <n-form-item :label='marketType === "US" ? "股票搜索" : "基金搜索"' v-if="showSearch">
<StockSearch :market-type="marketType" @select="addSelectedStock" /> <StockSearch :market-type="marketType" @select="addSelectedStock" />
</n-form-item> </n-form-item>
@@ -37,7 +45,7 @@
<n-input <n-input
v-model:value="stockCodes" v-model:value="stockCodes"
type="textarea" type="textarea"
placeholder="输入股票代码,多个代码用逗号、空格或换行分隔" placeholder="输入股票、基金代码,多个代码用逗号、空格或换行分隔"
:autosize="{ minRows: 3, maxRows: 6 }" :autosize="{ minRows: 3, maxRows: 6 }"
/> />
</n-form-item> </n-form-item>
@@ -63,7 +71,7 @@
</n-grid-item> </n-grid-item>
<!-- 右侧结果区域 --> <!-- 右侧结果区域 -->
<n-grid-item :span="24" :lg-span="16"> <n-grid-item span="1 xl:16">
<div class="results-section"> <div class="results-section">
<div class="results-header"> <div class="results-header">
<n-space align="center" justify="space-between"> <n-space align="center" justify="space-between">
@@ -113,7 +121,7 @@
</template> </template>
<template v-else-if="displayMode === 'card'"> <template v-else-if="displayMode === 'card'">
<n-grid :cols="1" :x-gap="16" :y-gap="16" :lg-cols="2"> <n-grid cols="1" :x-gap="8" :y-gap="8" responsive="screen">
<n-grid-item v-for="stock in analyzedStocks" :key="stock.code"> <n-grid-item v-for="stock in analyzedStocks" :key="stock.code">
<StockCard :stock="stock" /> <StockCard :stock="stock" />
</n-grid-item> </n-grid-item>
@@ -121,6 +129,7 @@
</template> </template>
<template v-else> <template v-else>
<div class="table-container">
<n-data-table <n-data-table
:columns="stockTableColumns" :columns="stockTableColumns"
:data="analyzedStocks" :data="analyzedStocks"
@@ -129,19 +138,22 @@
:bordered="false" :bordered="false"
:single-line="false" :single-line="false"
striped striped
:scroll-x="1200"
/> />
</div>
</template> </template>
</div> </div>
</n-grid-item> </n-grid-item>
</n-grid> </n-grid>
</n-card> </n-card>
</n-layout-content> </n-layout-content>
</n-layout> </n-layout>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, h } from 'vue'; import { ref, onMounted, computed, onBeforeUnmount } from 'vue';
import { import {
NLayout, NLayout,
NLayoutContent, NLayoutContent,
@@ -155,7 +167,6 @@ import {
NButton, NButton,
NEmpty, NEmpty,
useMessage, useMessage,
useNotification,
NSpace, NSpace,
NText, NText,
NDataTable, NDataTable,
@@ -166,13 +177,13 @@ import { useClipboard } from '@vueuse/core'
import { import {
DocumentTextOutline as DocumentTextIcon, DocumentTextOutline as DocumentTextIcon,
DownloadOutline as DownloadIcon, DownloadOutline as DownloadIcon,
NotificationsOutline as NotificationsIcon
} from '@vicons/ionicons5'; } from '@vicons/ionicons5';
import MarketTimeDisplay from './MarketTimeDisplay.vue'; import MarketTimeDisplay from './MarketTimeDisplay.vue';
import ApiConfigPanel from './ApiConfigPanel.vue'; import ApiConfigPanel from './ApiConfigPanel.vue';
import StockSearch from './StockSearch.vue'; import StockSearch from './StockSearch.vue';
import StockCard from './StockCard.vue'; import StockCard from './StockCard.vue';
import AnnouncementBanner from './AnnouncementBanner.vue';
import { apiService } from '@/services/api'; import { apiService } from '@/services/api';
import type { StockInfo, ApiConfig, StreamInitMessage, StreamAnalysisUpdate } from '@/types'; import type { StockInfo, ApiConfig, StreamInitMessage, StreamAnalysisUpdate } from '@/types';
@@ -181,7 +192,6 @@ import { validateMultipleStockCodes, MarketType } from '@/utils/stockValidator';
// 使用Naive UI的组件API // 使用Naive UI的组件API
const message = useMessage(); const message = useMessage();
const notification = useNotification();
const { copy } = useClipboard(); const { copy } = useClipboard();
// 从环境变量获取的默认配置 // 从环境变量获取的默认配置
@@ -189,6 +199,7 @@ const defaultApiUrl = ref('');
const defaultApiModel = ref(''); const defaultApiModel = ref('');
const defaultApiTimeout = ref('60'); const defaultApiTimeout = ref('60');
const announcement = ref(''); const announcement = ref('');
const showAnnouncementBanner = ref(true);
// 股票分析配置 // 股票分析配置
const marketType = ref('A'); const marketType = ref('A');
@@ -206,29 +217,33 @@ const apiConfig = ref<ApiConfig>({
saveApiConfig: false saveApiConfig: false
}); });
// 移动端检测
const isMobile = computed(() => {
return window.innerWidth <= 768;
});
// 监听窗口大小变化
function handleResize() {
// 窗口大小变化时isMobile计算属性会自动更新
// 这里可以添加其他需要在窗口大小变化时执行的逻辑
}
// 显示系统公告 // 显示系统公告
const showAnnouncement = (content: string) => { const showAnnouncement = (content: string) => {
if (!content) return; if (!content) return;
notification.info({ // 使用AnnouncementBanner组件显示公告
title: '系统公告', announcement.value = content;
content: () => h('div', { style: 'display: flex; align-items: center;' }, [ showAnnouncementBanner.value = true;
h(NIcon, { component: NotificationsIcon, style: 'margin-right: 8px; font-size: 18px;' }),
h('span', null, content)
]),
duration: 0, // 设置为0表示不会自动关闭
keepAliveOnHover: true,
closable: true
});
}; };
// 市场选项 // 市场选项
const marketOptions = [ const marketOptions = [
{ label: 'A股', value: 'A' }, { label: 'A股', value: 'A' },
{ label: '港股', value: 'HK' }, { label: '港股', value: 'HK' },
{ label: '美股', value: 'US' }, { label: '美股', value: 'US', showSearch: true },
{ label: 'ETF', value: 'ETF' }, { label: 'ETF', value: 'ETF', showSearch: true },
{ label: 'LOF', value: 'LOF' } { label: 'LOF', value: 'LOF', showSearch: true }
]; ];
// 表格列定义 // 表格列定义
@@ -384,6 +399,10 @@ const exportOptions = [
} }
]; ];
const showSearch = computed(() =>
marketOptions.find(option => option.value === marketType.value)?.showSearch
);
// 更新API配置 // 更新API配置
function updateApiConfig(config: ApiConfig) { function updateApiConfig(config: ApiConfig) {
apiConfig.value = { ...config }; apiConfig.value = { ...config };
@@ -397,10 +416,13 @@ function handleMarketTypeChange() {
// 添加选择的股票 // 添加选择的股票
function addSelectedStock(symbol: string) { function addSelectedStock(symbol: string) {
// 确保symbol不包含序号或其他不需要的信息
const cleanSymbol = symbol.trim().replace(/^\d+\.\s*/, '');
if (stockCodes.value) { if (stockCodes.value) {
stockCodes.value += ', ' + symbol; stockCodes.value += ', ' + cleanSymbol;
} else { } else {
stockCodes.value = symbol; stockCodes.value = cleanSymbol;
} }
} }
@@ -911,6 +933,9 @@ function getChineseVolumeStatus(status: string): string {
// 页面加载时获取默认配置和公告 // 页面加载时获取默认配置和公告
onMounted(async () => { onMounted(async () => {
try { try {
// 添加窗口大小变化监听
window.addEventListener('resize', handleResize);
// 从API获取配置信息 // 从API获取配置信息
const config = await apiService.getConfig(); const config = await apiService.getConfig();
@@ -938,6 +963,16 @@ onMounted(async () => {
console.error('获取默认配置时出错:', error); console.error('获取默认配置时出错:', error);
} }
}); });
// 组件销毁前移除事件监听
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize);
});
// 处理公告关闭事件
function handleAnnouncementClose() {
showAnnouncementBanner.value = false;
}
</script> </script>
<style scoped> <style scoped>
@@ -946,6 +981,8 @@ onMounted(async () => {
width: 100%; width: 100%;
max-width: 100vw; max-width: 100vw;
overflow-x: hidden; overflow-x: hidden;
padding-bottom: 20px; /* 增加底部内边距 */
box-sizing: border-box;
} }
.main-layout { .main-layout {
@@ -953,6 +990,7 @@ onMounted(async () => {
width: 100%; width: 100%;
max-width: 100vw; max-width: 100vw;
overflow-x: hidden; overflow-x: hidden;
min-height: calc(100vh - 20px); /* 确保至少占满视口高度减去底部空间 */
} }
.main-content { .main-content {
@@ -969,7 +1007,12 @@ onMounted(async () => {
} }
.analysis-container { .analysis-container {
margin-bottom: 2rem; margin-bottom: 1rem;
}
/* 修改卡片内容区域的内边距 */
.analysis-container :deep(.n-card__content) {
padding: 16px;
} }
.config-section { .config-section {
@@ -998,4 +1041,256 @@ onMounted(async () => {
text-overflow: ellipsis; text-overflow: ellipsis;
word-break: break-word; word-break: break-word;
} }
/* 表格容器基础样式 */
.table-container {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch; /* 支持iOS的滚动惯性 */
position: relative;
border-radius: 0.5rem;
}
/* 表格横向滚动指示器 */
.table-container::after {
content: '←→';
position: absolute;
bottom: 10px;
right: 10px;
color: rgba(32, 128, 240, 0.6);
font-size: 14px;
pointer-events: none;
z-index: 2;
animation: fadeInOut 2s infinite;
display: none; /* 默认隐藏,只在移动端显示 */
}
/* 移动端适配的媒体查询 */
@media (max-width: 768px) {
.main-content {
padding: 0.5rem;
max-width: 100%;
width: 100%;
}
/* 显示滚动指示器 */
.table-container::after {
display: block;
}
/* 减少移动端卡片内容区域的内边距 */
.analysis-container :deep(.n-card__content) {
padding: 12px 8px;
}
/* 确保卡片内部没有多余边距 */
:deep(.n-card > .n-card__content) {
padding: 12px 8px;
}
/* 减少结果区域的内边距 */
.results-section {
padding: 0.25rem 0.125rem;
}
.action-buttons {
flex-direction: column;
gap: 0.5rem;
}
.action-buttons .n-button {
width: 100%;
}
.card-title {
font-size: 1.1rem;
}
.analysis-container {
margin-bottom: 0.75rem;
border-radius: 0.75rem;
overflow: hidden;
width: 100%;
box-sizing: border-box;
}
.config-section {
padding: 0.25rem;
width: 100%;
box-sizing: border-box;
}
/* 移动端表格样式优化 */
.table-container {
margin: 0 -4px; /* 抵消父容器的padding */
padding: 0 4px;
}
/* 表格组件移动端优化 */
:deep(.n-data-table-wrapper) {
border-radius: 0.5rem;
}
:deep(.n-data-table-base-table-header, .n-data-table-base-table-body) {
min-width: 100%;
}
:deep(.n-pagination) {
flex-wrap: wrap;
justify-content: center;
margin-top: 8px;
}
/* 保留原有移动端优化样式 */
:deep(.n-form-item) {
margin-bottom: 0.75rem;
}
:deep(.n-grid) {
width: 100% !important;
}
:deep(.n-grid-item) {
width: 100% !important;
max-width: 100% !important;
}
:deep(.n-grid[cols="1 m\\:24"]) {
gap: 8px !important;
}
:deep(.n-grid[cols="1 l\\:2"]) {
gap: 6px !important;
}
:deep(.n-grid-item) > * {
margin-bottom: 8px;
}
:deep(.n-dropdown-menu) {
max-width: 90vw;
}
.app-container {
padding-bottom: 30px; /* 增加移动端底部内边距 */
}
}
/* 小屏幕手机适配 */
@media (max-width: 480px) {
.main-content {
padding: 0.25rem;
}
/* 进一步减少小屏幕卡片内容区域的内边距 */
.analysis-container :deep(.n-card__content) {
padding: 6px 4px;
}
/* 使用更精确的选择器确保覆盖 */
:deep(.n-card) > :deep(.n-card__content),
:deep(.n-card-header) {
padding: 6px 4px !important;
}
/* 减少网格间距到最小 */
:deep(.n-grid[cols="1 l\\:2"]) {
gap: 4px !important;
}
.results-section {
padding: 0.15rem 0.05rem;
}
.results-header {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
:deep(.n-space) {
flex-wrap: wrap;
width: 100%;
justify-content: space-between;
}
:deep(.n-space .n-button) {
margin-right: 0 !important;
}
.analysis-container {
border-radius: 0.625rem;
margin-bottom: 0.5rem;
}
/* 小屏幕下进一步优化n-grid */
:deep(.n-grid) {
gap: 4px !important;
}
:deep(.n-grid-item) {
padding: 0 !important;
}
/* 确保n-grid-item内容在小屏幕下有更紧凑的间距 */
:deep(.n-grid-item) > * {
margin-bottom: 4px;
}
/* 小屏幕表格样式调整 */
.table-container {
margin: 0 -2px;
padding: 0 2px;
}
/* 小屏幕分页控件优化 */
:deep(.n-pagination .n-pagination-item) {
margin: 0 2px;
}
.app-container {
padding-bottom: 40px; /* 增加小屏幕底部内边距 */
}
}
/* 超小屏幕适配 */
@media (max-width: 375px) {
/* 超小屏幕卡片内容区域几乎无内边距 */
.analysis-container :deep(.n-card__content) {
padding: 4px 2px;
}
/* 使用更精确的选择器确保覆盖 */
:deep(.n-card) > :deep(.n-card__content),
:deep(.n-card-header) {
padding: 3px 2px !important;
}
/* 网格间距最小化 */
:deep(.n-grid[cols="1 l\\:2"]),
:deep(.n-grid[cols="1 m\\:24"]) {
gap: 3px !important;
}
/* 极简边距 */
.results-section {
padding: 0.1rem 0.025rem;
}
/* 进一步调整超小屏幕的间距和尺寸 */
.main-content {
padding: 0.15rem;
}
.config-section {
padding: 0.15rem;
}
/* 确保StockCard组件最大化利用空间 */
:deep(.stock-card) {
margin: 2px 0 !important;
border-radius: 4px !important;
}
}
</style> </style>

View File

@@ -1,6 +1,6 @@
<template> <template>
<n-card class="stock-card" :bordered="false" :class="{ 'is-analyzing': isAnalyzing }"> <n-card class="stock-card mobile-card mobile-shadow mobile-stock-card" :bordered="false" :class="{ 'is-analyzing': isAnalyzing }">
<div class="card-header"> <div class="card-header mobile-card-header">
<div class="header-main"> <div class="header-main">
<div class="header-left"> <div class="header-left">
<div class="stock-info"> <div class="stock-info">
@@ -126,7 +126,10 @@
</template> </template>
<template v-else-if="stock.analysisStatus === 'analyzing'"> <template v-else-if="stock.analysisStatus === 'analyzing'">
<div class="analysis-result analysis-streaming" v-if="parsedAnalysis" v-html="parsedAnalysis" :key="analysisContentKey"></div> <div class="analysis-result analysis-streaming"
ref="analysisResultRef"
v-html="parsedAnalysis">
</div>
</template> </template>
<template v-else-if="stock.analysisStatus === 'completed'"> <template v-else-if="stock.analysisStatus === 'completed'">
@@ -138,7 +141,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, watch, ref } from 'vue'; import { computed, watch, ref, nextTick, onMounted, onBeforeUnmount } from 'vue';
import { NCard, NDivider, NIcon, NTag, NButton, useMessage } from 'naive-ui'; import { NCard, NDivider, NIcon, NTag, NButton, useMessage } from 'naive-ui';
import { import {
AlertCircleOutline as AlertCircleIcon, AlertCircleOutline as AlertCircleIcon,
@@ -159,22 +162,17 @@ const isAnalyzing = computed(() => {
}); });
const lastAnalysisLength = ref(0); const lastAnalysisLength = ref(0);
const lastAnalysisText = ref('');
// 监听分析内容变化 // 监听分析内容变化
watch(() => props.stock.analysis, (newVal) => { watch(() => props.stock.analysis, (newVal) => {
if (newVal && props.stock.analysisStatus === 'analyzing') { if (newVal && props.stock.analysisStatus === 'analyzing') {
lastAnalysisLength.value = newVal.length; lastAnalysisLength.value = newVal.length;
lastAnalysisText.value = newVal;
} }
}, { immediate: true }); }, { immediate: true });
// 添加一个计算属性,用于监控分析内容是否更新 // 分析内容的解析
const analysisContentKey = ref(0);
watch(() => props.stock.analysis, (newVal, oldVal) => {
if (newVal && oldVal && newVal.length > oldVal.length && props.stock.analysisStatus === 'analyzing') {
analysisContentKey.value++;
}
}, { immediate: false });
const parsedAnalysis = computed(() => { const parsedAnalysis = computed(() => {
if (props.stock.analysis) { if (props.stock.analysis) {
let result = parseMarkdown(props.stock.analysis); let result = parseMarkdown(props.stock.analysis);
@@ -414,6 +412,99 @@ const getStatusText = computed(() => {
return ''; return '';
} }
}); });
// 添加滚动控制相关变量
const analysisResultRef = ref<HTMLElement | null>(null);
const userScrolling = ref(false);
const scrollPosition = ref(0);
const scrollThreshold = 30; // 底部阈值,小于这个值认为用户已滚动到底部
// 检测用户滚动行为
function handleScroll() {
if (!analysisResultRef.value) return;
const element = analysisResultRef.value;
const atBottom = element.scrollHeight - element.scrollTop - element.clientHeight < scrollThreshold;
// 记录当前滚动位置
scrollPosition.value = element.scrollTop;
// 判断用户是否正在主动滚动
if (atBottom) {
// 用户滚动到底部,标记为非主动滚动状态
userScrolling.value = false;
} else {
// 用户未在底部,标记为主动滚动状态
userScrolling.value = true;
}
}
// 监听滚动事件
onMounted(() => {
if (analysisResultRef.value) {
// 初始滚动到底部
analysisResultRef.value.scrollTop = analysisResultRef.value.scrollHeight;
analysisResultRef.value.addEventListener('scroll', handleScroll);
}
});
// 清理事件监听
onBeforeUnmount(() => {
if (analysisResultRef.value) {
analysisResultRef.value.removeEventListener('scroll', handleScroll);
}
});
// 改进流式更新监听,更保守地控制滚动行为
let isProcessingUpdate = false; // 防止重复处理更新
watch(() => props.stock.analysis, (newVal, oldVal) => {
// 只在分析中且内容增加时处理
if (newVal && oldVal && newVal.length > oldVal.length &&
props.stock.analysisStatus === 'analyzing' && !isProcessingUpdate) {
isProcessingUpdate = true; // 标记正在处理更新
// 检查是否应该自动滚动
let shouldAutoScroll = false;
if (analysisResultRef.value) {
const element = analysisResultRef.value;
// 仅当滚动接近底部或用户尚未开始滚动时自动滚动
const atBottom = element.scrollHeight - element.scrollTop - element.clientHeight < scrollThreshold;
shouldAutoScroll = atBottom || !userScrolling.value;
}
// 使用nextTick确保DOM已更新
nextTick(() => {
if (analysisResultRef.value && shouldAutoScroll) {
// 使用smoothScroll而非直接设置scrollTop减少视觉跳动
smoothScrollToBottom(analysisResultRef.value);
}
// 重置处理标记
setTimeout(() => {
isProcessingUpdate = false;
}, 50); // 短暂延迟,防止过快连续处理
});
}
}, { immediate: false });
// 平滑滚动到底部的辅助函数
function smoothScrollToBottom(element: HTMLElement) {
const targetPosition = element.scrollHeight;
// 如果已经很接近底部,直接跳转避免不必要的动画
const currentGap = targetPosition - element.scrollTop - element.clientHeight;
if (currentGap < 100) {
element.scrollTop = targetPosition;
return;
}
// 否则使用平滑滚动
element.scrollTo({
top: targetPosition,
behavior: 'smooth'
});
}
</script> </script>
<style scoped> <style scoped>
@@ -423,6 +514,8 @@ const getStatusText = computed(() => {
flex-direction: column; flex-direction: column;
transition: all 0.3s ease; transition: all 0.3s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
width: 100%; /* 确保宽度不会超过容器 */
max-width: 100%; /* 限制最大宽度 */
} }
.stock-card.is-analyzing { .stock-card.is-analyzing {
@@ -439,6 +532,7 @@ const getStatusText = computed(() => {
position: relative; position: relative;
background: linear-gradient(to bottom, rgba(240, 240, 245, 0.3), transparent); background: linear-gradient(to bottom, rgba(240, 240, 245, 0.3), transparent);
border-radius: 8px 8px 0 0; border-radius: 8px 8px 0 0;
width: 100%; /* 确保宽度不会超过容器 */
} }
.header-main { .header-main {
@@ -525,6 +619,7 @@ const getStatusText = computed(() => {
.header-right { .header-right {
display: flex; display: flex;
align-items: center; align-items: center;
max-width: 380px;
} }
.copy-button { .copy-button {
@@ -738,6 +833,8 @@ const getStatusText = computed(() => {
text-align: left; text-align: left;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; /* 确保宽度不会超过容器 */
overflow-x: hidden; /* 防止内容横向溢出 */
} }
.error-status { .error-status {
@@ -771,10 +868,18 @@ const getStatusText = computed(() => {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
overflow-x: hidden; overflow-x: hidden;
display: block; /* 确保显示为块级元素 */
box-sizing: border-box; /* 确保padding不增加宽度 */
/* 自定义滚动条样式 */ /* 自定义滚动条样式 */
scrollbar-width: thin; /* Firefox */ scrollbar-width: thin; /* Firefox */
scrollbar-color: rgba(32, 128, 240, 0.3) transparent; /* Firefox */ scrollbar-color: rgba(32, 128, 240, 0.3) transparent; /* Firefox */
/* 改进滚动行为 */
scroll-behavior: smooth;
overflow-anchor: auto;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
will-change: scroll-position;
} }
/* Webkit浏览器的滚动条样式 */ /* Webkit浏览器的滚动条样式 */
@@ -807,6 +912,13 @@ const getStatusText = computed(() => {
position: relative; position: relative;
border-left: 2px solid var(--n-info-color); border-left: 2px solid var(--n-info-color);
animation: fadePulse 2s infinite; animation: fadePulse 2s infinite;
/* 改进滚动行为 */
overflow-y: auto;
scroll-behavior: smooth;
will-change: scroll-position;
/* 防止内容更新时的布局抖动 */
contain: content;
scroll-padding-bottom: 20px;
} }
/* 改进流式输出的动画效果,消除闪烁 */ /* 改进流式输出的动画效果,消除闪烁 */
@@ -886,6 +998,8 @@ const getStatusText = computed(() => {
border-radius: 3px; border-radius: 3px;
font-family: monospace; font-family: monospace;
font-size: 0.85em; font-size: 0.85em;
white-space: pre-wrap; /* 允许代码内容自动换行 */
word-break: break-word; /* 确保长单词可以换行 */
} }
.analysis-result :deep(pre) { .analysis-result :deep(pre) {
@@ -896,12 +1010,16 @@ const getStatusText = computed(() => {
margin: 0.75rem 0; margin: 0.75rem 0;
border-left: 3px solid #2080f0; border-left: 3px solid #2080f0;
max-width: 100%; max-width: 100%;
width: 100%;
box-sizing: border-box;
white-space: pre-wrap; /* 允许代码块自动换行 */ white-space: pre-wrap; /* 允许代码块自动换行 */
word-break: break-word; /* 允许长单词换行 */
} }
.analysis-result :deep(pre code) { .analysis-result :deep(pre code) {
background: transparent; background: transparent;
padding: 0; padding: 0;
white-space: inherit; /* 继承pre的换行行为 */
} }
/* 优化引用样式 */ /* 优化引用样式 */
@@ -923,6 +1041,8 @@ const getStatusText = computed(() => {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
table-layout: fixed; /* 固定表格布局 */ table-layout: fixed; /* 固定表格布局 */
max-width: 100%; max-width: 100%;
display: block; /* 使表格成为块级元素 */
overflow-x: auto; /* 允许表格滚动 */
} }
.analysis-result :deep(th), .analysis-result :deep(td) { .analysis-result :deep(th), .analysis-result :deep(td) {
@@ -994,6 +1114,10 @@ const getStatusText = computed(() => {
border-bottom: 1px dotted #2080f0; border-bottom: 1px dotted #2080f0;
transition: all 0.2s ease; transition: all 0.2s ease;
font-weight: 500; font-weight: 500;
word-break: break-word;
overflow-wrap: break-word;
display: inline-block;
max-width: 100%;
} }
.analysis-result :deep(a:hover) { .analysis-result :deep(a:hover) {
@@ -1009,5 +1133,682 @@ const getStatusText = computed(() => {
margin: 0.75rem auto; margin: 0.75rem auto;
border-radius: 4px; border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
object-fit: contain; /* 保持图片比例 */
}
/* 移动端适配样式 */
@media (max-width: 768px) {
.stock-card {
margin-bottom: 0.75rem;
}
.card-header {
padding: 0.75rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.header-main {
flex-direction: row;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.header-left {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: 100%;
margin-bottom: 0.5rem;
}
.stock-info {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
min-width: auto;
}
.stock-code {
font-size: 1.2rem;
}
.stock-name {
font-size: 0.8rem;
max-width: 100px;
}
.header-right {
margin-top: 0.5rem;
width: 320px;
display: flex;
justify-content: flex-end;
}
.stock-price-info {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
margin-top: 0.5rem;
gap: 16px;
border-left: none;
border-top: 1px dashed rgba(0, 0, 0, 0.09);
padding-top: 8px;
padding-left: 0;
width: 100%;
}
.stock-price, .stock-change {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
padding: 0;
}
.stock-price .label,
.stock-change .label {
font-size: 0.75rem;
}
.stock-price .value {
font-size: 1rem;
}
.stock-change .value {
font-size: 0.9rem;
}
.stock-summary {
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.technical-indicators {
margin: 0.75rem 0.5rem;
background-color: rgba(240, 240, 245, 0.5);
border-radius: 0.5rem;
padding: 0.5rem;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.05);
}
.indicators-grid {
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
padding: 0.25rem;
}
.indicator-item {
border-radius: 0.5rem;
padding: 0.625rem 0.5rem;
background-color: rgba(255, 255, 255, 0.7);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
}
.indicator-item:active {
transform: scale(0.98);
box-shadow: 0 0 1px rgba(0, 0, 0, 0.1);
}
.indicator-value {
font-size: 0.95rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.indicator-label {
font-size: 0.7rem;
color: var(--n-text-color-3);
margin-top: 0.125rem;
}
.actions-bar {
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
}
.action-button {
width: 100%;
height: 36px !important;
}
.card-content {
padding: 0.5rem 0.3rem;
}
.analysis-result {
font-size: 0.85rem;
line-height: 1.65;
padding: 0.6rem 0.5rem;
max-height: 350px;
border-radius: 0.5rem;
border: 1px solid rgba(0, 0, 0, 0.07);
margin: 0.4rem 0;
background-color: rgba(255, 255, 255, 0.7);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
-webkit-overflow-scrolling: touch; /* 加强iOS滚动平滑性 */
overscroll-behavior: contain; /* 防止滚动传播 */
touch-action: pan-y; /* 优化触摸滚动体验 */
width: 100%; /* 占据全部可用宽度 */
box-sizing: border-box;
position: relative; /* 确保滚动提示正确定位 */
overflow-x: hidden !important; /* 强制禁止横向滚动 */
}
/* 优化表格在移动端的显示 */
.analysis-result :deep(table) {
width: 100% !important;
max-width: 100% !important;
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
font-size: 0.8rem;
border: none;
border-radius: 0.4rem;
margin: 0.7rem 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.07);
position: relative;
}
/* 优化代码块在移动端的显示 */
.analysis-result :deep(pre) {
font-size: 0.8rem;
padding: 0.75rem 0.5rem;
border-radius: 0.4rem;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin: 0.7rem 0;
background-color: rgba(0, 0, 0, 0.04);
border-left: 3px solid rgba(32, 128, 240, 0.5);
width: 100% !important;
box-sizing: border-box;
white-space: pre-wrap;
word-break: break-word;
position: relative;
}
/* 拖动滚动提示效果 - 恢复并优化 */
.analysis-result :deep(pre)::after,
.analysis-result :deep(table)::after {
content: '⟷';
position: absolute;
right: 5px;
bottom: 5px;
color: rgba(32, 128, 240, 0.5);
font-size: 12px;
opacity: 0.6;
pointer-events: none;
z-index: 3;
}
/* 改进链接触摸体验 */
.analysis-result :deep(a) {
padding: 0.1rem 0;
margin: 0 0.1rem;
word-break: break-word;
overflow-wrap: break-word;
max-width: 100%;
}
/* 改进按钮和交互元素触摸体验 */
.analysis-result :deep(button),
.analysis-result :deep(.interactive) {
min-height: 36px; /* 最小触摸高度 */
min-width: 36px; /* 最小触摸宽度 */
display: inline-flex;
align-items: center;
justify-content: center;
}
/* 确保所有内容在移动端都能正确换行和显示 */
.analysis-result :deep(*) {
max-width: 100% !important;
box-sizing: border-box !important;
}
.analysis-streaming {
background-color: rgba(32, 128, 240, 0.03);
}
.analysis-completed {
background-color: rgba(24, 160, 88, 0.02);
}
/* 优化标题样式 */
.analysis-result :deep(h1),
.analysis-result :deep(h2),
.analysis-result :deep(h3) {
margin: 1rem 0 0.7rem 0;
line-height: 1.3;
padding-bottom: 0.4rem;
}
.analysis-result :deep(h1) {
font-size: 1.3rem;
}
.analysis-result :deep(h2) {
font-size: 1.15rem;
}
.analysis-result :deep(h3) {
font-size: 1rem;
}
/* 优化段落间距 */
.analysis-result :deep(p) {
margin: 0.6rem 0;
}
/* 优化列表样式 */
.analysis-result :deep(ul),
.analysis-result :deep(ol) {
padding-left: 1.2rem;
margin: 0.6rem 0;
}
.analysis-result :deep(li) {
margin-bottom: 0.35rem;
padding-left: 0.3rem;
}
/* 优化引用块 */
.analysis-result :deep(blockquote) {
margin: 0.7rem 0;
padding: 0.6rem 0.75rem;
border-left: 4px solid #f0a020;
background-color: rgba(240, 160, 32, 0.07);
border-radius: 0.25rem;
}
/* 优化代码块 */
.analysis-result :deep(pre) {
font-size: 0.8rem;
padding: 0.75rem 0.5rem;
border-radius: 0.4rem;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin: 0.7rem 0;
background-color: rgba(0, 0, 0, 0.04);
border-left: 3px solid rgba(32, 128, 240, 0.5);
white-space: pre-wrap;
}
.analysis-result :deep(code) {
font-size: 0.8rem;
padding: 0.15rem 0.3rem;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 0.2rem;
}
/* 优化表格显示 */
.analysis-result :deep(table) {
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
width: 100%;
border-radius: 0.4rem;
margin: 0.7rem 0;
font-size: 0.8rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.07);
}
.analysis-result :deep(th),
.analysis-result :deep(td) {
padding: 0.5rem 0.4rem;
}
/* 优化强调文本 */
.analysis-result :deep(strong) {
font-weight: 600;
}
/* 优化专业术语显示 */
.analysis-result :deep(.buy),
.analysis-result :deep(.sell),
.analysis-result :deep(.hold) {
padding: 0.1rem 0.3rem;
border-radius: 0.2rem;
}
.analysis-result :deep(.indicator) {
padding: 0.1rem 0.3rem;
border-radius: 0.2rem;
}
/* 优化图片显示 */
.analysis-result :deep(img) {
max-width: 100%;
height: auto;
border-radius: 0.4rem;
margin: 0.7rem auto;
}
/* 优化滚动条样式 */
.analysis-result::-webkit-scrollbar {
width: 4px;
height: 4px;
}
.analysis-result::-webkit-scrollbar-thumb {
background-color: rgba(32, 128, 240, 0.3);
border-radius: 2px;
}
/* 滚动提示 */
.analysis-result::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 20px;
background: linear-gradient(to top, rgba(255, 255, 255, 0.7), transparent);
pointer-events: none;
opacity: 0.8;
border-radius: 0 0 0.5rem 0.5rem;
z-index: 2;
}
}
/* 小屏幕手机适配 */
@media (max-width: 480px) {
.stock-card {
margin-bottom: 0.5rem;
border-radius: 0.625rem !important;
}
.stock-info {
flex-direction: row;
align-items: center;
gap: 6px;
}
.stock-code {
font-size: 1rem;
}
.stock-name {
margin-left: 0;
margin-top: 0;
font-size: 0.75rem;
max-width: 80px;
}
.stock-price-info {
gap: 12px;
padding-top: 6px;
margin-top: 6px;
flex-wrap: nowrap;
}
.stock-price, .stock-change {
white-space: nowrap;
}
.stock-price .label,
.stock-change .label {
font-size: 0.7rem;
}
.stock-price .value {
font-size: 0.85rem;
}
.stock-change .value {
font-size: 0.8rem;
padding: 1px 4px;
}
.technical-indicators {
margin: 0.5rem 0.25rem;
border-radius: 0.45rem;
padding: 0.4rem 0.3rem;
}
.indicators-grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
padding: 0.2rem;
}
.indicator-item {
border-radius: 0.45rem;
padding: 0.5rem 0.25rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
background-color: rgba(255, 255, 255, 0.7);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
}
.indicator-value {
font-size: 0.9rem;
margin-bottom: 0.15rem;
}
.indicator-label {
font-size: 0.7rem;
margin-top: 0;
}
.card-header {
padding: 0.625rem;
}
/* 确保边框在小屏幕上清晰可见 */
.stock-card, .indicator-item, .analysis-result {
border: 1px solid rgba(0, 0, 0, 0.08) !important;
}
/* 为不同类型的指标设置不同的边框颜色 */
.indicator-item .rsi-overbought {
border-bottom: 2px solid #d03050;
}
.indicator-item .rsi-oversold {
border-bottom: 2px solid #18a058;
}
.indicator-item .trend-up {
border-bottom: 2px solid #d03050;
}
.indicator-item .trend-down {
border-bottom: 2px solid #18a058;
}
.indicator-item .signal-buy {
border-bottom: 2px solid #d03050;
}
.indicator-item .signal-sell {
border-bottom: 2px solid #18a058;
}
/* 分析结果小屏幕样式 */
.analysis-result {
font-size: 0.825rem;
line-height: 1.6;
padding: 0.5rem 0.4rem;
margin: 0.2rem 0;
max-height: 300px;
max-width: none; /* 移除宽度限制 */
width: 100%; /* 占据全部可用宽度 */
box-sizing: border-box;
}
.card-content {
padding: 0.3rem 0.1rem;
}
.analysis-result :deep(h1) {
font-size: 1.2rem;
margin-top: 0.85rem;
}
.analysis-result :deep(h2) {
font-size: 1.1rem;
}
.analysis-result :deep(h3) {
font-size: 0.95rem;
}
.analysis-result :deep(ul),
.analysis-result :deep(ol) {
padding-left: 1rem;
}
.analysis-result :deep(blockquote) {
padding: 0.5rem 0.625rem;
}
.analysis-result :deep(pre) {
font-size: 0.75rem;
padding: 0.6rem 0.4rem;
}
.analysis-result :deep(code) {
font-size: 0.75rem;
}
.analysis-result :deep(th),
.analysis-result :deep(td) {
padding: 0.4rem 0.3rem;
}
}
/* 超小屏幕适配 */
@media (max-width: 375px) {
.indicators-grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.4rem;
}
.indicator-item {
padding: 0.4rem 0.2rem;
}
.indicator-value {
font-size: 0.85rem;
margin-bottom: 0.1rem;
}
.indicator-label {
font-size: 0.65rem;
}
/* 分析结果超小屏幕样式 */
.analysis-result {
font-size: 0.8rem;
padding: 0.4rem 0.3rem;
margin: 0.1rem 0;
width: 100%; /* 占据全部可用宽度 */
box-sizing: border-box;
}
.analysis-result :deep(h1) {
font-size: 1.15rem;
}
.analysis-result :deep(h2) {
font-size: 1.05rem;
}
.analysis-result :deep(h3) {
font-size: 0.9rem;
}
.card-content {
padding: 0.2rem 0.05rem;
}
}
/* 添加PC端特定样式确保纵向布局 */
@media (min-width: 769px) {
.stock-card {
max-width: 100%;
display: flex;
flex-direction: column;
}
.card-header {
flex-direction: column;
}
.header-main {
flex-direction: row;
flex-wrap: nowrap;
}
.header-left {
flex-direction: row;
flex-wrap: nowrap;
}
.stock-price-info {
flex-direction: column;
flex-wrap: nowrap;
}
.stock-summary {
flex-direction: row;
flex-wrap: nowrap;
}
.card-content {
width: 100%;
overflow-x: hidden;
}
.analysis-result {
width: 100%;
max-width: 100%;
overflow-x: hidden;
}
/* 优化技术指标在PC端的显示 */
.indicators-grid {
grid-template-columns: repeat(5, 1fr);
gap: 1rem;
}
}
/* 确保所有嵌套元素不会超出容器 */
.analysis-result :deep(*) {
max-width: 100%;
box-sizing: border-box;
}
/* 对于图片特别控制 */
.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);
object-fit: contain; /* 保持图片比例 */
}
/* 修复长链接可能导致的溢出 */
.analysis-result :deep(a) {
word-break: break-word;
overflow-wrap: break-word;
display: inline-block;
max-width: 100%;
}
/* 删除滚动控制面板样式 */
.scroll-controls {
display: none;
} }
</style> </style>

View File

@@ -2,7 +2,7 @@
<div class="stock-search-container"> <div class="stock-search-container">
<n-input <n-input
v-model:value="searchKeyword" v-model:value="searchKeyword"
placeholder="输入股票代码或名称搜索" placeholder="输入代码或名称搜索"
@input="handleSearchInput" @input="handleSearchInput"
@blur="handleBlur" @blur="handleBlur"
@focus="handleFocus" @focus="handleFocus"
@@ -13,14 +13,14 @@
</template> </template>
</n-input> </n-input>
<div class="search-results" v-show="showResults"> <div class="search-results mobile-search-results" v-show="showResults">
<div v-if="loading" class="loading-results"> <div v-if="loading" class="loading-results">
<n-spin size="small" /> <n-spin size="small" />
<span>搜索中...</span> <span>搜索中...</span>
</div> </div>
<div v-else-if="results.length === 0 && searchKeyword" class="no-results"> <div v-else-if="results.length === 0 && searchKeyword" class="no-results">
未找到相关股票 未找到相关数据
</div> </div>
<template v-else> <template v-else>
@@ -28,17 +28,17 @@
<div <div
v-for="item in results" v-for="item in results"
:key="item.symbol" :key="item.symbol"
class="search-result-item" class="search-result-item mobile-search-result-item"
@click="selectStock(item)" @click="selectStock(item)"
> >
<div class="result-symbol-name"> <div class="result-symbol-name">
<span class="result-symbol">{{ item.symbol }}</span> <span class="result-symbol">{{ item.symbol }}</span>
<span class="result-name">{{ item.name }}</span> <span class="result-name mobile-result-name">{{ item.name }}</span>
</div> </div>
<div class="result-meta"> <div class="result-meta">
<span class="result-market">{{ item.market }}</span> <span class="result-market">{{ item.market }}</span>
<span v-if="item.marketValue" class="result-market-value"> <span v-if="item.market_value" class="result-market-value">
市值: {{ formatMarketValue(item.marketValue) }} 市值: {{ formatMarketValue(item.market_value) }}
</span> </span>
</div> </div>
</div> </div>
@@ -87,11 +87,13 @@ const debouncedSearch = debounce(async (keyword: string) => {
// 限制只显示前10个结果 // 限制只显示前10个结果
results.value = searchResults.slice(0, 10); results.value = searchResults.slice(0, 10);
} else { } else {
// 其他市场搜索 (后端需要实现对应的接口) // 基金搜索
results.value = []; const searchResults = await apiService.searchFunds(keyword);
// 限制只显示前10个结果
results.value = searchResults.slice(0, 10);
} }
} catch (error) { } catch (error) {
console.error('搜索股票时出错:', error); console.error('搜索数据时出错:', error);
results.value = []; results.value = [];
} finally { } finally {
loading.value = false; loading.value = false;
@@ -104,7 +106,10 @@ function handleSearchInput() {
} }
function selectStock(item: SearchResult) { function selectStock(item: SearchResult) {
emit('select', item.symbol); // 处理symbol确保不包含序号
// 假设symbol格式可能是"1. AAPL"这样的格式,我们只需要"AAPL"部分
const cleanSymbol = item.symbol.replace(/^\d+\.\s*/, '');
emit('select', cleanSymbol);
searchKeyword.value = ''; searchKeyword.value = '';
showResults.value = false; showResults.value = false;
} }
@@ -219,4 +224,32 @@ onBeforeUnmount(() => {
.result-market-value { .result-market-value {
margin-top: 0.25rem; margin-top: 0.25rem;
} }
/* 移动端适配 */
@media (max-width: 768px) {
.search-result-item:last-child {
border-bottom: none;
}
/* 确保输入框在移动端正确显示 */
:deep(.n-input) {
width: 100% !important;
}
}
@media (max-width: 480px) {
.result-symbol-name, .result-meta {
font-size: 0.875rem;
}
.result-market, .result-market-value {
font-size: 0.75rem;
}
.loading-results, .no-results {
padding: 0.75rem;
font-size: 0.75rem;
}
}
</style> </style>

View File

@@ -1,6 +1,7 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import './style.css' import './style.css'
import './assets/css/global.css' import './assets/css/global.css'
import './assets/styles/mobile.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'

View File

@@ -116,6 +116,19 @@ export const apiService = {
} }
}, },
// 搜索基金
searchFunds: async (keyword: string): Promise<SearchResult[]> => {
try {
const response = await axiosInstance.get('/search_funds', {
params: { keyword }
});
return response.data.results || [];
} catch (error) {
console.error('搜索基金时出错:', error);
return [];
}
},
// 获取配置 // 获取配置
getConfig: async () => { getConfig: async () => {
try { try {

View File

@@ -24,10 +24,9 @@ a:hover {
body { body {
margin: 0; margin: 0;
display: flex;
place-items: center;
min-width: 320px; min-width: 320px;
min-height: 100vh; min-height: 100vh;
background-color: #f5f5f7;
} }
h1 { h1 {
@@ -63,6 +62,8 @@ button:focus-visible {
max-width: 1280px; max-width: 1280px;
margin: 0 auto; margin: 0 auto;
padding: 2rem; padding: 2rem;
width: 100%;
box-sizing: border-box;
text-align: center; text-align: center;
} }
@@ -113,3 +114,97 @@ input::-moz-focus-inner {
outline: none !important; outline: none !important;
box-shadow: 0 0 0 2px rgba(32, 128, 240, 0.2) !important; box-shadow: 0 0 0 2px rgba(32, 128, 240, 0.2) !important;
} }
/* 组件宽度统一规范 */
.component-container {
width: 100%;
max-width: 1200px;
margin-left: auto;
margin-right: auto;
box-sizing: border-box;
}
.form-container {
width: 100%;
max-width: 900px;
margin-left: auto;
margin-right: auto;
box-sizing: border-box;
}
.control-container {
width: 100%;
max-width: 600px;
margin-left: auto;
margin-right: auto;
box-sizing: border-box;
}
/* 移动端适配 */
@media (max-width: 768px) {
body {
background-color: #f5f5f7;
overflow-x: hidden;
padding: 0;
margin: 0;
}
#app {
width: 100%;
overflow-x: hidden;
padding: 1rem;
margin: 0 auto;
}
.component-container {
width: calc(100% - 20px);
max-width: 560px;
}
.form-container {
width: calc(100% - 20px);
max-width: 480px;
}
.control-container {
width: calc(100% - 16px);
max-width: 440px;
}
}
@media (max-width: 480px) {
#app {
padding: 0.5rem;
}
.component-container {
width: calc(100% - 16px);
max-width: 360px;
}
.form-container {
width: calc(100% - 12px);
max-width: 320px;
}
.control-container {
width: calc(100% - 8px);
max-width: 300px;
}
}
/* 表单控件统一样式 */
.uniform-input,
.uniform-select,
.uniform-button {
width: 100%;
box-sizing: border-box;
border-radius: 0.5rem;
}
.button-group {
display: flex;
gap: 0.5rem;
width: 100%;
justify-content: center;
}

View File

@@ -43,12 +43,13 @@ export interface SearchResult {
symbol: string; symbol: string;
name: string; name: string;
market: string; market: string;
marketValue?: number; market_value?: number;
} }
export interface MarketStatus { export interface MarketStatus {
isOpen: boolean; isOpen: boolean;
nextTime: string; nextTime: string;
progressPercentage?: number;
} }
export interface MarketTimeInfo { export interface MarketTimeInfo {

View File

@@ -7,7 +7,7 @@ pandas==2.2.2
scipy==1.15.1 scipy==1.15.1
# 数据获取和分析库 # 数据获取和分析库
akshare==1.16.35 akshare==1.17.44
tqdm==4.67.1 tqdm==4.67.1
# Web框架与异步处理 # Web框架与异步处理

View File

@@ -1,13 +1,11 @@
import pandas as pd import pandas as pd
import numpy as np
import os import os
import json import json
import asyncio
import httpx import httpx
import re import re
from typing import Dict, List, Optional, Any, Generator, AsyncGenerator from typing import AsyncGenerator
from dotenv import load_dotenv from dotenv import load_dotenv
from logger import get_logger from utils.logger import get_logger
from utils.api_utils import APIUtils from utils.api_utils import APIUtils
from datetime import datetime from datetime import datetime
@@ -268,13 +266,17 @@ class AIAnalyzer:
chunk_data = json.loads(line) chunk_data = json.loads(line)
# 检查是否有finish_reason # 检查是否有finish_reason
finish_reason = chunk_data.get("choices", [{}])[0].get("finish_reason") choices = chunk_data.get("choices", [])
if not choices:
logger.debug("收到空的choices数组跳过")
continue
finish_reason = choices[0].get("finish_reason")
if finish_reason == "stop": if finish_reason == "stop":
logger.debug("收到finish_reason=stop流结束") logger.debug("收到finish_reason=stop流结束")
continue continue
# 获取delta内容 # 获取delta内容
delta = chunk_data.get("choices", [{}])[0].get("delta", {}) delta = choices[0].get("delta", {})
# 检查delta是否为空对象 # 检查delta是否为空对象
if not delta or delta == {}: if not delta or delta == {}:
@@ -352,7 +354,16 @@ class AIAnalyzer:
return return
response_data = response.json() response_data = response.json()
analysis_text = response_data.get("choices", [{}])[0].get("message", {}).get("content", "") choices = response_data.get("choices", [])
if not choices:
logger.error("API响应中没有choices数据")
yield json.dumps({
"stock_code": stock_code,
"error": "API响应格式错误缺少choices数据",
"status": "error"
})
return
analysis_text = choices[0].get("message", {}).get("content", "")
# 尝试从分析内容中提取投资建议 # 尝试从分析内容中提取投资建议
recommendation = self._extract_recommendation(analysis_text) recommendation = self._extract_recommendation(analysis_text)

View File

@@ -1,7 +1,7 @@
import asyncio import asyncio
import pandas as pd import pandas as pd
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from logger import get_logger from utils.logger import get_logger
from datetime import datetime, timedelta from datetime import datetime, timedelta
# 获取日志器 # 获取日志器

View File

@@ -1,10 +1,7 @@
import pandas as pd
import numpy as np
import asyncio
import json import json
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional, Tuple, Any, AsyncGenerator from typing import List, AsyncGenerator
from logger import get_logger from utils.logger import get_logger
from services.stock_data_provider import StockDataProvider from services.stock_data_provider import StockDataProvider
from services.technical_indicator import TechnicalIndicator from services.technical_indicator import TechnicalIndicator
from services.stock_scorer import StockScorer from services.stock_scorer import StockScorer

View File

@@ -1,11 +1,8 @@
import pandas as pd import pandas as pd
import numpy as np
from datetime import datetime, timedelta from datetime import datetime, timedelta
import asyncio import asyncio
import os
from typing import Dict, List, Optional, Tuple, Any from typing import Dict, List, Optional, Tuple, Any
from logger import get_logger from utils.logger import get_logger
import re
# 获取日志器 # 获取日志器
logger = get_logger() logger = get_logger()
@@ -187,9 +184,16 @@ class StockDataProvider:
except Exception as e: except Exception as e:
logger.warning(f"日期过滤出错: {str(e)},返回原始数据") logger.warning(f"日期过滤出错: {str(e)},返回原始数据")
elif market_type in ['ETF', 'LOF']: elif market_type in ['ETF']:
logger.debug(f"获取{market_type}基金数据: {stock_code}") logger.debug(f"获取{market_type}基金数据: {stock_code}")
df = ak.fund_etf_hist_sina( df = ak.fund_etf_hist_em(
symbol=stock_code,
start_date=start_date.replace('-', ''),
end_date=end_date.replace('-', '')
)
elif market_type in ['LOF']:
logger.debug(f"获取{market_type}基金数据: {stock_code}")
df = ak.fund_lof_hist_em(
symbol=stock_code, symbol=stock_code,
start_date=start_date.replace('-', ''), start_date=start_date.replace('-', ''),
end_date=end_date.replace('-', '') end_date=end_date.replace('-', '')
@@ -233,7 +237,7 @@ class StockDataProvider:
elif market_type in ['ETF', 'LOF']: elif market_type in ['ETF', 'LOF']:
# 基金数据可能有不同的列 # 基金数据可能有不同的列
df.columns = ['Date', 'Open', 'High', 'Low', 'Close', 'Volume', 'Amount'] df.columns = ['Date', 'Open', 'Close', 'High', 'Low', 'Volume', 'Amount', 'Amplitude', 'Change_pct', 'Change', 'Turnover']
# 确保日期列是日期类型 # 确保日期列是日期类型
if 'Date' in df.columns: if 'Date' in df.columns:

View File

@@ -1,7 +1,6 @@
import pandas as pd import pandas as pd
import numpy as np from typing import Dict, List, Tuple
from typing import Dict, Optional, Any, List, Tuple from utils.logger import get_logger
from logger import get_logger
# 获取日志器 # 获取日志器
logger = get_logger() logger = get_logger()
@@ -14,7 +13,7 @@ class StockScorer:
def __init__(self): def __init__(self):
"""初始化股票评分服务""" """初始化股票评分服务"""
logger.debug("初始化StockScorer") logger.debug("初始化StockScorer股票评分服务")
def calculate_score(self, df: pd.DataFrame) -> int: def calculate_score(self, df: pd.DataFrame) -> int:
""" """

View File

@@ -1,7 +1,6 @@
import pandas as pd import pandas as pd
import numpy as np
from typing import Dict, Optional, Any from typing import Dict, Optional, Any
from logger import get_logger from utils.logger import get_logger
# 获取日志器 # 获取日志器
logger = get_logger() logger = get_logger()
@@ -29,7 +28,7 @@ class TechnicalIndicator:
'atr_period': 14 'atr_period': 14
} }
logger.debug(f"初始化TechnicalIndicator参数: {self.params}") logger.debug(f"初始化TechnicalIndicator技术指标计算服务,参数: {self.params}")
def calculate_ema(self, series: pd.Series, period: int) -> pd.Series: def calculate_ema(self, series: pd.Series, period: int) -> pd.Series:
""" """

View File

@@ -1,19 +1,19 @@
import asyncio import asyncio
import pandas as pd import pandas as pd
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from logger import get_logger from utils.logger import get_logger
# 获取日志器 # 获取日志器
logger = get_logger() logger = get_logger()
class USStockServiceAsync: class USStockServiceAsync:
""" """
异步美股服务 美股服务
提供美股数据的异步搜索和获取功能 提供美股数据的搜索和获取功能
""" """
def __init__(self): def __init__(self):
"""初始化异步美股服务""" """初始化美股服务"""
logger.debug("初始化USStockServiceAsync") logger.debug("初始化USStockServiceAsync")
# 可选:添加缓存以减少频繁请求 # 可选:添加缓存以减少频繁请求

12
tests/test_akshare.py Normal file
View File

@@ -0,0 +1,12 @@
import akshare as ak
print(f"akshare version: {ak.__version__}")
# df = ak.stock_zh_a_hist(symbol="000858",
# start_date="20250301",
# end_date="20250310",
# adjust="qfq")
# print(df)
stock_us_daily_df = ak.stock_us_daily(symbol="AAPL", adjust="qfq")
print(stock_us_daily_df)

View File

@@ -1,13 +1,13 @@
import os import os
import requests import requests
import json import json
from logger import get_logger, get_stream_logger from utils.logger import get_logger
from dotenv import load_dotenv from dotenv import load_dotenv
from utils.api_utils import APIUtils from utils.api_utils import APIUtils
# 获取日志器 # 获取日志器
logger = get_logger() logger = get_logger()
stream_logger = get_stream_logger()
def _truncate_json_for_logging(json_obj, max_length=500): def _truncate_json_for_logging(json_obj, max_length=500):
"""截断JSON对象用于日志记录避免日志过大 """截断JSON对象用于日志记录避免日志过大
@@ -38,7 +38,7 @@ def test_api_stream():
# 获取API配置 # 获取API配置
api_url = os.getenv('API_URL') api_url = os.getenv('API_URL')
api_key = os.getenv('API_KEY') api_key = os.getenv('API_KEY')
api_model = os.getenv('API_MODEL', 'gpt-3.5-turbo') api_model = os.getenv('API_MODEL', 'gemini-2.0-flash')
logger.info(f"开始测试API流式响应API URL: {api_url}, MODEL: {api_model}") logger.info(f"开始测试API流式响应API URL: {api_url}, MODEL: {api_model}")
@@ -117,7 +117,7 @@ def test_api_stream():
json_data = json.loads(data_content) json_data = json.loads(data_content)
logger.debug(f"JSON结构: {_truncate_json_for_logging(json_data)}") logger.debug(f"JSON结构: {_truncate_json_for_logging(json_data)}")
if 'choices' in json_data: if 'choices' in json_data and json_data['choices']:
delta = json_data['choices'][0].get('delta', {}) delta = json_data['choices'][0].get('delta', {})
content = delta.get('content', '') content = delta.get('content', '')

View File

@@ -2,7 +2,7 @@ from loguru import logger
import sys import sys
import os import os
from datetime import datetime from datetime import datetime
import shutil
# 创建日志目录 # 创建日志目录
log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs") log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")

View File

@@ -1,8 +1,8 @@
from fastapi import FastAPI, Request, Response, Depends, HTTPException, BackgroundTasks from fastapi import FastAPI, Request, Response, Depends, HTTPException, BackgroundTasks
from fastapi.responses import JSONResponse, StreamingResponse, FileResponse, RedirectResponse from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any, Generator from typing import List, Optional, Dict, Any, Generator
from services.stock_analyzer_service import StockAnalyzerService from services.stock_analyzer_service import StockAnalyzerService
@@ -10,9 +10,9 @@ from services.us_stock_service_async import USStockServiceAsync
from services.fund_service_async import FundServiceAsync from services.fund_service_async import FundServiceAsync
import os import os
import httpx import httpx
from logger import get_logger from utils.logger import get_logger
from utils.api_utils import APIUtils from utils.api_utils import APIUtils
from dotenv import load_dotenv, dotenv_values from dotenv import load_dotenv
import uvicorn import uvicorn
import json import json
import secrets import secrets