Compare commits
10 Commits
0303de48fb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c6e03a59f | ||
|
|
9471480c89 | ||
|
|
399207c4ce | ||
|
|
b157fbaab7 | ||
|
|
64deb4dfa0 | ||
|
|
d22f39bae2 | ||
|
|
f86f004181 | ||
|
|
a4a93973d2 | ||
|
|
c6165c2587 | ||
|
|
0ea9acfbb7 |
18
.claude/settings.local.json
Normal file
18
.claude/settings.local.json
Normal 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
2
.gitignore
vendored
@@ -21,6 +21,7 @@ build_upload.log
|
||||
*.spec
|
||||
*.zip
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# frontend
|
||||
node_modules/
|
||||
@@ -38,6 +39,7 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.vite
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
||||
11
Dockerfile
11
Dockerfile
@@ -1,5 +1,5 @@
|
||||
# 阶段一: 构建Vue前端
|
||||
FROM node:18-alpine as frontend-builder
|
||||
FROM node:18-alpine AS frontend-builder
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app/frontend
|
||||
@@ -13,18 +13,21 @@ RUN npm ci
|
||||
# 复制前端源代码
|
||||
COPY frontend/ ./
|
||||
|
||||
# 确保node_modules中的可执行文件有正确权限
|
||||
RUN chmod +x node_modules/.bin/*
|
||||
|
||||
# 构建前端应用
|
||||
RUN npm run build
|
||||
|
||||
# 阶段二: 构建Python后端
|
||||
FROM python:3.10-slim as backend-builder
|
||||
FROM python:3.10-slim AS backend-builder
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 安装系统依赖和构建依赖
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libgl1-mesa-glx \
|
||||
libgl1 \
|
||||
ca-certificates \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -43,7 +46,7 @@ WORKDIR /app
|
||||
|
||||
# 安装运行时依赖
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libgl1-mesa-glx \
|
||||
libgl1 \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
29
Dockerfile.backend
Normal file
29
Dockerfile.backend
Normal 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
32
Dockerfile.full
Normal 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
27
docker-compose.local.yml
Normal 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
|
||||
@@ -7,6 +7,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
platform: linux/amd64
|
||||
container_name: stock-scanner-app
|
||||
ports:
|
||||
- "8888:8888"
|
||||
|
||||
3
frontend/public/index.html
Normal file
3
frontend/public/index.html
Normal 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>
|
||||
551
frontend/src/assets/styles/mobile.css
Normal file
551
frontend/src/assets/styles/mobile.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<div v-if="showAnnouncement" class="announcement-container">
|
||||
<n-card class="announcement-card">
|
||||
<div v-if="showAnnouncement" class="announcement-container mobile-announcement-container" :class="{ 'login-page-announcement mobile-login-announcement': isLoginPage }">
|
||||
<n-card class="announcement-card mobile-card" :class="{ 'login-card-style': isLoginPage }">
|
||||
<template #header>
|
||||
<div class="announcement-header">
|
||||
<div class="announcement-header mobile-announcement-header">
|
||||
<n-icon size="18" :component="InformationCircleIcon" class="info-icon" />
|
||||
<span>系统公告</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="announcement-content" v-html="processedContent"></div>
|
||||
<div class="announcement-timer">{{ remainingTimeText }}</div>
|
||||
<div class="announcement-content mobile-announcement-content" v-html="processedContent"></div>
|
||||
<div class="announcement-timer mobile-announcement-timer">{{ remainingTimeText }}</div>
|
||||
<template #action>
|
||||
<n-button quaternary circle size="small" @click="closeAnnouncement">
|
||||
<n-button quaternary circle size="small" @click="closeAnnouncement" class="mobile-touch-target">
|
||||
<template #icon>
|
||||
<n-icon :component="CloseIcon" />
|
||||
</template>
|
||||
@@ -25,12 +25,20 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { NCard, NIcon, NButton } from 'naive-ui';
|
||||
import { InformationCircleOutline as InformationCircleIcon } from '@vicons/ionicons5';
|
||||
import { Close as CloseIcon } from '@vicons/ionicons5';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const props = defineProps<{
|
||||
content: string;
|
||||
autoCloseTime?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
}>();
|
||||
|
||||
const route = useRoute();
|
||||
const isLoginPage = computed(() => route.path === '/login');
|
||||
|
||||
const showAnnouncement = ref(true);
|
||||
const remainingTime = ref(props.autoCloseTime || 5);
|
||||
const timer = ref<number | null>(null);
|
||||
@@ -54,6 +62,7 @@ function closeAnnouncement() {
|
||||
window.clearInterval(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
emit('close');
|
||||
}
|
||||
|
||||
function updateTimer() {
|
||||
@@ -87,6 +96,11 @@ onBeforeUnmount(() => {
|
||||
|
||||
.announcement-card {
|
||||
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 {
|
||||
@@ -125,4 +139,19 @@ onBeforeUnmount(() => {
|
||||
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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="api-config-section">
|
||||
<div class="api-config-section mobile-api-config-section">
|
||||
<n-button
|
||||
class="toggle-button"
|
||||
class="toggle-button mobile-touch-target mobile-toggle-button"
|
||||
size="small"
|
||||
@click="toggleConfig"
|
||||
:quaternary="true"
|
||||
@@ -10,12 +10,12 @@
|
||||
<template #icon>
|
||||
<n-icon :component="expanded ? ChevronUpIcon : ChevronDownIcon" />
|
||||
</template>
|
||||
<span class="toggle-text">API配置 {{ expanded ? '收起' : '展开' }}</span>
|
||||
<span class="toggle-text mobile-toggle-text">API配置 {{ expanded ? '收起' : '展开' }}</span>
|
||||
</n-button>
|
||||
|
||||
<n-collapse-transition :show="expanded">
|
||||
<n-card class="api-config-card" :bordered="false">
|
||||
<n-alert title="OpenAI API配置" type="info" v-if="isApiInfoVisible" class="api-info-alert">
|
||||
<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 mobile-api-info-alert mobile-api-info-alert-small">
|
||||
<template #icon>
|
||||
<n-icon :component="InformationCircleIcon" />
|
||||
</template>
|
||||
@@ -29,9 +29,9 @@
|
||||
</div>
|
||||
</n-alert>
|
||||
|
||||
<n-grid :cols="24" :x-gap="16" :y-gap="16">
|
||||
<n-grid-item :span="24" :lg-span="14">
|
||||
<n-form-item label="API URL" path="apiUrl">
|
||||
<n-grid :cols="24" :x-gap="16" :y-gap="16" responsive="screen" class="mobile-grid mobile-grid-small">
|
||||
<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" class="mobile-form-item">
|
||||
<n-input
|
||||
v-model:value="apiConfig.apiUrl"
|
||||
placeholder="https://api.openai.com/v1/chat/completions"
|
||||
@@ -43,7 +43,7 @@
|
||||
</template>
|
||||
</n-input>
|
||||
<template #feedback>
|
||||
<div class="url-feedback">
|
||||
<div class="url-feedback mobile-url-feedback">
|
||||
<span class="formatted-url">实际请求地址: {{ formattedUrl }}</span>
|
||||
<div class="url-tips">
|
||||
<div>提示: URL以/结尾将忽略v1路径</div>
|
||||
@@ -54,8 +54,8 @@
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item :span="24" :lg-span="10">
|
||||
<n-form-item label="API Key" path="apiKey">
|
||||
<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" class="mobile-form-item">
|
||||
<n-input
|
||||
v-model:value="apiConfig.apiKey"
|
||||
type="password"
|
||||
@@ -71,8 +71,8 @@
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item :span="12" :lg-span="12">
|
||||
<n-form-item label="模型" path="apiModel">
|
||||
<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" class="mobile-form-item">
|
||||
<n-input
|
||||
v-model:value="apiConfig.apiModel"
|
||||
placeholder="输入或选择模型名称"
|
||||
@@ -101,7 +101,7 @@
|
||||
<div class="model-suggestions">
|
||||
<div class="model-tip">您可以直接输入模型名称,或点击右侧按钮从下拉菜单选择</div>
|
||||
<span>常用模型:</span>
|
||||
<div class="model-chips">
|
||||
<div class="model-chips mobile-model-chips">
|
||||
<n-tag
|
||||
v-for="model in commonModels"
|
||||
:key="model.key"
|
||||
@@ -109,6 +109,7 @@
|
||||
round
|
||||
clickable
|
||||
@click="selectModel(model.key)"
|
||||
class="mobile-model-tag"
|
||||
>
|
||||
{{ model.label }}
|
||||
</n-tag>
|
||||
@@ -118,8 +119,8 @@
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item :span="12" :lg-span="12">
|
||||
<n-form-item label="超时时间(秒)" path="apiTimeout">
|
||||
<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" class="mobile-form-item">
|
||||
<n-input-number
|
||||
v-model:value="apiTimeout"
|
||||
placeholder="60"
|
||||
@@ -144,13 +145,14 @@
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<div class="api-actions">
|
||||
<div class="api-save-option">
|
||||
<div class="api-actions mobile-api-actions">
|
||||
<div class="api-save-option mobile-api-save-option">
|
||||
<n-button
|
||||
tertiary
|
||||
size="small"
|
||||
@click="saveConfig"
|
||||
round
|
||||
class="mobile-api-save-option-button"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon :component="SaveIcon" />
|
||||
@@ -159,13 +161,14 @@
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<div class="api-buttons">
|
||||
<div class="api-buttons mobile-api-buttons mobile-api-buttons-small">
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="testingConnection"
|
||||
:disabled="!isConfigValid"
|
||||
@click="testConnection"
|
||||
round
|
||||
class="mobile-api-button"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon :component="CheckmarkIcon" />
|
||||
@@ -173,7 +176,7 @@
|
||||
测试连接
|
||||
</n-button>
|
||||
|
||||
<n-button @click="resetConfig" round>
|
||||
<n-button @click="resetConfig" round class="mobile-api-button">
|
||||
<template #icon>
|
||||
<n-icon :component="RefreshIcon" />
|
||||
</template>
|
||||
@@ -184,7 +187,7 @@
|
||||
|
||||
<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" />
|
||||
<span class="status-message">{{ connectionStatus.message }}</span>
|
||||
</div>
|
||||
@@ -465,8 +468,9 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.api-config-section {
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
@@ -487,12 +491,14 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.api-config-card {
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
background: linear-gradient(to bottom, rgba(240, 240, 245, 0.5), rgba(250, 250, 252, 0.8));
|
||||
padding: 16px;
|
||||
transition: all 0.3s ease;
|
||||
overflow: visible;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.api-info-alert {
|
||||
@@ -608,18 +614,6 @@ onMounted(() => {
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.api-actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.api-buttons {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.model-suggestions {
|
||||
margin-top: 6px;
|
||||
font-size: 0.75rem;
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<!-- 公告横幅 -->
|
||||
<AnnouncementBanner
|
||||
v-if="announcement && showAnnouncementBanner"
|
||||
:content="announcement"
|
||||
:auto-close-time="5"
|
||||
@close="handleAnnouncementClose"
|
||||
/>
|
||||
|
||||
<div class="login-background">
|
||||
<div class="login-shape shape1"></div>
|
||||
<div class="login-shape shape2"></div>
|
||||
@@ -69,7 +77,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, h } from 'vue';
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
NCard,
|
||||
@@ -80,22 +88,22 @@ import {
|
||||
NIcon,
|
||||
NText,
|
||||
useMessage,
|
||||
useNotification
|
||||
} from 'naive-ui';
|
||||
import type { FormInst, FormRules } from 'naive-ui';
|
||||
import {
|
||||
BarChartOutline as BarChartIcon,
|
||||
LockClosedOutline as LockClosedIcon,
|
||||
NotificationsOutline as NotificationsIcon
|
||||
} from '@vicons/ionicons5';
|
||||
import { apiService } from '@/services/api';
|
||||
import type { LoginRequest } from '@/types';
|
||||
import AnnouncementBanner from '@/components/AnnouncementBanner.vue';
|
||||
|
||||
const message = useMessage();
|
||||
const notification = useNotification();
|
||||
const router = useRouter();
|
||||
const formRef = ref<FormInst | null>(null);
|
||||
const loading = ref(false);
|
||||
const announcement = ref('');
|
||||
const showAnnouncementBanner = ref(true);
|
||||
|
||||
const formValue = reactive({
|
||||
password: ''
|
||||
@@ -114,16 +122,14 @@ const rules: FormRules = {
|
||||
const showAnnouncement = (content: string) => {
|
||||
if (!content) return;
|
||||
|
||||
notification.info({
|
||||
title: '系统公告',
|
||||
content: () => h('div', { style: 'display: flex; align-items: center;' }, [
|
||||
h(NIcon, { component: NotificationsIcon, style: 'margin-right: 8px; font-size: 18px;' }),
|
||||
h('span', null, content)
|
||||
]),
|
||||
duration: 10000,
|
||||
keepAliveOnHover: true,
|
||||
closable: true
|
||||
});
|
||||
// 使用AnnouncementBanner组件显示公告
|
||||
announcement.value = content;
|
||||
showAnnouncementBanner.value = true;
|
||||
};
|
||||
|
||||
// 处理公告关闭事件
|
||||
const handleAnnouncementClose = () => {
|
||||
showAnnouncementBanner.value = false;
|
||||
};
|
||||
|
||||
// 页面加载时检查是否已登录并获取系统公告
|
||||
@@ -265,6 +271,11 @@ html, body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 确保公告在登录页面上方显示 */
|
||||
:deep(.announcement-container) {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.login-background {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
|
||||
@@ -1,65 +1,95 @@
|
||||
<template>
|
||||
<n-card class="market-time-card">
|
||||
<n-grid :x-gap="16" :y-gap="16" :cols="gridCols">
|
||||
<n-card class="market-time-card mobile-card mobile-shadow mobile-market-time-card">
|
||||
<n-grid :x-gap="16" :y-gap="16" cols="1 s:2 m:4" responsive="screen">
|
||||
<!-- 当前时间 -->
|
||||
<n-grid-item>
|
||||
<div class="time-block">
|
||||
<p class="time-label">当前时间</p>
|
||||
<p class="current-time">{{ marketInfo.currentTime }}</p>
|
||||
<div class="time-block current-time-block mobile-time-block">
|
||||
<p class="time-label mobile-time-label">当前时间</p>
|
||||
<p class="current-time mobile-current-time">{{ marketInfo.currentTime }}</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- A股状态 -->
|
||||
<n-grid-item>
|
||||
<div class="time-block">
|
||||
<p class="time-label">A股市场</p>
|
||||
<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 mobile-time-label">A股市场</p>
|
||||
<div class="market-status" :class="marketInfo.cnMarket.isOpen ? 'status-open' : 'status-closed'">
|
||||
<n-tag v-if="marketInfo.cnMarket.isOpen" type="success" size="medium" round>
|
||||
<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>
|
||||
交易中
|
||||
</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>
|
||||
已休市
|
||||
</n-tag>
|
||||
</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>
|
||||
</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>
|
||||
<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>
|
||||
交易中
|
||||
</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>
|
||||
已休市
|
||||
</n-tag>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
交易中
|
||||
</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>
|
||||
已休市
|
||||
</n-tag>
|
||||
</div>
|
||||
<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>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
@@ -67,21 +97,14 @@
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
PulseOutline as PulseIcon,
|
||||
TimeOutline as TimeIcon
|
||||
TimeOutline as TimeIcon,
|
||||
} from '@vicons/ionicons5';
|
||||
import { updateMarketTimeInfo } from '@/utils';
|
||||
import type { MarketTimeInfo } from '@/types';
|
||||
|
||||
const props = defineProps({
|
||||
isMobile: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
import type { MarketTimeInfo, MarketStatus } from '@/types';
|
||||
|
||||
const marketInfo = ref<MarketTimeInfo>({
|
||||
currentTime: '',
|
||||
@@ -90,14 +113,116 @@ const marketInfo = ref<MarketTimeInfo>({
|
||||
usMarket: { isOpen: false, nextTime: '' }
|
||||
});
|
||||
|
||||
const gridCols = computed(() => {
|
||||
return props.isMobile ? 1 : 4;
|
||||
});
|
||||
|
||||
let intervalId: number | null = null;
|
||||
|
||||
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(() => {
|
||||
@@ -116,7 +241,22 @@ onBeforeUnmount(() => {
|
||||
<style scoped>
|
||||
.market-time-card {
|
||||
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 {
|
||||
@@ -124,7 +264,39 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
align-items: 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 {
|
||||
@@ -145,13 +317,20 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 32px;
|
||||
min-height: 36px;
|
||||
width: 100%;
|
||||
flex-wrap: wrap; /* 允许内容在必要时换行 */
|
||||
}
|
||||
|
||||
.market-status :deep(.n-tag) {
|
||||
padding: 0 12px;
|
||||
height: 32px;
|
||||
.status-tag {
|
||||
padding: 0 16px !important;
|
||||
height: 36px !important;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 100px;
|
||||
max-width: 100%; /* 确保不超过父容器宽度 */
|
||||
}
|
||||
|
||||
.market-status :deep(.n-tag__icon) {
|
||||
@@ -159,30 +338,299 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.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);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-size: 0.875rem;
|
||||
color: var(--n-text-color-3);
|
||||
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 {
|
||||
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% {
|
||||
box-shadow: 0 0 0 6px rgba(var(--success-color), 0);
|
||||
box-shadow: 0 0 0 6px rgba(24, 160, 88, 0);
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
<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-content class="main-content">
|
||||
<n-layout-content class="main-content mobile-content-container">
|
||||
|
||||
<!-- 市场时间显示 -->
|
||||
<MarketTimeDisplay />
|
||||
<MarketTimeDisplay :is-mobile="isMobile" />
|
||||
|
||||
<!-- API配置面板 -->
|
||||
<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">
|
||||
<n-form-item label="选择市场类型">
|
||||
<n-select
|
||||
@@ -29,7 +37,7 @@
|
||||
/>
|
||||
</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" />
|
||||
</n-form-item>
|
||||
|
||||
@@ -37,7 +45,7 @@
|
||||
<n-input
|
||||
v-model:value="stockCodes"
|
||||
type="textarea"
|
||||
placeholder="输入股票代码,多个代码用逗号、空格或换行分隔"
|
||||
placeholder="输入股票、基金代码,多个代码用逗号、空格或换行分隔"
|
||||
:autosize="{ minRows: 3, maxRows: 6 }"
|
||||
/>
|
||||
</n-form-item>
|
||||
@@ -63,7 +71,7 @@
|
||||
</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-header">
|
||||
<n-space align="center" justify="space-between">
|
||||
@@ -113,7 +121,7 @@
|
||||
</template>
|
||||
|
||||
<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">
|
||||
<StockCard :stock="stock" />
|
||||
</n-grid-item>
|
||||
@@ -121,6 +129,7 @@
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<n-data-table
|
||||
:columns="stockTableColumns"
|
||||
:data="analyzedStocks"
|
||||
@@ -129,19 +138,22 @@
|
||||
:bordered="false"
|
||||
:single-line="false"
|
||||
striped
|
||||
:scroll-x="1200"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, h } from 'vue';
|
||||
import { ref, onMounted, computed, onBeforeUnmount } from 'vue';
|
||||
import {
|
||||
NLayout,
|
||||
NLayoutContent,
|
||||
@@ -155,7 +167,6 @@ import {
|
||||
NButton,
|
||||
NEmpty,
|
||||
useMessage,
|
||||
useNotification,
|
||||
NSpace,
|
||||
NText,
|
||||
NDataTable,
|
||||
@@ -166,13 +177,13 @@ import { useClipboard } from '@vueuse/core'
|
||||
import {
|
||||
DocumentTextOutline as DocumentTextIcon,
|
||||
DownloadOutline as DownloadIcon,
|
||||
NotificationsOutline as NotificationsIcon
|
||||
} from '@vicons/ionicons5';
|
||||
|
||||
import MarketTimeDisplay from './MarketTimeDisplay.vue';
|
||||
import ApiConfigPanel from './ApiConfigPanel.vue';
|
||||
import StockSearch from './StockSearch.vue';
|
||||
import StockCard from './StockCard.vue';
|
||||
import AnnouncementBanner from './AnnouncementBanner.vue';
|
||||
|
||||
import { apiService } from '@/services/api';
|
||||
import type { StockInfo, ApiConfig, StreamInitMessage, StreamAnalysisUpdate } from '@/types';
|
||||
@@ -181,7 +192,6 @@ import { validateMultipleStockCodes, MarketType } from '@/utils/stockValidator';
|
||||
|
||||
// 使用Naive UI的组件API
|
||||
const message = useMessage();
|
||||
const notification = useNotification();
|
||||
const { copy } = useClipboard();
|
||||
|
||||
// 从环境变量获取的默认配置
|
||||
@@ -189,6 +199,7 @@ const defaultApiUrl = ref('');
|
||||
const defaultApiModel = ref('');
|
||||
const defaultApiTimeout = ref('60');
|
||||
const announcement = ref('');
|
||||
const showAnnouncementBanner = ref(true);
|
||||
|
||||
// 股票分析配置
|
||||
const marketType = ref('A');
|
||||
@@ -206,29 +217,33 @@ const apiConfig = ref<ApiConfig>({
|
||||
saveApiConfig: false
|
||||
});
|
||||
|
||||
// 移动端检测
|
||||
const isMobile = computed(() => {
|
||||
return window.innerWidth <= 768;
|
||||
});
|
||||
|
||||
// 监听窗口大小变化
|
||||
function handleResize() {
|
||||
// 窗口大小变化时,isMobile计算属性会自动更新
|
||||
// 这里可以添加其他需要在窗口大小变化时执行的逻辑
|
||||
}
|
||||
|
||||
// 显示系统公告
|
||||
const showAnnouncement = (content: string) => {
|
||||
if (!content) return;
|
||||
|
||||
notification.info({
|
||||
title: '系统公告',
|
||||
content: () => h('div', { style: 'display: flex; align-items: center;' }, [
|
||||
h(NIcon, { component: NotificationsIcon, style: 'margin-right: 8px; font-size: 18px;' }),
|
||||
h('span', null, content)
|
||||
]),
|
||||
duration: 0, // 设置为0表示不会自动关闭
|
||||
keepAliveOnHover: true,
|
||||
closable: true
|
||||
});
|
||||
// 使用AnnouncementBanner组件显示公告
|
||||
announcement.value = content;
|
||||
showAnnouncementBanner.value = true;
|
||||
};
|
||||
|
||||
// 市场选项
|
||||
const marketOptions = [
|
||||
{ label: 'A股', value: 'A' },
|
||||
{ label: '港股', value: 'HK' },
|
||||
{ label: '美股', value: 'US' },
|
||||
{ label: 'ETF', value: 'ETF' },
|
||||
{ label: 'LOF', value: 'LOF' }
|
||||
{ label: '美股', value: 'US', showSearch: true },
|
||||
{ label: 'ETF', value: 'ETF', showSearch: true },
|
||||
{ label: 'LOF', value: 'LOF', showSearch: true }
|
||||
];
|
||||
|
||||
// 表格列定义
|
||||
@@ -384,6 +399,10 @@ const exportOptions = [
|
||||
}
|
||||
];
|
||||
|
||||
const showSearch = computed(() =>
|
||||
marketOptions.find(option => option.value === marketType.value)?.showSearch
|
||||
);
|
||||
|
||||
// 更新API配置
|
||||
function updateApiConfig(config: ApiConfig) {
|
||||
apiConfig.value = { ...config };
|
||||
@@ -397,10 +416,13 @@ function handleMarketTypeChange() {
|
||||
|
||||
// 添加选择的股票
|
||||
function addSelectedStock(symbol: string) {
|
||||
// 确保symbol不包含序号或其他不需要的信息
|
||||
const cleanSymbol = symbol.trim().replace(/^\d+\.\s*/, '');
|
||||
|
||||
if (stockCodes.value) {
|
||||
stockCodes.value += ', ' + symbol;
|
||||
stockCodes.value += ', ' + cleanSymbol;
|
||||
} else {
|
||||
stockCodes.value = symbol;
|
||||
stockCodes.value = cleanSymbol;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -911,6 +933,9 @@ function getChineseVolumeStatus(status: string): string {
|
||||
// 页面加载时获取默认配置和公告
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// 添加窗口大小变化监听
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// 从API获取配置信息
|
||||
const config = await apiService.getConfig();
|
||||
|
||||
@@ -938,6 +963,16 @@ onMounted(async () => {
|
||||
console.error('获取默认配置时出错:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// 组件销毁前移除事件监听
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
// 处理公告关闭事件
|
||||
function handleAnnouncementClose() {
|
||||
showAnnouncementBanner.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -946,6 +981,8 @@ onMounted(async () => {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
padding-bottom: 20px; /* 增加底部内边距 */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.main-layout {
|
||||
@@ -953,6 +990,7 @@ onMounted(async () => {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
min-height: calc(100vh - 20px); /* 确保至少占满视口高度减去底部空间 */
|
||||
}
|
||||
|
||||
.main-content {
|
||||
@@ -969,7 +1007,12 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.analysis-container {
|
||||
margin-bottom: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* 修改卡片内容区域的内边距 */
|
||||
.analysis-container :deep(.n-card__content) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
@@ -998,4 +1041,256 @@ onMounted(async () => {
|
||||
text-overflow: ellipsis;
|
||||
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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<n-card class="stock-card" :bordered="false" :class="{ 'is-analyzing': isAnalyzing }">
|
||||
<div class="card-header">
|
||||
<n-card class="stock-card mobile-card mobile-shadow mobile-stock-card" :bordered="false" :class="{ 'is-analyzing': isAnalyzing }">
|
||||
<div class="card-header mobile-card-header">
|
||||
<div class="header-main">
|
||||
<div class="header-left">
|
||||
<div class="stock-info">
|
||||
@@ -126,7 +126,10 @@
|
||||
</template>
|
||||
|
||||
<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 v-else-if="stock.analysisStatus === 'completed'">
|
||||
@@ -138,7 +141,7 @@
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
AlertCircleOutline as AlertCircleIcon,
|
||||
@@ -159,22 +162,17 @@ const isAnalyzing = computed(() => {
|
||||
});
|
||||
|
||||
const lastAnalysisLength = ref(0);
|
||||
const lastAnalysisText = ref('');
|
||||
|
||||
// 监听分析内容变化
|
||||
watch(() => props.stock.analysis, (newVal) => {
|
||||
if (newVal && props.stock.analysisStatus === 'analyzing') {
|
||||
lastAnalysisLength.value = newVal.length;
|
||||
lastAnalysisText.value = newVal;
|
||||
}
|
||||
}, { 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(() => {
|
||||
if (props.stock.analysis) {
|
||||
let result = parseMarkdown(props.stock.analysis);
|
||||
@@ -414,6 +412,99 @@ const getStatusText = computed(() => {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -423,6 +514,8 @@ const getStatusText = computed(() => {
|
||||
flex-direction: column;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
width: 100%; /* 确保宽度不会超过容器 */
|
||||
max-width: 100%; /* 限制最大宽度 */
|
||||
}
|
||||
|
||||
.stock-card.is-analyzing {
|
||||
@@ -439,6 +532,7 @@ const getStatusText = computed(() => {
|
||||
position: relative;
|
||||
background: linear-gradient(to bottom, rgba(240, 240, 245, 0.3), transparent);
|
||||
border-radius: 8px 8px 0 0;
|
||||
width: 100%; /* 确保宽度不会超过容器 */
|
||||
}
|
||||
|
||||
.header-main {
|
||||
@@ -525,6 +619,7 @@ const getStatusText = computed(() => {
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
@@ -738,6 +833,8 @@ const getStatusText = computed(() => {
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%; /* 确保宽度不会超过容器 */
|
||||
overflow-x: hidden; /* 防止内容横向溢出 */
|
||||
}
|
||||
|
||||
.error-status {
|
||||
@@ -771,10 +868,18 @@ const getStatusText = computed(() => {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
display: block; /* 确保显示为块级元素 */
|
||||
box-sizing: border-box; /* 确保padding不增加宽度 */
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
scrollbar-width: thin; /* 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浏览器的滚动条样式 */
|
||||
@@ -807,6 +912,13 @@ const getStatusText = computed(() => {
|
||||
position: relative;
|
||||
border-left: 2px solid var(--n-info-color);
|
||||
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;
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
white-space: pre-wrap; /* 允许代码内容自动换行 */
|
||||
word-break: break-word; /* 确保长单词可以换行 */
|
||||
}
|
||||
|
||||
.analysis-result :deep(pre) {
|
||||
@@ -896,12 +1010,16 @@ const getStatusText = computed(() => {
|
||||
margin: 0.75rem 0;
|
||||
border-left: 3px solid #2080f0;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
white-space: pre-wrap; /* 允许代码块自动换行 */
|
||||
word-break: break-word; /* 允许长单词换行 */
|
||||
}
|
||||
|
||||
.analysis-result :deep(pre code) {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
white-space: inherit; /* 继承pre的换行行为 */
|
||||
}
|
||||
|
||||
/* 优化引用样式 */
|
||||
@@ -923,6 +1041,8 @@ const getStatusText = computed(() => {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
table-layout: fixed; /* 固定表格布局 */
|
||||
max-width: 100%;
|
||||
display: block; /* 使表格成为块级元素 */
|
||||
overflow-x: auto; /* 允许表格滚动 */
|
||||
}
|
||||
|
||||
.analysis-result :deep(th), .analysis-result :deep(td) {
|
||||
@@ -994,6 +1114,10 @@ const getStatusText = computed(() => {
|
||||
border-bottom: 1px dotted #2080f0;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.analysis-result :deep(a:hover) {
|
||||
@@ -1009,5 +1133,682 @@ const getStatusText = computed(() => {
|
||||
margin: 0.75rem auto;
|
||||
border-radius: 4px;
|
||||
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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="stock-search-container">
|
||||
<n-input
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="输入股票代码或名称搜索"
|
||||
placeholder="输入代码或名称搜索"
|
||||
@input="handleSearchInput"
|
||||
@blur="handleBlur"
|
||||
@focus="handleFocus"
|
||||
@@ -13,14 +13,14 @@
|
||||
</template>
|
||||
</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">
|
||||
<n-spin size="small" />
|
||||
<span>搜索中...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="results.length === 0 && searchKeyword" class="no-results">
|
||||
未找到相关股票
|
||||
未找到相关数据
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
@@ -28,17 +28,17 @@
|
||||
<div
|
||||
v-for="item in results"
|
||||
:key="item.symbol"
|
||||
class="search-result-item"
|
||||
class="search-result-item mobile-search-result-item"
|
||||
@click="selectStock(item)"
|
||||
>
|
||||
<div class="result-symbol-name">
|
||||
<span class="result-symbol">{{ item.symbol }}</span>
|
||||
<span class="result-name">{{ item.name }}</span>
|
||||
<span class="result-name mobile-result-name">{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="result-meta">
|
||||
<span class="result-market">{{ item.market }}</span>
|
||||
<span v-if="item.marketValue" class="result-market-value">
|
||||
市值: {{ formatMarketValue(item.marketValue) }}
|
||||
<span v-if="item.market_value" class="result-market-value">
|
||||
市值: {{ formatMarketValue(item.market_value) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,11 +87,13 @@ const debouncedSearch = debounce(async (keyword: string) => {
|
||||
// 限制只显示前10个结果
|
||||
results.value = searchResults.slice(0, 10);
|
||||
} else {
|
||||
// 其他市场搜索 (后端需要实现对应的接口)
|
||||
results.value = [];
|
||||
// 基金搜索
|
||||
const searchResults = await apiService.searchFunds(keyword);
|
||||
// 限制只显示前10个结果
|
||||
results.value = searchResults.slice(0, 10);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索股票时出错:', error);
|
||||
console.error('搜索数据时出错:', error);
|
||||
results.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -104,7 +106,10 @@ function handleSearchInput() {
|
||||
}
|
||||
|
||||
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 = '';
|
||||
showResults.value = false;
|
||||
}
|
||||
@@ -219,4 +224,32 @@ onBeforeUnmount(() => {
|
||||
.result-market-value {
|
||||
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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import './assets/css/global.css'
|
||||
import './assets/styles/mobile.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
try {
|
||||
|
||||
@@ -24,10 +24,9 @@ a:hover {
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f7;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@@ -63,6 +62,8 @@ button:focus-visible {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -113,3 +114,97 @@ input::-moz-focus-inner {
|
||||
outline: none !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;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,12 +43,13 @@ export interface SearchResult {
|
||||
symbol: string;
|
||||
name: string;
|
||||
market: string;
|
||||
marketValue?: number;
|
||||
market_value?: number;
|
||||
}
|
||||
|
||||
export interface MarketStatus {
|
||||
isOpen: boolean;
|
||||
nextTime: string;
|
||||
progressPercentage?: number;
|
||||
}
|
||||
|
||||
export interface MarketTimeInfo {
|
||||
|
||||
@@ -7,7 +7,7 @@ pandas==2.2.2
|
||||
scipy==1.15.1
|
||||
|
||||
# 数据获取和分析库
|
||||
akshare==1.16.35
|
||||
akshare==1.17.44
|
||||
tqdm==4.67.1
|
||||
|
||||
# Web框架与异步处理
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
import httpx
|
||||
import re
|
||||
from typing import Dict, List, Optional, Any, Generator, AsyncGenerator
|
||||
from typing import AsyncGenerator
|
||||
from dotenv import load_dotenv
|
||||
from logger import get_logger
|
||||
from utils.logger import get_logger
|
||||
from utils.api_utils import APIUtils
|
||||
from datetime import datetime
|
||||
|
||||
@@ -268,13 +266,17 @@ class AIAnalyzer:
|
||||
chunk_data = json.loads(line)
|
||||
|
||||
# 检查是否有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":
|
||||
logger.debug("收到finish_reason=stop,流结束")
|
||||
continue
|
||||
|
||||
# 获取delta内容
|
||||
delta = chunk_data.get("choices", [{}])[0].get("delta", {})
|
||||
delta = choices[0].get("delta", {})
|
||||
|
||||
# 检查delta是否为空对象
|
||||
if not delta or delta == {}:
|
||||
@@ -352,7 +354,16 @@ class AIAnalyzer:
|
||||
return
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import asyncio
|
||||
import pandas as pd
|
||||
from typing import List, Dict, Any, Optional
|
||||
from logger import get_logger
|
||||
from utils.logger import get_logger
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# 获取日志器
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Tuple, Any, AsyncGenerator
|
||||
from logger import get_logger
|
||||
from typing import List, AsyncGenerator
|
||||
from utils.logger import get_logger
|
||||
from services.stock_data_provider import StockDataProvider
|
||||
from services.technical_indicator import TechnicalIndicator
|
||||
from services.stock_scorer import StockScorer
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from datetime import datetime, timedelta
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
from logger import get_logger
|
||||
import re
|
||||
from utils.logger import get_logger
|
||||
|
||||
# 获取日志器
|
||||
logger = get_logger()
|
||||
@@ -187,9 +184,16 @@ class StockDataProvider:
|
||||
except Exception as e:
|
||||
logger.warning(f"日期过滤出错: {str(e)},返回原始数据")
|
||||
|
||||
elif market_type in ['ETF', 'LOF']:
|
||||
elif market_type in ['ETF']:
|
||||
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,
|
||||
start_date=start_date.replace('-', ''),
|
||||
end_date=end_date.replace('-', '')
|
||||
@@ -233,7 +237,7 @@ class StockDataProvider:
|
||||
|
||||
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:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from typing import Dict, Optional, Any, List, Tuple
|
||||
from logger import get_logger
|
||||
from typing import Dict, List, Tuple
|
||||
from utils.logger import get_logger
|
||||
|
||||
# 获取日志器
|
||||
logger = get_logger()
|
||||
@@ -14,7 +13,7 @@ class StockScorer:
|
||||
|
||||
def __init__(self):
|
||||
"""初始化股票评分服务"""
|
||||
logger.debug("初始化StockScorer")
|
||||
logger.debug("初始化StockScorer股票评分服务")
|
||||
|
||||
def calculate_score(self, df: pd.DataFrame) -> int:
|
||||
"""
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from typing import Dict, Optional, Any
|
||||
from logger import get_logger
|
||||
from utils.logger import get_logger
|
||||
|
||||
# 获取日志器
|
||||
logger = get_logger()
|
||||
@@ -29,7 +28,7 @@ class TechnicalIndicator:
|
||||
'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:
|
||||
"""
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import asyncio
|
||||
import pandas as pd
|
||||
from typing import List, Dict, Any, Optional
|
||||
from logger import get_logger
|
||||
from utils.logger import get_logger
|
||||
|
||||
# 获取日志器
|
||||
logger = get_logger()
|
||||
|
||||
class USStockServiceAsync:
|
||||
"""
|
||||
异步美股服务
|
||||
提供美股数据的异步搜索和获取功能
|
||||
美股服务
|
||||
提供美股数据的搜索和获取功能
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化异步美股服务"""
|
||||
"""初始化美股服务"""
|
||||
logger.debug("初始化USStockServiceAsync")
|
||||
|
||||
# 可选:添加缓存以减少频繁请求
|
||||
|
||||
12
tests/test_akshare.py
Normal file
12
tests/test_akshare.py
Normal 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)
|
||||
@@ -1,13 +1,13 @@
|
||||
import os
|
||||
import requests
|
||||
import json
|
||||
from logger import get_logger, get_stream_logger
|
||||
from utils.logger import get_logger
|
||||
from dotenv import load_dotenv
|
||||
from utils.api_utils import APIUtils
|
||||
|
||||
# 获取日志器
|
||||
logger = get_logger()
|
||||
stream_logger = get_stream_logger()
|
||||
|
||||
|
||||
def _truncate_json_for_logging(json_obj, max_length=500):
|
||||
"""截断JSON对象用于日志记录,避免日志过大
|
||||
@@ -38,7 +38,7 @@ def test_api_stream():
|
||||
# 获取API配置
|
||||
api_url = os.getenv('API_URL')
|
||||
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}")
|
||||
|
||||
@@ -117,7 +117,7 @@ def test_api_stream():
|
||||
json_data = json.loads(data_content)
|
||||
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', {})
|
||||
content = delta.get('content', '')
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from loguru import logger
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime
|
||||
import shutil
|
||||
|
||||
|
||||
# 创建日志目录
|
||||
log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
|
||||
@@ -1,8 +1,8 @@
|
||||
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.middleware.cors import CORSMiddleware
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any, Generator
|
||||
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
|
||||
import os
|
||||
import httpx
|
||||
from logger import get_logger
|
||||
from utils.logger import get_logger
|
||||
from utils.api_utils import APIUtils
|
||||
from dotenv import load_dotenv, dotenv_values
|
||||
from dotenv import load_dotenv
|
||||
import uvicorn
|
||||
import json
|
||||
import secrets
|
||||
|
||||
Reference in New Issue
Block a user