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
|
*.spec
|
||||||
*.zip
|
*.zip
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
# frontend
|
# frontend
|
||||||
node_modules/
|
node_modules/
|
||||||
@@ -38,6 +39,7 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
.vite
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
11
Dockerfile
11
Dockerfile
@@ -1,5 +1,5 @@
|
|||||||
# 阶段一: 构建Vue前端
|
# 阶段一: 构建Vue前端
|
||||||
FROM node:18-alpine as frontend-builder
|
FROM node:18-alpine AS frontend-builder
|
||||||
|
|
||||||
# 设置工作目录
|
# 设置工作目录
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
@@ -13,18 +13,21 @@ RUN npm ci
|
|||||||
# 复制前端源代码
|
# 复制前端源代码
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
|
|
||||||
|
# 确保node_modules中的可执行文件有正确权限
|
||||||
|
RUN chmod +x node_modules/.bin/*
|
||||||
|
|
||||||
# 构建前端应用
|
# 构建前端应用
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# 阶段二: 构建Python后端
|
# 阶段二: 构建Python后端
|
||||||
FROM python:3.10-slim as backend-builder
|
FROM python:3.10-slim AS backend-builder
|
||||||
|
|
||||||
# 设置工作目录
|
# 设置工作目录
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 安装系统依赖和构建依赖
|
# 安装系统依赖和构建依赖
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
libgl1-mesa-glx \
|
libgl1 \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
build-essential \
|
build-essential \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
@@ -43,7 +46,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
# 安装运行时依赖
|
# 安装运行时依赖
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
libgl1-mesa-glx \
|
libgl1 \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
29
Dockerfile.backend
Normal file
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:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
platform: linux/amd64
|
||||||
container_name: stock-scanner-app
|
container_name: stock-scanner-app
|
||||||
ports:
|
ports:
|
||||||
- "8888:8888"
|
- "8888:8888"
|
||||||
|
|||||||
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>
|
<template>
|
||||||
<div v-if="showAnnouncement" class="announcement-container">
|
<div v-if="showAnnouncement" class="announcement-container mobile-announcement-container" :class="{ 'login-page-announcement mobile-login-announcement': isLoginPage }">
|
||||||
<n-card class="announcement-card">
|
<n-card class="announcement-card mobile-card" :class="{ 'login-card-style': isLoginPage }">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="announcement-header">
|
<div class="announcement-header mobile-announcement-header">
|
||||||
<n-icon size="18" :component="InformationCircleIcon" class="info-icon" />
|
<n-icon size="18" :component="InformationCircleIcon" class="info-icon" />
|
||||||
<span>系统公告</span>
|
<span>系统公告</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="announcement-content" v-html="processedContent"></div>
|
<div class="announcement-content mobile-announcement-content" v-html="processedContent"></div>
|
||||||
<div class="announcement-timer">{{ remainingTimeText }}</div>
|
<div class="announcement-timer mobile-announcement-timer">{{ remainingTimeText }}</div>
|
||||||
<template #action>
|
<template #action>
|
||||||
<n-button quaternary circle size="small" @click="closeAnnouncement">
|
<n-button quaternary circle size="small" @click="closeAnnouncement" class="mobile-touch-target">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<n-icon :component="CloseIcon" />
|
<n-icon :component="CloseIcon" />
|
||||||
</template>
|
</template>
|
||||||
@@ -25,12 +25,20 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
|||||||
import { NCard, NIcon, NButton } from 'naive-ui';
|
import { NCard, NIcon, NButton } from 'naive-ui';
|
||||||
import { InformationCircleOutline as InformationCircleIcon } from '@vicons/ionicons5';
|
import { InformationCircleOutline as InformationCircleIcon } from '@vicons/ionicons5';
|
||||||
import { Close as CloseIcon } from '@vicons/ionicons5';
|
import { Close as CloseIcon } from '@vicons/ionicons5';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
content: string;
|
content: string;
|
||||||
autoCloseTime?: number;
|
autoCloseTime?: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const isLoginPage = computed(() => route.path === '/login');
|
||||||
|
|
||||||
const showAnnouncement = ref(true);
|
const showAnnouncement = ref(true);
|
||||||
const remainingTime = ref(props.autoCloseTime || 5);
|
const remainingTime = ref(props.autoCloseTime || 5);
|
||||||
const timer = ref<number | null>(null);
|
const timer = ref<number | null>(null);
|
||||||
@@ -54,6 +62,7 @@ function closeAnnouncement() {
|
|||||||
window.clearInterval(timer.value);
|
window.clearInterval(timer.value);
|
||||||
timer.value = null;
|
timer.value = null;
|
||||||
}
|
}
|
||||||
|
emit('close');
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTimer() {
|
function updateTimer() {
|
||||||
@@ -87,6 +96,11 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.announcement-card {
|
.announcement-card {
|
||||||
border-left: 4px solid var(--n-primary-color);
|
border-left: 4px solid var(--n-primary-color);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
background-color: rgba(255, 255, 255, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.announcement-header {
|
.announcement-header {
|
||||||
@@ -125,4 +139,19 @@ onBeforeUnmount(() => {
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 登录页面适配 */
|
||||||
|
.login-page-announcement {
|
||||||
|
z-index: 1000;
|
||||||
|
top: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card-style {
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-left: 4px solid #2080f0;
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="api-config-section">
|
<div class="api-config-section mobile-api-config-section">
|
||||||
<n-button
|
<n-button
|
||||||
class="toggle-button"
|
class="toggle-button mobile-touch-target mobile-toggle-button"
|
||||||
size="small"
|
size="small"
|
||||||
@click="toggleConfig"
|
@click="toggleConfig"
|
||||||
:quaternary="true"
|
:quaternary="true"
|
||||||
@@ -10,12 +10,12 @@
|
|||||||
<template #icon>
|
<template #icon>
|
||||||
<n-icon :component="expanded ? ChevronUpIcon : ChevronDownIcon" />
|
<n-icon :component="expanded ? ChevronUpIcon : ChevronDownIcon" />
|
||||||
</template>
|
</template>
|
||||||
<span class="toggle-text">API配置 {{ expanded ? '收起' : '展开' }}</span>
|
<span class="toggle-text mobile-toggle-text">API配置 {{ expanded ? '收起' : '展开' }}</span>
|
||||||
</n-button>
|
</n-button>
|
||||||
|
|
||||||
<n-collapse-transition :show="expanded">
|
<n-collapse-transition :show="expanded">
|
||||||
<n-card class="api-config-card" :bordered="false">
|
<n-card class="api-config-card mobile-card mobile-shadow mobile-api-config-card" :bordered="false">
|
||||||
<n-alert title="OpenAI API配置" type="info" v-if="isApiInfoVisible" class="api-info-alert">
|
<n-alert title="OpenAI API配置" type="info" v-if="isApiInfoVisible" class="api-info-alert mobile-api-info-alert mobile-api-info-alert-small">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<n-icon :component="InformationCircleIcon" />
|
<n-icon :component="InformationCircleIcon" />
|
||||||
</template>
|
</template>
|
||||||
@@ -29,9 +29,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</n-alert>
|
</n-alert>
|
||||||
|
|
||||||
<n-grid :cols="24" :x-gap="16" :y-gap="16">
|
<n-grid :cols="24" :x-gap="16" :y-gap="16" responsive="screen" class="mobile-grid mobile-grid-small">
|
||||||
<n-grid-item :span="24" :lg-span="14">
|
<n-grid-item :span="24" :md-span="14" :lg-span="14" class="mobile-grid-item mobile-grid-item-small">
|
||||||
<n-form-item label="API URL" path="apiUrl">
|
<n-form-item label="API URL" path="apiUrl" class="mobile-form-item">
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="apiConfig.apiUrl"
|
v-model:value="apiConfig.apiUrl"
|
||||||
placeholder="https://api.openai.com/v1/chat/completions"
|
placeholder="https://api.openai.com/v1/chat/completions"
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</n-input>
|
</n-input>
|
||||||
<template #feedback>
|
<template #feedback>
|
||||||
<div class="url-feedback">
|
<div class="url-feedback mobile-url-feedback">
|
||||||
<span class="formatted-url">实际请求地址: {{ formattedUrl }}</span>
|
<span class="formatted-url">实际请求地址: {{ formattedUrl }}</span>
|
||||||
<div class="url-tips">
|
<div class="url-tips">
|
||||||
<div>提示: URL以/结尾将忽略v1路径</div>
|
<div>提示: URL以/结尾将忽略v1路径</div>
|
||||||
@@ -54,8 +54,8 @@
|
|||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
|
|
||||||
<n-grid-item :span="24" :lg-span="10">
|
<n-grid-item :span="24" :md-span="10" :lg-span="10" class="mobile-grid-item mobile-grid-item-small">
|
||||||
<n-form-item label="API Key" path="apiKey">
|
<n-form-item label="API Key" path="apiKey" class="mobile-form-item">
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="apiConfig.apiKey"
|
v-model:value="apiConfig.apiKey"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -71,8 +71,8 @@
|
|||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
|
|
||||||
<n-grid-item :span="12" :lg-span="12">
|
<n-grid-item :span="12" :md-span="12" :lg-span="12" class="mobile-grid-item mobile-grid-item-small">
|
||||||
<n-form-item label="模型" path="apiModel">
|
<n-form-item label="模型" path="apiModel" class="mobile-form-item">
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="apiConfig.apiModel"
|
v-model:value="apiConfig.apiModel"
|
||||||
placeholder="输入或选择模型名称"
|
placeholder="输入或选择模型名称"
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
<div class="model-suggestions">
|
<div class="model-suggestions">
|
||||||
<div class="model-tip">您可以直接输入模型名称,或点击右侧按钮从下拉菜单选择</div>
|
<div class="model-tip">您可以直接输入模型名称,或点击右侧按钮从下拉菜单选择</div>
|
||||||
<span>常用模型:</span>
|
<span>常用模型:</span>
|
||||||
<div class="model-chips">
|
<div class="model-chips mobile-model-chips">
|
||||||
<n-tag
|
<n-tag
|
||||||
v-for="model in commonModels"
|
v-for="model in commonModels"
|
||||||
:key="model.key"
|
:key="model.key"
|
||||||
@@ -109,6 +109,7 @@
|
|||||||
round
|
round
|
||||||
clickable
|
clickable
|
||||||
@click="selectModel(model.key)"
|
@click="selectModel(model.key)"
|
||||||
|
class="mobile-model-tag"
|
||||||
>
|
>
|
||||||
{{ model.label }}
|
{{ model.label }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
@@ -118,8 +119,8 @@
|
|||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
|
|
||||||
<n-grid-item :span="12" :lg-span="12">
|
<n-grid-item :span="12" :md-span="12" :lg-span="12" class="mobile-grid-item mobile-grid-item-small">
|
||||||
<n-form-item label="超时时间(秒)" path="apiTimeout">
|
<n-form-item label="超时时间(秒)" path="apiTimeout" class="mobile-form-item">
|
||||||
<n-input-number
|
<n-input-number
|
||||||
v-model:value="apiTimeout"
|
v-model:value="apiTimeout"
|
||||||
placeholder="60"
|
placeholder="60"
|
||||||
@@ -144,13 +145,14 @@
|
|||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
</n-grid>
|
</n-grid>
|
||||||
|
|
||||||
<div class="api-actions">
|
<div class="api-actions mobile-api-actions">
|
||||||
<div class="api-save-option">
|
<div class="api-save-option mobile-api-save-option">
|
||||||
<n-button
|
<n-button
|
||||||
tertiary
|
tertiary
|
||||||
size="small"
|
size="small"
|
||||||
@click="saveConfig"
|
@click="saveConfig"
|
||||||
round
|
round
|
||||||
|
class="mobile-api-save-option-button"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<n-icon :component="SaveIcon" />
|
<n-icon :component="SaveIcon" />
|
||||||
@@ -159,13 +161,14 @@
|
|||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="api-buttons">
|
<div class="api-buttons mobile-api-buttons mobile-api-buttons-small">
|
||||||
<n-button
|
<n-button
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="testingConnection"
|
:loading="testingConnection"
|
||||||
:disabled="!isConfigValid"
|
:disabled="!isConfigValid"
|
||||||
@click="testConnection"
|
@click="testConnection"
|
||||||
round
|
round
|
||||||
|
class="mobile-api-button"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<n-icon :component="CheckmarkIcon" />
|
<n-icon :component="CheckmarkIcon" />
|
||||||
@@ -173,7 +176,7 @@
|
|||||||
测试连接
|
测试连接
|
||||||
</n-button>
|
</n-button>
|
||||||
|
|
||||||
<n-button @click="resetConfig" round>
|
<n-button @click="resetConfig" round class="mobile-api-button">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<n-icon :component="RefreshIcon" />
|
<n-icon :component="RefreshIcon" />
|
||||||
</template>
|
</template>
|
||||||
@@ -184,7 +187,7 @@
|
|||||||
|
|
||||||
<n-divider v-if="connectionStatus" style="margin: 16px 0 12px" />
|
<n-divider v-if="connectionStatus" style="margin: 16px 0 12px" />
|
||||||
|
|
||||||
<div v-if="connectionStatus" class="connection-status" :class="connectionStatus.type">
|
<div v-if="connectionStatus" class="connection-status mobile-connection-status" :class="connectionStatus.type">
|
||||||
<n-icon :component="connectionStatus.icon" class="status-icon" />
|
<n-icon :component="connectionStatus.icon" class="status-icon" />
|
||||||
<span class="status-message">{{ connectionStatus.message }}</span>
|
<span class="status-message">{{ connectionStatus.message }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -465,8 +468,9 @@ onMounted(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.api-config-section {
|
.api-config-section {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 2rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-button {
|
.toggle-button {
|
||||||
@@ -487,12 +491,14 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.api-config-card {
|
.api-config-card {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1.5rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||||
background: linear-gradient(to bottom, rgba(240, 240, 245, 0.5), rgba(250, 250, 252, 0.8));
|
background: linear-gradient(to bottom, rgba(240, 240, 245, 0.5), rgba(250, 250, 252, 0.8));
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
overflow: visible;
|
||||||
|
min-height: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-info-alert {
|
.api-info-alert {
|
||||||
@@ -608,18 +614,6 @@ onMounted(() => {
|
|||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.api-actions {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.api-buttons {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-suggestions {
|
.model-suggestions {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="login-container">
|
<div class="login-container">
|
||||||
|
<!-- 公告横幅 -->
|
||||||
|
<AnnouncementBanner
|
||||||
|
v-if="announcement && showAnnouncementBanner"
|
||||||
|
:content="announcement"
|
||||||
|
:auto-close-time="5"
|
||||||
|
@close="handleAnnouncementClose"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="login-background">
|
<div class="login-background">
|
||||||
<div class="login-shape shape1"></div>
|
<div class="login-shape shape1"></div>
|
||||||
<div class="login-shape shape2"></div>
|
<div class="login-shape shape2"></div>
|
||||||
@@ -69,7 +77,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted, h } from 'vue';
|
import { ref, reactive, onMounted } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import {
|
import {
|
||||||
NCard,
|
NCard,
|
||||||
@@ -80,22 +88,22 @@ import {
|
|||||||
NIcon,
|
NIcon,
|
||||||
NText,
|
NText,
|
||||||
useMessage,
|
useMessage,
|
||||||
useNotification
|
|
||||||
} from 'naive-ui';
|
} from 'naive-ui';
|
||||||
import type { FormInst, FormRules } from 'naive-ui';
|
import type { FormInst, FormRules } from 'naive-ui';
|
||||||
import {
|
import {
|
||||||
BarChartOutline as BarChartIcon,
|
BarChartOutline as BarChartIcon,
|
||||||
LockClosedOutline as LockClosedIcon,
|
LockClosedOutline as LockClosedIcon,
|
||||||
NotificationsOutline as NotificationsIcon
|
|
||||||
} from '@vicons/ionicons5';
|
} from '@vicons/ionicons5';
|
||||||
import { apiService } from '@/services/api';
|
import { apiService } from '@/services/api';
|
||||||
import type { LoginRequest } from '@/types';
|
import type { LoginRequest } from '@/types';
|
||||||
|
import AnnouncementBanner from '@/components/AnnouncementBanner.vue';
|
||||||
|
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
const notification = useNotification();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const formRef = ref<FormInst | null>(null);
|
const formRef = ref<FormInst | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const announcement = ref('');
|
||||||
|
const showAnnouncementBanner = ref(true);
|
||||||
|
|
||||||
const formValue = reactive({
|
const formValue = reactive({
|
||||||
password: ''
|
password: ''
|
||||||
@@ -114,16 +122,14 @@ const rules: FormRules = {
|
|||||||
const showAnnouncement = (content: string) => {
|
const showAnnouncement = (content: string) => {
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
|
|
||||||
notification.info({
|
// 使用AnnouncementBanner组件显示公告
|
||||||
title: '系统公告',
|
announcement.value = content;
|
||||||
content: () => h('div', { style: 'display: flex; align-items: center;' }, [
|
showAnnouncementBanner.value = true;
|
||||||
h(NIcon, { component: NotificationsIcon, style: 'margin-right: 8px; font-size: 18px;' }),
|
};
|
||||||
h('span', null, content)
|
|
||||||
]),
|
// 处理公告关闭事件
|
||||||
duration: 10000,
|
const handleAnnouncementClose = () => {
|
||||||
keepAliveOnHover: true,
|
showAnnouncementBanner.value = false;
|
||||||
closable: true
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 页面加载时检查是否已登录并获取系统公告
|
// 页面加载时检查是否已登录并获取系统公告
|
||||||
@@ -265,6 +271,11 @@ html, body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 确保公告在登录页面上方显示 */
|
||||||
|
:deep(.announcement-container) {
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
.login-background {
|
.login-background {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -1,65 +1,95 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-card class="market-time-card">
|
<n-card class="market-time-card mobile-card mobile-shadow mobile-market-time-card">
|
||||||
<n-grid :x-gap="16" :y-gap="16" :cols="gridCols">
|
<n-grid :x-gap="16" :y-gap="16" cols="1 s:2 m:4" responsive="screen">
|
||||||
<!-- 当前时间 -->
|
<!-- 当前时间 -->
|
||||||
<n-grid-item>
|
<n-grid-item>
|
||||||
<div class="time-block">
|
<div class="time-block current-time-block mobile-time-block">
|
||||||
<p class="time-label">当前时间</p>
|
<p class="time-label mobile-time-label">当前时间</p>
|
||||||
<p class="current-time">{{ marketInfo.currentTime }}</p>
|
<p class="current-time mobile-current-time">{{ marketInfo.currentTime }}</p>
|
||||||
</div>
|
</div>
|
||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
|
|
||||||
<!-- A股状态 -->
|
<!-- A股状态 -->
|
||||||
<n-grid-item>
|
<n-grid-item>
|
||||||
<div class="time-block">
|
<div class="time-block market-block mobile-time-block" :class="{'market-open-block mobile-market-open-block': marketInfo.cnMarket.isOpen, 'market-closed-block mobile-market-closed-block': !marketInfo.cnMarket.isOpen}">
|
||||||
<p class="time-label">A股市场</p>
|
<p class="time-label mobile-time-label">A股市场</p>
|
||||||
<div class="market-status" :class="marketInfo.cnMarket.isOpen ? 'status-open' : 'status-closed'">
|
<div class="market-status" :class="marketInfo.cnMarket.isOpen ? 'status-open' : 'status-closed'">
|
||||||
<n-tag v-if="marketInfo.cnMarket.isOpen" type="success" size="medium" round>
|
<n-tag v-if="marketInfo.cnMarket.isOpen" type="success" size="medium" round class="status-tag mobile-touch-target mobile-status-tag">
|
||||||
<template #icon><n-icon size="18"><pulse-icon /></n-icon></template>
|
<template #icon><n-icon size="18"><pulse-icon /></n-icon></template>
|
||||||
交易中
|
交易中
|
||||||
</n-tag>
|
</n-tag>
|
||||||
<n-tag v-else type="default" size="medium" round>
|
<n-tag v-else type="default" size="medium" round class="status-tag mobile-touch-target mobile-status-tag">
|
||||||
<template #icon><n-icon size="18"><time-icon /></n-icon></template>
|
<template #icon><n-icon size="18"><time-icon /></n-icon></template>
|
||||||
已休市
|
已休市
|
||||||
</n-tag>
|
</n-tag>
|
||||||
</div>
|
</div>
|
||||||
<p class="time-counter">{{ marketInfo.cnMarket.nextTime }}</p>
|
<p class="time-counter mobile-time-counter">{{ marketInfo.cnMarket.nextTime }}</p>
|
||||||
|
<div class="market-progress-container">
|
||||||
|
<div class="market-progress-bar"
|
||||||
|
:class="marketInfo.cnMarket.isOpen ? 'progress-open' : 'progress-closed'"
|
||||||
|
:style="{ width: marketInfo.cnMarket.progressPercentage + '%' }">
|
||||||
|
</div>
|
||||||
|
<div class="progress-markers" :class="{'reverse-markers': !marketInfo.cnMarket.isOpen}">
|
||||||
|
<div class="progress-marker" :class="marketInfo.cnMarket.isOpen ? 'start' : 'end'">开盘</div>
|
||||||
|
<div class="progress-marker" :class="marketInfo.cnMarket.isOpen ? 'end' : 'start'">收盘</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
|
|
||||||
<!-- 港股状态 -->
|
<!-- 港股状态 -->
|
||||||
<n-grid-item>
|
<n-grid-item>
|
||||||
<div class="time-block">
|
<div class="time-block market-block" :class="{'market-open-block': marketInfo.hkMarket.isOpen, 'market-closed-block': !marketInfo.hkMarket.isOpen}">
|
||||||
<p class="time-label">港股市场</p>
|
<p class="time-label">港股市场</p>
|
||||||
<div class="market-status" :class="marketInfo.hkMarket.isOpen ? 'status-open' : 'status-closed'">
|
<div class="market-status" :class="marketInfo.hkMarket.isOpen ? 'status-open' : 'status-closed'">
|
||||||
<n-tag v-if="marketInfo.hkMarket.isOpen" type="success" size="medium" round>
|
<n-tag v-if="marketInfo.hkMarket.isOpen" type="success" size="medium" round class="status-tag mobile-touch-target">
|
||||||
<template #icon><n-icon size="18"><pulse-icon /></n-icon></template>
|
<template #icon><n-icon size="18"><pulse-icon /></n-icon></template>
|
||||||
交易中
|
交易中
|
||||||
</n-tag>
|
</n-tag>
|
||||||
<n-tag v-else type="default" size="medium" round>
|
<n-tag v-else type="default" size="medium" round class="status-tag mobile-touch-target">
|
||||||
<template #icon><n-icon size="18"><time-icon /></n-icon></template>
|
<template #icon><n-icon size="18"><time-icon /></n-icon></template>
|
||||||
已休市
|
已休市
|
||||||
</n-tag>
|
</n-tag>
|
||||||
</div>
|
</div>
|
||||||
<p class="time-counter">{{ marketInfo.hkMarket.nextTime }}</p>
|
<p class="time-counter">{{ marketInfo.hkMarket.nextTime }}</p>
|
||||||
|
<div class="market-progress-container">
|
||||||
|
<div class="market-progress-bar"
|
||||||
|
:class="marketInfo.hkMarket.isOpen ? 'progress-open' : 'progress-closed'"
|
||||||
|
:style="{ width: marketInfo.hkMarket.progressPercentage + '%' }">
|
||||||
|
</div>
|
||||||
|
<div class="progress-markers" :class="{'reverse-markers': !marketInfo.hkMarket.isOpen}">
|
||||||
|
<div class="progress-marker" :class="marketInfo.hkMarket.isOpen ? 'start' : 'end'">开盘</div>
|
||||||
|
<div class="progress-marker" :class="marketInfo.hkMarket.isOpen ? 'end' : 'start'">收盘</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
|
|
||||||
<!-- 美股状态 -->
|
<!-- 美股状态 -->
|
||||||
<n-grid-item>
|
<n-grid-item>
|
||||||
<div class="time-block">
|
<div class="time-block market-block" :class="{'market-open-block': marketInfo.usMarket.isOpen, 'market-closed-block': !marketInfo.usMarket.isOpen}">
|
||||||
<p class="time-label">美股市场</p>
|
<p class="time-label">美股市场</p>
|
||||||
<div class="market-status" :class="marketInfo.usMarket.isOpen ? 'status-open' : 'status-closed'">
|
<div class="market-status" :class="marketInfo.usMarket.isOpen ? 'status-open' : 'status-closed'">
|
||||||
<n-tag v-if="marketInfo.usMarket.isOpen" type="success" size="medium" round>
|
<n-tag v-if="marketInfo.usMarket.isOpen" type="success" size="medium" round class="status-tag mobile-touch-target">
|
||||||
<template #icon><n-icon size="18"><pulse-icon /></n-icon></template>
|
<template #icon><n-icon size="18"><pulse-icon /></n-icon></template>
|
||||||
交易中
|
交易中
|
||||||
</n-tag>
|
</n-tag>
|
||||||
<n-tag v-else type="default" size="medium" round>
|
<n-tag v-else type="default" size="medium" round class="status-tag mobile-touch-target">
|
||||||
<template #icon><n-icon size="18"><time-icon /></n-icon></template>
|
<template #icon><n-icon size="18"><time-icon /></n-icon></template>
|
||||||
已休市
|
已休市
|
||||||
</n-tag>
|
</n-tag>
|
||||||
</div>
|
</div>
|
||||||
<p class="time-counter">{{ marketInfo.usMarket.nextTime }}</p>
|
<p class="time-counter">{{ marketInfo.usMarket.nextTime }}</p>
|
||||||
|
<div class="market-progress-container">
|
||||||
|
<div class="market-progress-bar"
|
||||||
|
:class="marketInfo.usMarket.isOpen ? 'progress-open' : 'progress-closed'"
|
||||||
|
:style="{ width: marketInfo.usMarket.progressPercentage + '%' }">
|
||||||
|
</div>
|
||||||
|
<div class="progress-markers" :class="{'reverse-markers': !marketInfo.usMarket.isOpen}">
|
||||||
|
<div class="progress-marker" :class="marketInfo.usMarket.isOpen ? 'start' : 'end'">开盘</div>
|
||||||
|
<div class="progress-marker" :class="marketInfo.usMarket.isOpen ? 'end' : 'start'">收盘</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
</n-grid>
|
</n-grid>
|
||||||
@@ -67,21 +97,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||||
import { NCard, NGrid, NGridItem, NTag, NIcon } from 'naive-ui';
|
import { NCard, NGrid, NGridItem, NTag, NIcon } from 'naive-ui';
|
||||||
import {
|
import {
|
||||||
PulseOutline as PulseIcon,
|
PulseOutline as PulseIcon,
|
||||||
TimeOutline as TimeIcon
|
TimeOutline as TimeIcon,
|
||||||
} from '@vicons/ionicons5';
|
} from '@vicons/ionicons5';
|
||||||
import { updateMarketTimeInfo } from '@/utils';
|
import { updateMarketTimeInfo } from '@/utils';
|
||||||
import type { MarketTimeInfo } from '@/types';
|
import type { MarketTimeInfo, MarketStatus } from '@/types';
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
isMobile: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const marketInfo = ref<MarketTimeInfo>({
|
const marketInfo = ref<MarketTimeInfo>({
|
||||||
currentTime: '',
|
currentTime: '',
|
||||||
@@ -90,14 +113,116 @@ const marketInfo = ref<MarketTimeInfo>({
|
|||||||
usMarket: { isOpen: false, nextTime: '' }
|
usMarket: { isOpen: false, nextTime: '' }
|
||||||
});
|
});
|
||||||
|
|
||||||
const gridCols = computed(() => {
|
|
||||||
return props.isMobile ? 1 : 4;
|
|
||||||
});
|
|
||||||
|
|
||||||
let intervalId: number | null = null;
|
let intervalId: number | null = null;
|
||||||
|
|
||||||
function updateMarketTime() {
|
function updateMarketTime() {
|
||||||
marketInfo.value = updateMarketTimeInfo();
|
const baseInfo = updateMarketTimeInfo();
|
||||||
|
|
||||||
|
// 计算各市场的进度百分比
|
||||||
|
marketInfo.value = {
|
||||||
|
currentTime: baseInfo.currentTime,
|
||||||
|
cnMarket: {
|
||||||
|
...baseInfo.cnMarket,
|
||||||
|
progressPercentage: calculateProgressPercentage(baseInfo.cnMarket)
|
||||||
|
},
|
||||||
|
hkMarket: {
|
||||||
|
...baseInfo.hkMarket,
|
||||||
|
progressPercentage: calculateProgressPercentage(baseInfo.hkMarket)
|
||||||
|
},
|
||||||
|
usMarket: {
|
||||||
|
...baseInfo.usMarket,
|
||||||
|
progressPercentage: calculateProgressPercentage(baseInfo.usMarket)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算进度百分比的函数
|
||||||
|
function calculateProgressPercentage(market: MarketStatus): number {
|
||||||
|
// 从nextTime中提取时间信息来计算进度
|
||||||
|
const timeText = market.nextTime;
|
||||||
|
|
||||||
|
// 如果没有时间文本,返回默认值50%
|
||||||
|
if (!timeText) return 50;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 特殊情况处理
|
||||||
|
if (timeText.includes("已休市") || timeText.includes("已闭市")) {
|
||||||
|
return market.isOpen ? 100 : 0; // 休市状态:开市时为100%,休市时为0%
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeText.includes("即将开市") || timeText.includes("即将开盘")) {
|
||||||
|
return market.isOpen ? 5 : 95; // 即将开市:开市时为5%,休市时为95%
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取小时和分钟,支持多种格式
|
||||||
|
let hours = 0;
|
||||||
|
let minutes = 0;
|
||||||
|
|
||||||
|
// 匹配"XX小时XX分钟"格式
|
||||||
|
const hourMinuteMatch = timeText.match(/(\d+)\s*小时\s*(\d+)\s*分钟/);
|
||||||
|
if (hourMinuteMatch) {
|
||||||
|
hours = parseInt(hourMinuteMatch[1]);
|
||||||
|
minutes = parseInt(hourMinuteMatch[2]);
|
||||||
|
} else {
|
||||||
|
// 单独匹配小时和分钟
|
||||||
|
const hourMatch = timeText.match(/(\d+)\s*小时/);
|
||||||
|
const minuteMatch = timeText.match(/(\d+)\s*分钟/);
|
||||||
|
|
||||||
|
hours = hourMatch ? parseInt(hourMatch[1]) : 0;
|
||||||
|
minutes = minuteMatch ? parseInt(minuteMatch[1]) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 总分钟数
|
||||||
|
const totalMinutes = hours * 60 + minutes;
|
||||||
|
|
||||||
|
// 根据市场类型设置不同的交易时长
|
||||||
|
let tradingMinutes = 240; // 默认交易时长4小时
|
||||||
|
let nonTradingMinutes = 1200; // 默认非交易时长20小时
|
||||||
|
|
||||||
|
// 根据市场调整时长
|
||||||
|
if (timeText.includes("A股") || timeText.includes("沪深") ||
|
||||||
|
(!timeText.includes("港股") && !timeText.includes("美股"))) {
|
||||||
|
tradingMinutes = 240; // A股交易4小时
|
||||||
|
nonTradingMinutes = 1200; // 非交易20小时
|
||||||
|
} else if (timeText.includes("港股")) {
|
||||||
|
tradingMinutes = 390; // 港股交易6.5小时
|
||||||
|
nonTradingMinutes = 1050; // 非交易17.5小时
|
||||||
|
} else if (timeText.includes("美股")) {
|
||||||
|
tradingMinutes = 390; // 美股交易6.5小时
|
||||||
|
nonTradingMinutes = 1050; // 非交易17.5小时
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据市场状态计算进度
|
||||||
|
if (market.isOpen) {
|
||||||
|
// 市场开市状态 - 从开盘到收盘方向
|
||||||
|
if (timeText.includes("距离收市") || timeText.includes("距离闭市") ||
|
||||||
|
timeText.includes("距离休市") || timeText.includes("距离收盘")) {
|
||||||
|
// 计算已经交易的时间比例
|
||||||
|
const tradedMinutes = tradingMinutes - totalMinutes;
|
||||||
|
const percentage = (tradedMinutes / tradingMinutes) * 100;
|
||||||
|
return Math.max(0, Math.min(100, percentage));
|
||||||
|
} else {
|
||||||
|
// 处理交易开始阶段但没有明确提示的情况
|
||||||
|
return 5; // 开盘初期设为5%
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 市场休市状态 - 从收盘到开盘方向
|
||||||
|
if (timeText.includes("距离开市") || timeText.includes("距离开盘")) {
|
||||||
|
// 计算接近开盘的时间比例
|
||||||
|
const closedMinutes = nonTradingMinutes - totalMinutes;
|
||||||
|
const percentage = (closedMinutes / nonTradingMinutes) * 100;
|
||||||
|
// 反转比例:0% 表示刚刚休市,100% 表示即将开盘
|
||||||
|
return Math.max(0, Math.min(100, 100 - percentage));
|
||||||
|
} else {
|
||||||
|
// 处理休市开始阶段但没有明确提示的情况
|
||||||
|
return 5; // 刚休市设为5%
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("计算市场进度时出错:", error);
|
||||||
|
// 出错时返回默认值
|
||||||
|
return market.isOpen ? 50 : 5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -116,7 +241,22 @@ onBeforeUnmount(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.market-time-card {
|
.market-time-card {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
padding: 0.5rem;
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
background: linear-gradient(to bottom, rgba(250, 250, 252, 0.8), rgba(245, 245, 250, 0.5));
|
||||||
|
min-height: 200px; /* 确保卡片有最小高度 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保网格布局在各种屏幕尺寸下正确显示 */
|
||||||
|
:deep(.n-grid) {
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-grid-item) {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-block {
|
.time-block {
|
||||||
@@ -124,7 +264,39 @@ onBeforeUnmount(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0.5rem;
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 100%; /* 确保不超过父容器宽度 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-time-block {
|
||||||
|
background-color: rgba(32, 128, 240, 0.05);
|
||||||
|
border: 1px solid rgba(32, 128, 240, 0.1);
|
||||||
|
max-width: 360px; /* 限制当前时间块的最大宽度 */
|
||||||
|
width: 100%; /* 确保响应式 */
|
||||||
|
margin: 0 auto; /* 居中显示 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-block {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
max-width: 360px; /* 限制市场块的最大宽度 */
|
||||||
|
width: 100%; /* 确保响应式 */
|
||||||
|
margin: 0 auto; /* 居中显示 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-open-block {
|
||||||
|
background-color: rgba(24, 160, 88, 0.05);
|
||||||
|
border-color: rgba(24, 160, 88, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-closed-block {
|
||||||
|
background-color: rgba(128, 128, 128, 0.05);
|
||||||
|
border-color: rgba(128, 128, 128, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-label {
|
.time-label {
|
||||||
@@ -145,13 +317,20 @@ onBeforeUnmount(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 32px;
|
min-height: 36px;
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap; /* 允许内容在必要时换行 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.market-status :deep(.n-tag) {
|
.status-tag {
|
||||||
padding: 0 12px;
|
padding: 0 16px !important;
|
||||||
height: 32px;
|
height: 36px !important;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 100px;
|
||||||
|
max-width: 100%; /* 确保不超过父容器宽度 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.market-status :deep(.n-tag__icon) {
|
.market-status :deep(.n-tag__icon) {
|
||||||
@@ -159,30 +338,299 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-open :deep(.n-tag) {
|
.status-open :deep(.n-tag) {
|
||||||
background-color: rgba(var(--success-color), 0.15);
|
background-color: rgba(24, 160, 88, 0.15);
|
||||||
border: 1px solid var(--n-success-color);
|
border: 1px solid var(--n-success-color);
|
||||||
animation: pulse 2s infinite;
|
animation: pulse 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-closed :deep(.n-tag) {
|
.status-closed :deep(.n-tag) {
|
||||||
background-color: rgba(var(--n-text-color-3), 0.1);
|
background-color: rgba(128, 128, 128, 0.1);
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-counter {
|
.time-counter {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--n-text-color-3);
|
color: var(--n-text-color-3);
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
|
width: 100%; /* 确保文本容器占满宽度 */
|
||||||
|
text-align: center; /* 文本居中 */
|
||||||
|
white-space: nowrap; /* 防止文本换行 */
|
||||||
|
overflow: hidden; /* 隐藏溢出内容 */
|
||||||
|
text-overflow: ellipsis; /* 显示省略号 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进度条样式 */
|
||||||
|
.market-progress-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background-color: rgba(200, 200, 200, 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
overflow: visible;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
border: 1px solid rgba(200, 200, 200, 0.4);
|
||||||
|
max-width: 100%; /* 确保不超过父容器宽度 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-progress-bar::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
rgba(255, 255, 255, 0.15) 0%,
|
||||||
|
rgba(255, 255, 255, 0.4) 50%,
|
||||||
|
rgba(255, 255, 255, 0.15) 100%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-open {
|
||||||
|
background-color: rgba(24, 160, 88, 0.9);
|
||||||
|
box-shadow: 0 0 8px rgba(24, 160, 88, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||||
|
border: 1px solid rgba(24, 160, 88, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-closed {
|
||||||
|
background-color: rgba(100, 100, 100, 0.8);
|
||||||
|
box-shadow: 0 0 5px rgba(100, 100, 100, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||||
|
border: 1px solid rgba(80, 80, 80, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进度条标记 */
|
||||||
|
.progress-markers {
|
||||||
|
position: absolute;
|
||||||
|
top: -20px;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--n-text-color-2);
|
||||||
|
padding: 0 2px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-shadow: 0 1px 1px rgba(255, 255, 255, 0.7);
|
||||||
|
box-sizing: border-box; /* 确保内边距不会增加宽度 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 反向标记(休市状态) */
|
||||||
|
.reverse-markers {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-marker {
|
||||||
|
position: relative;
|
||||||
|
white-space: nowrap; /* 防止文本换行 */
|
||||||
|
max-width: 45%; /* 限制宽度,防止重叠 */
|
||||||
|
overflow: hidden; /* 隐藏溢出内容 */
|
||||||
|
text-overflow: ellipsis; /* 显示省略号 */
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0% {
|
0% {
|
||||||
box-shadow: 0 0 0 0 rgba(var(--success-color), 0.4);
|
box-shadow: 0 0 0 0 rgba(24, 160, 88, 0.4);
|
||||||
}
|
}
|
||||||
70% {
|
70% {
|
||||||
box-shadow: 0 0 0 6px rgba(var(--success-color), 0);
|
box-shadow: 0 0 0 6px rgba(24, 160, 88, 0);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
box-shadow: 0 0 0 0 rgba(var(--success-color), 0);
|
box-shadow: 0 0 0 0 rgba(24, 160, 88, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 移动端适配 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.market-time-card {
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
min-height: 180px; /* 移动端下的最小高度 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-block {
|
||||||
|
padding: 0.625rem;
|
||||||
|
margin-bottom: 0.75rem; /* 增加底部外边距 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-time {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-label {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag {
|
||||||
|
min-width: 100px; /* 减小移动端下的最小宽度 */
|
||||||
|
height: 36px !important;
|
||||||
|
font-size: 0.875rem; /* 减小字体大小 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-counter {
|
||||||
|
font-size: 0.75rem; /* 减小字体大小 */
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 增强视觉层次 */
|
||||||
|
.market-open-block::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 4px;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--n-success-color);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-closed-block::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 4px;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(128, 128, 128, 0.5);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-progress-container {
|
||||||
|
height: 5px;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-markers {
|
||||||
|
top: -18px; /* 调整位置 */
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-marker {
|
||||||
|
max-width: 40%; /* 移动端下进一步限制宽度 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-marker.start::before,
|
||||||
|
.progress-marker.end::before {
|
||||||
|
top: -10px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 增强移动端进度条可见性 */
|
||||||
|
.progress-open {
|
||||||
|
background-color: rgba(24, 160, 88, 1);
|
||||||
|
box-shadow: 0 0 6px rgba(24, 160, 88, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-closed {
|
||||||
|
background-color: rgba(90, 90, 90, 0.9);
|
||||||
|
box-shadow: 0 0 4px rgba(90, 90, 90, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-time-block {
|
||||||
|
max-width: 360px; /* 移动端下的最大宽度 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-block {
|
||||||
|
max-width: 360px; /* 移动端下的最大宽度 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 小屏幕手机适配 */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.market-time-card {
|
||||||
|
padding: 0.375rem;
|
||||||
|
min-height: 160px; /* 小屏幕下的最小高度 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-block {
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 1rem; /* 增加小屏幕下的底部外边距 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-time {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-counter {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag {
|
||||||
|
min-width: 90px; /* 进一步减小最小宽度 */
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
padding: 0 12px !important; /* 减小内边距 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保边框在小屏幕上清晰可见 */
|
||||||
|
.time-block {
|
||||||
|
border-width: 1px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-progress-container {
|
||||||
|
height: 4px;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-markers {
|
||||||
|
top: -16px; /* 调整位置 */
|
||||||
|
font-size: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-marker {
|
||||||
|
max-width: 35%; /* 小屏幕下进一步限制宽度 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-marker.start::before,
|
||||||
|
.progress-marker.end::before {
|
||||||
|
top: -8px;
|
||||||
|
height: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进一步增强小屏幕进度条可见性 */
|
||||||
|
.market-progress-container {
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-open, .progress-closed {
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-time-block {
|
||||||
|
max-width: 300px; /* 小屏幕下的最大宽度 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-block {
|
||||||
|
max-width: 300px; /* 小屏幕下的最大宽度 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保API配置面板有足够的空间 */
|
||||||
|
.n-collapse {
|
||||||
|
margin-bottom: 16px; /* 添加底部间距 */
|
||||||
|
padding-bottom: 8px; /* 增加内边距底部 */
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container">
|
<div class="app-container mobile-bottom-extend">
|
||||||
|
<!-- 公告横幅 -->
|
||||||
|
<AnnouncementBanner
|
||||||
|
v-if="announcement && showAnnouncementBanner"
|
||||||
|
:content="announcement"
|
||||||
|
:auto-close-time="5"
|
||||||
|
@close="handleAnnouncementClose"
|
||||||
|
/>
|
||||||
|
|
||||||
<n-layout class="main-layout">
|
<n-layout class="main-layout">
|
||||||
<n-layout-content class="main-content">
|
<n-layout-content class="main-content mobile-content-container">
|
||||||
|
|
||||||
<!-- 市场时间显示 -->
|
<!-- 市场时间显示 -->
|
||||||
<MarketTimeDisplay />
|
<MarketTimeDisplay :is-mobile="isMobile" />
|
||||||
|
|
||||||
<!-- API配置面板 -->
|
<!-- API配置面板 -->
|
||||||
<ApiConfigPanel
|
<ApiConfigPanel
|
||||||
@@ -15,11 +23,11 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 主要内容 -->
|
<!-- 主要内容 -->
|
||||||
<n-card class="analysis-container">
|
<n-card class="analysis-container mobile-card mobile-card-spacing mobile-shadow">
|
||||||
|
|
||||||
<n-grid :cols="24" :x-gap="16" :y-gap="16">
|
<n-grid cols="1 xl:24" :x-gap="16" :y-gap="16" responsive="screen">
|
||||||
<!-- 左侧配置区域 -->
|
<!-- 左侧配置区域 -->
|
||||||
<n-grid-item :span="24" :lg-span="8">
|
<n-grid-item span="1 xl:8">
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
<n-form-item label="选择市场类型">
|
<n-form-item label="选择市场类型">
|
||||||
<n-select
|
<n-select
|
||||||
@@ -29,7 +37,7 @@
|
|||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
|
|
||||||
<n-form-item label="股票搜索" v-if="marketType === 'US'">
|
<n-form-item :label='marketType === "US" ? "股票搜索" : "基金搜索"' v-if="showSearch">
|
||||||
<StockSearch :market-type="marketType" @select="addSelectedStock" />
|
<StockSearch :market-type="marketType" @select="addSelectedStock" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
|
|
||||||
@@ -37,7 +45,7 @@
|
|||||||
<n-input
|
<n-input
|
||||||
v-model:value="stockCodes"
|
v-model:value="stockCodes"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
placeholder="输入股票代码,多个代码用逗号、空格或换行分隔"
|
placeholder="输入股票、基金代码,多个代码用逗号、空格或换行分隔"
|
||||||
:autosize="{ minRows: 3, maxRows: 6 }"
|
:autosize="{ minRows: 3, maxRows: 6 }"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
@@ -63,7 +71,7 @@
|
|||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
|
|
||||||
<!-- 右侧结果区域 -->
|
<!-- 右侧结果区域 -->
|
||||||
<n-grid-item :span="24" :lg-span="16">
|
<n-grid-item span="1 xl:16">
|
||||||
<div class="results-section">
|
<div class="results-section">
|
||||||
<div class="results-header">
|
<div class="results-header">
|
||||||
<n-space align="center" justify="space-between">
|
<n-space align="center" justify="space-between">
|
||||||
@@ -113,7 +121,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="displayMode === 'card'">
|
<template v-else-if="displayMode === 'card'">
|
||||||
<n-grid :cols="1" :x-gap="16" :y-gap="16" :lg-cols="2">
|
<n-grid cols="1" :x-gap="8" :y-gap="8" responsive="screen">
|
||||||
<n-grid-item v-for="stock in analyzedStocks" :key="stock.code">
|
<n-grid-item v-for="stock in analyzedStocks" :key="stock.code">
|
||||||
<StockCard :stock="stock" />
|
<StockCard :stock="stock" />
|
||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
@@ -121,27 +129,31 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<n-data-table
|
<div class="table-container">
|
||||||
:columns="stockTableColumns"
|
<n-data-table
|
||||||
:data="analyzedStocks"
|
:columns="stockTableColumns"
|
||||||
:pagination="{ pageSize: 10 }"
|
:data="analyzedStocks"
|
||||||
:row-key="(row: StockInfo) => row.code"
|
:pagination="{ pageSize: 10 }"
|
||||||
:bordered="false"
|
:row-key="(row: StockInfo) => row.code"
|
||||||
:single-line="false"
|
:bordered="false"
|
||||||
striped
|
:single-line="false"
|
||||||
/>
|
striped
|
||||||
|
:scroll-x="1200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
</n-grid>
|
</n-grid>
|
||||||
</n-card>
|
</n-card>
|
||||||
|
|
||||||
</n-layout-content>
|
</n-layout-content>
|
||||||
</n-layout>
|
</n-layout>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, h } from 'vue';
|
import { ref, onMounted, computed, onBeforeUnmount } from 'vue';
|
||||||
import {
|
import {
|
||||||
NLayout,
|
NLayout,
|
||||||
NLayoutContent,
|
NLayoutContent,
|
||||||
@@ -155,7 +167,6 @@ import {
|
|||||||
NButton,
|
NButton,
|
||||||
NEmpty,
|
NEmpty,
|
||||||
useMessage,
|
useMessage,
|
||||||
useNotification,
|
|
||||||
NSpace,
|
NSpace,
|
||||||
NText,
|
NText,
|
||||||
NDataTable,
|
NDataTable,
|
||||||
@@ -166,13 +177,13 @@ import { useClipboard } from '@vueuse/core'
|
|||||||
import {
|
import {
|
||||||
DocumentTextOutline as DocumentTextIcon,
|
DocumentTextOutline as DocumentTextIcon,
|
||||||
DownloadOutline as DownloadIcon,
|
DownloadOutline as DownloadIcon,
|
||||||
NotificationsOutline as NotificationsIcon
|
|
||||||
} from '@vicons/ionicons5';
|
} from '@vicons/ionicons5';
|
||||||
|
|
||||||
import MarketTimeDisplay from './MarketTimeDisplay.vue';
|
import MarketTimeDisplay from './MarketTimeDisplay.vue';
|
||||||
import ApiConfigPanel from './ApiConfigPanel.vue';
|
import ApiConfigPanel from './ApiConfigPanel.vue';
|
||||||
import StockSearch from './StockSearch.vue';
|
import StockSearch from './StockSearch.vue';
|
||||||
import StockCard from './StockCard.vue';
|
import StockCard from './StockCard.vue';
|
||||||
|
import AnnouncementBanner from './AnnouncementBanner.vue';
|
||||||
|
|
||||||
import { apiService } from '@/services/api';
|
import { apiService } from '@/services/api';
|
||||||
import type { StockInfo, ApiConfig, StreamInitMessage, StreamAnalysisUpdate } from '@/types';
|
import type { StockInfo, ApiConfig, StreamInitMessage, StreamAnalysisUpdate } from '@/types';
|
||||||
@@ -181,7 +192,6 @@ import { validateMultipleStockCodes, MarketType } from '@/utils/stockValidator';
|
|||||||
|
|
||||||
// 使用Naive UI的组件API
|
// 使用Naive UI的组件API
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
const notification = useNotification();
|
|
||||||
const { copy } = useClipboard();
|
const { copy } = useClipboard();
|
||||||
|
|
||||||
// 从环境变量获取的默认配置
|
// 从环境变量获取的默认配置
|
||||||
@@ -189,6 +199,7 @@ const defaultApiUrl = ref('');
|
|||||||
const defaultApiModel = ref('');
|
const defaultApiModel = ref('');
|
||||||
const defaultApiTimeout = ref('60');
|
const defaultApiTimeout = ref('60');
|
||||||
const announcement = ref('');
|
const announcement = ref('');
|
||||||
|
const showAnnouncementBanner = ref(true);
|
||||||
|
|
||||||
// 股票分析配置
|
// 股票分析配置
|
||||||
const marketType = ref('A');
|
const marketType = ref('A');
|
||||||
@@ -206,29 +217,33 @@ const apiConfig = ref<ApiConfig>({
|
|||||||
saveApiConfig: false
|
saveApiConfig: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 移动端检测
|
||||||
|
const isMobile = computed(() => {
|
||||||
|
return window.innerWidth <= 768;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听窗口大小变化
|
||||||
|
function handleResize() {
|
||||||
|
// 窗口大小变化时,isMobile计算属性会自动更新
|
||||||
|
// 这里可以添加其他需要在窗口大小变化时执行的逻辑
|
||||||
|
}
|
||||||
|
|
||||||
// 显示系统公告
|
// 显示系统公告
|
||||||
const showAnnouncement = (content: string) => {
|
const showAnnouncement = (content: string) => {
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
|
|
||||||
notification.info({
|
// 使用AnnouncementBanner组件显示公告
|
||||||
title: '系统公告',
|
announcement.value = content;
|
||||||
content: () => h('div', { style: 'display: flex; align-items: center;' }, [
|
showAnnouncementBanner.value = true;
|
||||||
h(NIcon, { component: NotificationsIcon, style: 'margin-right: 8px; font-size: 18px;' }),
|
|
||||||
h('span', null, content)
|
|
||||||
]),
|
|
||||||
duration: 0, // 设置为0表示不会自动关闭
|
|
||||||
keepAliveOnHover: true,
|
|
||||||
closable: true
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 市场选项
|
// 市场选项
|
||||||
const marketOptions = [
|
const marketOptions = [
|
||||||
{ label: 'A股', value: 'A' },
|
{ label: 'A股', value: 'A' },
|
||||||
{ label: '港股', value: 'HK' },
|
{ label: '港股', value: 'HK' },
|
||||||
{ label: '美股', value: 'US' },
|
{ label: '美股', value: 'US', showSearch: true },
|
||||||
{ label: 'ETF', value: 'ETF' },
|
{ label: 'ETF', value: 'ETF', showSearch: true },
|
||||||
{ label: 'LOF', value: 'LOF' }
|
{ label: 'LOF', value: 'LOF', showSearch: true }
|
||||||
];
|
];
|
||||||
|
|
||||||
// 表格列定义
|
// 表格列定义
|
||||||
@@ -384,6 +399,10 @@ const exportOptions = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const showSearch = computed(() =>
|
||||||
|
marketOptions.find(option => option.value === marketType.value)?.showSearch
|
||||||
|
);
|
||||||
|
|
||||||
// 更新API配置
|
// 更新API配置
|
||||||
function updateApiConfig(config: ApiConfig) {
|
function updateApiConfig(config: ApiConfig) {
|
||||||
apiConfig.value = { ...config };
|
apiConfig.value = { ...config };
|
||||||
@@ -397,10 +416,13 @@ function handleMarketTypeChange() {
|
|||||||
|
|
||||||
// 添加选择的股票
|
// 添加选择的股票
|
||||||
function addSelectedStock(symbol: string) {
|
function addSelectedStock(symbol: string) {
|
||||||
|
// 确保symbol不包含序号或其他不需要的信息
|
||||||
|
const cleanSymbol = symbol.trim().replace(/^\d+\.\s*/, '');
|
||||||
|
|
||||||
if (stockCodes.value) {
|
if (stockCodes.value) {
|
||||||
stockCodes.value += ', ' + symbol;
|
stockCodes.value += ', ' + cleanSymbol;
|
||||||
} else {
|
} else {
|
||||||
stockCodes.value = symbol;
|
stockCodes.value = cleanSymbol;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,6 +933,9 @@ function getChineseVolumeStatus(status: string): string {
|
|||||||
// 页面加载时获取默认配置和公告
|
// 页面加载时获取默认配置和公告
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
|
// 添加窗口大小变化监听
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
// 从API获取配置信息
|
// 从API获取配置信息
|
||||||
const config = await apiService.getConfig();
|
const config = await apiService.getConfig();
|
||||||
|
|
||||||
@@ -938,6 +963,16 @@ onMounted(async () => {
|
|||||||
console.error('获取默认配置时出错:', error);
|
console.error('获取默认配置时出错:', error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 组件销毁前移除事件监听
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理公告关闭事件
|
||||||
|
function handleAnnouncementClose() {
|
||||||
|
showAnnouncementBanner.value = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -946,6 +981,8 @@ onMounted(async () => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
padding-bottom: 20px; /* 增加底部内边距 */
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-layout {
|
.main-layout {
|
||||||
@@ -953,6 +990,7 @@ onMounted(async () => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
min-height: calc(100vh - 20px); /* 确保至少占满视口高度减去底部空间 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
@@ -969,7 +1007,12 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.analysis-container {
|
.analysis-container {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 修改卡片内容区域的内边距 */
|
||||||
|
.analysis-container :deep(.n-card__content) {
|
||||||
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-section {
|
.config-section {
|
||||||
@@ -998,4 +1041,256 @@ onMounted(async () => {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 表格容器基础样式 */
|
||||||
|
.table-container {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch; /* 支持iOS的滚动惯性 */
|
||||||
|
position: relative;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格横向滚动指示器 */
|
||||||
|
.table-container::after {
|
||||||
|
content: '←→';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 10px;
|
||||||
|
right: 10px;
|
||||||
|
color: rgba(32, 128, 240, 0.6);
|
||||||
|
font-size: 14px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
animation: fadeInOut 2s infinite;
|
||||||
|
display: none; /* 默认隐藏,只在移动端显示 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端适配的媒体查询 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main-content {
|
||||||
|
padding: 0.5rem;
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 显示滚动指示器 */
|
||||||
|
.table-container::after {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 减少移动端卡片内容区域的内边距 */
|
||||||
|
.analysis-container :deep(.n-card__content) {
|
||||||
|
padding: 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保卡片内部没有多余边距 */
|
||||||
|
:deep(.n-card > .n-card__content) {
|
||||||
|
padding: 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 减少结果区域的内边距 */
|
||||||
|
.results-section {
|
||||||
|
padding: 0.25rem 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons .n-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-container {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section {
|
||||||
|
padding: 0.25rem;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端表格样式优化 */
|
||||||
|
.table-container {
|
||||||
|
margin: 0 -4px; /* 抵消父容器的padding */
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格组件移动端优化 */
|
||||||
|
:deep(.n-data-table-wrapper) {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-data-table-base-table-header, .n-data-table-base-table-body) {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-pagination) {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 保留原有移动端优化样式 */
|
||||||
|
:deep(.n-form-item) {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-grid) {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-grid-item) {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-grid[cols="1 m\\:24"]) {
|
||||||
|
gap: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-grid[cols="1 l\\:2"]) {
|
||||||
|
gap: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-grid-item) > * {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-dropdown-menu) {
|
||||||
|
max-width: 90vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
padding-bottom: 30px; /* 增加移动端底部内边距 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 小屏幕手机适配 */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.main-content {
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进一步减少小屏幕卡片内容区域的内边距 */
|
||||||
|
.analysis-container :deep(.n-card__content) {
|
||||||
|
padding: 6px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 使用更精确的选择器确保覆盖 */
|
||||||
|
:deep(.n-card) > :deep(.n-card__content),
|
||||||
|
:deep(.n-card-header) {
|
||||||
|
padding: 6px 4px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 减少网格间距到最小 */
|
||||||
|
:deep(.n-grid[cols="1 l\\:2"]) {
|
||||||
|
gap: 4px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-section {
|
||||||
|
padding: 0.15rem 0.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-space) {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-space .n-button) {
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-container {
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 小屏幕下进一步优化n-grid */
|
||||||
|
:deep(.n-grid) {
|
||||||
|
gap: 4px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-grid-item) {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保n-grid-item内容在小屏幕下有更紧凑的间距 */
|
||||||
|
:deep(.n-grid-item) > * {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 小屏幕表格样式调整 */
|
||||||
|
.table-container {
|
||||||
|
margin: 0 -2px;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 小屏幕分页控件优化 */
|
||||||
|
:deep(.n-pagination .n-pagination-item) {
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
padding-bottom: 40px; /* 增加小屏幕底部内边距 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 超小屏幕适配 */
|
||||||
|
@media (max-width: 375px) {
|
||||||
|
/* 超小屏幕卡片内容区域几乎无内边距 */
|
||||||
|
.analysis-container :deep(.n-card__content) {
|
||||||
|
padding: 4px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 使用更精确的选择器确保覆盖 */
|
||||||
|
:deep(.n-card) > :deep(.n-card__content),
|
||||||
|
:deep(.n-card-header) {
|
||||||
|
padding: 3px 2px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 网格间距最小化 */
|
||||||
|
:deep(.n-grid[cols="1 l\\:2"]),
|
||||||
|
:deep(.n-grid[cols="1 m\\:24"]) {
|
||||||
|
gap: 3px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 极简边距 */
|
||||||
|
.results-section {
|
||||||
|
padding: 0.1rem 0.025rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进一步调整超小屏幕的间距和尺寸 */
|
||||||
|
.main-content {
|
||||||
|
padding: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section {
|
||||||
|
padding: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保StockCard组件最大化利用空间 */
|
||||||
|
:deep(.stock-card) {
|
||||||
|
margin: 2px 0 !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-card class="stock-card" :bordered="false" :class="{ 'is-analyzing': isAnalyzing }">
|
<n-card class="stock-card mobile-card mobile-shadow mobile-stock-card" :bordered="false" :class="{ 'is-analyzing': isAnalyzing }">
|
||||||
<div class="card-header">
|
<div class="card-header mobile-card-header">
|
||||||
<div class="header-main">
|
<div class="header-main">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<div class="stock-info">
|
<div class="stock-info">
|
||||||
@@ -126,7 +126,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="stock.analysisStatus === 'analyzing'">
|
<template v-else-if="stock.analysisStatus === 'analyzing'">
|
||||||
<div class="analysis-result analysis-streaming" v-if="parsedAnalysis" v-html="parsedAnalysis" :key="analysisContentKey"></div>
|
<div class="analysis-result analysis-streaming"
|
||||||
|
ref="analysisResultRef"
|
||||||
|
v-html="parsedAnalysis">
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="stock.analysisStatus === 'completed'">
|
<template v-else-if="stock.analysisStatus === 'completed'">
|
||||||
@@ -138,7 +141,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, watch, ref } from 'vue';
|
import { computed, watch, ref, nextTick, onMounted, onBeforeUnmount } from 'vue';
|
||||||
import { NCard, NDivider, NIcon, NTag, NButton, useMessage } from 'naive-ui';
|
import { NCard, NDivider, NIcon, NTag, NButton, useMessage } from 'naive-ui';
|
||||||
import {
|
import {
|
||||||
AlertCircleOutline as AlertCircleIcon,
|
AlertCircleOutline as AlertCircleIcon,
|
||||||
@@ -159,22 +162,17 @@ const isAnalyzing = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const lastAnalysisLength = ref(0);
|
const lastAnalysisLength = ref(0);
|
||||||
|
const lastAnalysisText = ref('');
|
||||||
|
|
||||||
// 监听分析内容变化
|
// 监听分析内容变化
|
||||||
watch(() => props.stock.analysis, (newVal) => {
|
watch(() => props.stock.analysis, (newVal) => {
|
||||||
if (newVal && props.stock.analysisStatus === 'analyzing') {
|
if (newVal && props.stock.analysisStatus === 'analyzing') {
|
||||||
lastAnalysisLength.value = newVal.length;
|
lastAnalysisLength.value = newVal.length;
|
||||||
|
lastAnalysisText.value = newVal;
|
||||||
}
|
}
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
// 添加一个计算属性,用于监控分析内容是否更新
|
// 分析内容的解析
|
||||||
const analysisContentKey = ref(0);
|
|
||||||
watch(() => props.stock.analysis, (newVal, oldVal) => {
|
|
||||||
if (newVal && oldVal && newVal.length > oldVal.length && props.stock.analysisStatus === 'analyzing') {
|
|
||||||
analysisContentKey.value++;
|
|
||||||
}
|
|
||||||
}, { immediate: false });
|
|
||||||
|
|
||||||
const parsedAnalysis = computed(() => {
|
const parsedAnalysis = computed(() => {
|
||||||
if (props.stock.analysis) {
|
if (props.stock.analysis) {
|
||||||
let result = parseMarkdown(props.stock.analysis);
|
let result = parseMarkdown(props.stock.analysis);
|
||||||
@@ -414,6 +412,99 @@ const getStatusText = computed(() => {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 添加滚动控制相关变量
|
||||||
|
const analysisResultRef = ref<HTMLElement | null>(null);
|
||||||
|
const userScrolling = ref(false);
|
||||||
|
const scrollPosition = ref(0);
|
||||||
|
const scrollThreshold = 30; // 底部阈值,小于这个值认为用户已滚动到底部
|
||||||
|
|
||||||
|
// 检测用户滚动行为
|
||||||
|
function handleScroll() {
|
||||||
|
if (!analysisResultRef.value) return;
|
||||||
|
|
||||||
|
const element = analysisResultRef.value;
|
||||||
|
const atBottom = element.scrollHeight - element.scrollTop - element.clientHeight < scrollThreshold;
|
||||||
|
|
||||||
|
// 记录当前滚动位置
|
||||||
|
scrollPosition.value = element.scrollTop;
|
||||||
|
|
||||||
|
// 判断用户是否正在主动滚动
|
||||||
|
if (atBottom) {
|
||||||
|
// 用户滚动到底部,标记为非主动滚动状态
|
||||||
|
userScrolling.value = false;
|
||||||
|
} else {
|
||||||
|
// 用户未在底部,标记为主动滚动状态
|
||||||
|
userScrolling.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听滚动事件
|
||||||
|
onMounted(() => {
|
||||||
|
if (analysisResultRef.value) {
|
||||||
|
// 初始滚动到底部
|
||||||
|
analysisResultRef.value.scrollTop = analysisResultRef.value.scrollHeight;
|
||||||
|
analysisResultRef.value.addEventListener('scroll', handleScroll);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清理事件监听
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (analysisResultRef.value) {
|
||||||
|
analysisResultRef.value.removeEventListener('scroll', handleScroll);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 改进流式更新监听,更保守地控制滚动行为
|
||||||
|
let isProcessingUpdate = false; // 防止重复处理更新
|
||||||
|
watch(() => props.stock.analysis, (newVal, oldVal) => {
|
||||||
|
// 只在分析中且内容增加时处理
|
||||||
|
if (newVal && oldVal && newVal.length > oldVal.length &&
|
||||||
|
props.stock.analysisStatus === 'analyzing' && !isProcessingUpdate) {
|
||||||
|
|
||||||
|
isProcessingUpdate = true; // 标记正在处理更新
|
||||||
|
|
||||||
|
// 检查是否应该自动滚动
|
||||||
|
let shouldAutoScroll = false;
|
||||||
|
if (analysisResultRef.value) {
|
||||||
|
const element = analysisResultRef.value;
|
||||||
|
// 仅当滚动接近底部或用户尚未开始滚动时自动滚动
|
||||||
|
const atBottom = element.scrollHeight - element.scrollTop - element.clientHeight < scrollThreshold;
|
||||||
|
shouldAutoScroll = atBottom || !userScrolling.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用nextTick确保DOM已更新
|
||||||
|
nextTick(() => {
|
||||||
|
if (analysisResultRef.value && shouldAutoScroll) {
|
||||||
|
// 使用smoothScroll而非直接设置scrollTop,减少视觉跳动
|
||||||
|
smoothScrollToBottom(analysisResultRef.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置处理标记
|
||||||
|
setTimeout(() => {
|
||||||
|
isProcessingUpdate = false;
|
||||||
|
}, 50); // 短暂延迟,防止过快连续处理
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, { immediate: false });
|
||||||
|
|
||||||
|
// 平滑滚动到底部的辅助函数
|
||||||
|
function smoothScrollToBottom(element: HTMLElement) {
|
||||||
|
const targetPosition = element.scrollHeight;
|
||||||
|
|
||||||
|
// 如果已经很接近底部,直接跳转避免不必要的动画
|
||||||
|
const currentGap = targetPosition - element.scrollTop - element.clientHeight;
|
||||||
|
if (currentGap < 100) {
|
||||||
|
element.scrollTop = targetPosition;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则使用平滑滚动
|
||||||
|
element.scrollTo({
|
||||||
|
top: targetPosition,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -423,6 +514,8 @@ const getStatusText = computed(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
width: 100%; /* 确保宽度不会超过容器 */
|
||||||
|
max-width: 100%; /* 限制最大宽度 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.stock-card.is-analyzing {
|
.stock-card.is-analyzing {
|
||||||
@@ -439,6 +532,7 @@ const getStatusText = computed(() => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
background: linear-gradient(to bottom, rgba(240, 240, 245, 0.3), transparent);
|
background: linear-gradient(to bottom, rgba(240, 240, 245, 0.3), transparent);
|
||||||
border-radius: 8px 8px 0 0;
|
border-radius: 8px 8px 0 0;
|
||||||
|
width: 100%; /* 确保宽度不会超过容器 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-main {
|
.header-main {
|
||||||
@@ -525,6 +619,7 @@ const getStatusText = computed(() => {
|
|||||||
.header-right {
|
.header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
max-width: 380px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-button {
|
.copy-button {
|
||||||
@@ -738,6 +833,8 @@ const getStatusText = computed(() => {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
width: 100%; /* 确保宽度不会超过容器 */
|
||||||
|
overflow-x: hidden; /* 防止内容横向溢出 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-status {
|
.error-status {
|
||||||
@@ -771,10 +868,18 @@ const getStatusText = computed(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
display: block; /* 确保显示为块级元素 */
|
||||||
|
box-sizing: border-box; /* 确保padding不增加宽度 */
|
||||||
|
|
||||||
/* 自定义滚动条样式 */
|
/* 自定义滚动条样式 */
|
||||||
scrollbar-width: thin; /* Firefox */
|
scrollbar-width: thin; /* Firefox */
|
||||||
scrollbar-color: rgba(32, 128, 240, 0.3) transparent; /* Firefox */
|
scrollbar-color: rgba(32, 128, 240, 0.3) transparent; /* Firefox */
|
||||||
|
/* 改进滚动行为 */
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
overflow-anchor: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
touch-action: pan-y;
|
||||||
|
will-change: scroll-position;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Webkit浏览器的滚动条样式 */
|
/* Webkit浏览器的滚动条样式 */
|
||||||
@@ -807,6 +912,13 @@ const getStatusText = computed(() => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
border-left: 2px solid var(--n-info-color);
|
border-left: 2px solid var(--n-info-color);
|
||||||
animation: fadePulse 2s infinite;
|
animation: fadePulse 2s infinite;
|
||||||
|
/* 改进滚动行为 */
|
||||||
|
overflow-y: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
will-change: scroll-position;
|
||||||
|
/* 防止内容更新时的布局抖动 */
|
||||||
|
contain: content;
|
||||||
|
scroll-padding-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 改进流式输出的动画效果,消除闪烁 */
|
/* 改进流式输出的动画效果,消除闪烁 */
|
||||||
@@ -886,6 +998,8 @@ const getStatusText = computed(() => {
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
|
white-space: pre-wrap; /* 允许代码内容自动换行 */
|
||||||
|
word-break: break-word; /* 确保长单词可以换行 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.analysis-result :deep(pre) {
|
.analysis-result :deep(pre) {
|
||||||
@@ -896,12 +1010,16 @@ const getStatusText = computed(() => {
|
|||||||
margin: 0.75rem 0;
|
margin: 0.75rem 0;
|
||||||
border-left: 3px solid #2080f0;
|
border-left: 3px solid #2080f0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
white-space: pre-wrap; /* 允许代码块自动换行 */
|
white-space: pre-wrap; /* 允许代码块自动换行 */
|
||||||
|
word-break: break-word; /* 允许长单词换行 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.analysis-result :deep(pre code) {
|
.analysis-result :deep(pre code) {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
white-space: inherit; /* 继承pre的换行行为 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 优化引用样式 */
|
/* 优化引用样式 */
|
||||||
@@ -923,6 +1041,8 @@ const getStatusText = computed(() => {
|
|||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
table-layout: fixed; /* 固定表格布局 */
|
table-layout: fixed; /* 固定表格布局 */
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
display: block; /* 使表格成为块级元素 */
|
||||||
|
overflow-x: auto; /* 允许表格滚动 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.analysis-result :deep(th), .analysis-result :deep(td) {
|
.analysis-result :deep(th), .analysis-result :deep(td) {
|
||||||
@@ -994,6 +1114,10 @@ const getStatusText = computed(() => {
|
|||||||
border-bottom: 1px dotted #2080f0;
|
border-bottom: 1px dotted #2080f0;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.analysis-result :deep(a:hover) {
|
.analysis-result :deep(a:hover) {
|
||||||
@@ -1009,5 +1133,682 @@ const getStatusText = computed(() => {
|
|||||||
margin: 0.75rem auto;
|
margin: 0.75rem auto;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
object-fit: contain; /* 保持图片比例 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端适配样式 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.stock-card {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-main {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-code {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-name {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
max-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
width: 320px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-price-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
gap: 16px;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px dashed rgba(0, 0, 0, 0.09);
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-price, .stock-change {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-price .label,
|
||||||
|
.stock-change .label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-price .value {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-change .value {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-summary {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.technical-indicators {
|
||||||
|
margin: 0.75rem 0.5rem;
|
||||||
|
background-color: rgba(240, 240, 245, 0.5);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicators-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-item {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.625rem 0.5rem;
|
||||||
|
background-color: rgba(255, 255, 255, 0.7);
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-item:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
box-shadow: 0 0 1px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-value {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--n-text-color-3);
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
width: 100%;
|
||||||
|
height: 36px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
padding: 0.5rem 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-result {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
padding: 0.6rem 0.5rem;
|
||||||
|
max-height: 350px;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.07);
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
background-color: rgba(255, 255, 255, 0.7);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||||
|
-webkit-overflow-scrolling: touch; /* 加强iOS滚动平滑性 */
|
||||||
|
overscroll-behavior: contain; /* 防止滚动传播 */
|
||||||
|
touch-action: pan-y; /* 优化触摸滚动体验 */
|
||||||
|
width: 100%; /* 占据全部可用宽度 */
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative; /* 确保滚动提示正确定位 */
|
||||||
|
overflow-x: hidden !important; /* 强制禁止横向滚动 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优化表格在移动端的显示 */
|
||||||
|
.analysis-result :deep(table) {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
margin: 0.7rem 0;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.07);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优化代码块在移动端的显示 */
|
||||||
|
.analysis-result :deep(pre) {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
margin: 0.7rem 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.04);
|
||||||
|
border-left: 3px solid rgba(32, 128, 240, 0.5);
|
||||||
|
width: 100% !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 拖动滚动提示效果 - 恢复并优化 */
|
||||||
|
.analysis-result :deep(pre)::after,
|
||||||
|
.analysis-result :deep(table)::after {
|
||||||
|
content: '⟷';
|
||||||
|
position: absolute;
|
||||||
|
right: 5px;
|
||||||
|
bottom: 5px;
|
||||||
|
color: rgba(32, 128, 240, 0.5);
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 改进链接触摸体验 */
|
||||||
|
.analysis-result :deep(a) {
|
||||||
|
padding: 0.1rem 0;
|
||||||
|
margin: 0 0.1rem;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 改进按钮和交互元素触摸体验 */
|
||||||
|
.analysis-result :deep(button),
|
||||||
|
.analysis-result :deep(.interactive) {
|
||||||
|
min-height: 36px; /* 最小触摸高度 */
|
||||||
|
min-width: 36px; /* 最小触摸宽度 */
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保所有内容在移动端都能正确换行和显示 */
|
||||||
|
.analysis-result :deep(*) {
|
||||||
|
max-width: 100% !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-streaming {
|
||||||
|
background-color: rgba(32, 128, 240, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-completed {
|
||||||
|
background-color: rgba(24, 160, 88, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优化标题样式 */
|
||||||
|
.analysis-result :deep(h1),
|
||||||
|
.analysis-result :deep(h2),
|
||||||
|
.analysis-result :deep(h3) {
|
||||||
|
margin: 1rem 0 0.7rem 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-result :deep(h1) {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-result :deep(h2) {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-result :deep(h3) {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优化段落间距 */
|
||||||
|
.analysis-result :deep(p) {
|
||||||
|
margin: 0.6rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优化列表样式 */
|
||||||
|
.analysis-result :deep(ul),
|
||||||
|
.analysis-result :deep(ol) {
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
margin: 0.6rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-result :deep(li) {
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
padding-left: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优化引用块 */
|
||||||
|
.analysis-result :deep(blockquote) {
|
||||||
|
margin: 0.7rem 0;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border-left: 4px solid #f0a020;
|
||||||
|
background-color: rgba(240, 160, 32, 0.07);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优化代码块 */
|
||||||
|
.analysis-result :deep(pre) {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
margin: 0.7rem 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.04);
|
||||||
|
border-left: 3px solid rgba(32, 128, 240, 0.5);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-result :deep(code) {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优化表格显示 */
|
||||||
|
.analysis-result :deep(table) {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
margin: 0.7rem 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-result :deep(th),
|
||||||
|
.analysis-result :deep(td) {
|
||||||
|
padding: 0.5rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优化强调文本 */
|
||||||
|
.analysis-result :deep(strong) {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优化专业术语显示 */
|
||||||
|
.analysis-result :deep(.buy),
|
||||||
|
.analysis-result :deep(.sell),
|
||||||
|
.analysis-result :deep(.hold) {
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-result :deep(.indicator) {
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优化图片显示 */
|
||||||
|
.analysis-result :deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
margin: 0.7rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优化滚动条样式 */
|
||||||
|
.analysis-result::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-result::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(32, 128, 240, 0.3);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动提示 */
|
||||||
|
.analysis-result::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 20px;
|
||||||
|
background: linear-gradient(to top, rgba(255, 255, 255, 0.7), transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.8;
|
||||||
|
border-radius: 0 0 0.5rem 0.5rem;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 小屏幕手机适配 */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.stock-card {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-radius: 0.625rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-info {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-code {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-name {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
max-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-price-info {
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-price, .stock-change {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-price .label,
|
||||||
|
.stock-change .label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-price .value {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-change .value {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 1px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.technical-indicators {
|
||||||
|
margin: 0.5rem 0.25rem;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
padding: 0.4rem 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicators-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-item {
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
padding: 0.5rem 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
background-color: rgba(255, 255, 255, 0.7);
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-value {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保边框在小屏幕上清晰可见 */
|
||||||
|
.stock-card, .indicator-item, .analysis-result {
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 为不同类型的指标设置不同的边框颜色 */
|
||||||
|
.indicator-item .rsi-overbought {
|
||||||
|
border-bottom: 2px solid #d03050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-item .rsi-oversold {
|
||||||
|
border-bottom: 2px solid #18a058;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-item .trend-up {
|
||||||
|
border-bottom: 2px solid #d03050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-item .trend-down {
|
||||||
|
border-bottom: 2px solid #18a058;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-item .signal-buy {
|
||||||
|
border-bottom: 2px solid #d03050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-item .signal-sell {
|
||||||
|
border-bottom: 2px solid #18a058;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分析结果小屏幕样式 */
|
||||||
|
.analysis-result {
|
||||||
|
font-size: 0.825rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 0.5rem 0.4rem;
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
max-height: 300px;
|
||||||
|
max-width: none; /* 移除宽度限制 */
|
||||||
|
width: 100%; /* 占据全部可用宽度 */
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
padding: 0.3rem 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-result :deep(h1) {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-result :deep(h2) {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-result :deep(h3) {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-result :deep(ul),
|
||||||
|
.analysis-result :deep(ol) {
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-result :deep(blockquote) {
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-result :deep(pre) {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.6rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-result :deep(code) {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-result :deep(th),
|
||||||
|
.analysis-result :deep(td) {
|
||||||
|
padding: 0.4rem 0.3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 超小屏幕适配 */
|
||||||
|
@media (max-width: 375px) {
|
||||||
|
.indicators-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-item {
|
||||||
|
padding: 0.4rem 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-value {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-label {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分析结果超小屏幕样式 */
|
||||||
|
.analysis-result {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.4rem 0.3rem;
|
||||||
|
margin: 0.1rem 0;
|
||||||
|
width: 100%; /* 占据全部可用宽度 */
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-result :deep(h1) {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-result :deep(h2) {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-result :deep(h3) {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
padding: 0.2rem 0.05rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 添加PC端特定样式,确保纵向布局 */
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.stock-card {
|
||||||
|
max-width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-main {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-price-info {
|
||||||
|
flex-direction: column;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-summary {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-result {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优化技术指标在PC端的显示 */
|
||||||
|
.indicators-grid {
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保所有嵌套元素不会超出容器 */
|
||||||
|
.analysis-result :deep(*) {
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 对于图片特别控制 */
|
||||||
|
.analysis-result :deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
margin: 0.75rem auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
object-fit: contain; /* 保持图片比例 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 修复长链接可能导致的溢出 */
|
||||||
|
.analysis-result :deep(a) {
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 删除滚动控制面板样式 */
|
||||||
|
.scroll-controls {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="stock-search-container">
|
<div class="stock-search-container">
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="searchKeyword"
|
v-model:value="searchKeyword"
|
||||||
placeholder="输入股票代码或名称搜索"
|
placeholder="输入代码或名称搜索"
|
||||||
@input="handleSearchInput"
|
@input="handleSearchInput"
|
||||||
@blur="handleBlur"
|
@blur="handleBlur"
|
||||||
@focus="handleFocus"
|
@focus="handleFocus"
|
||||||
@@ -13,14 +13,14 @@
|
|||||||
</template>
|
</template>
|
||||||
</n-input>
|
</n-input>
|
||||||
|
|
||||||
<div class="search-results" v-show="showResults">
|
<div class="search-results mobile-search-results" v-show="showResults">
|
||||||
<div v-if="loading" class="loading-results">
|
<div v-if="loading" class="loading-results">
|
||||||
<n-spin size="small" />
|
<n-spin size="small" />
|
||||||
<span>搜索中...</span>
|
<span>搜索中...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="results.length === 0 && searchKeyword" class="no-results">
|
<div v-else-if="results.length === 0 && searchKeyword" class="no-results">
|
||||||
未找到相关股票
|
未找到相关数据
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -28,17 +28,17 @@
|
|||||||
<div
|
<div
|
||||||
v-for="item in results"
|
v-for="item in results"
|
||||||
:key="item.symbol"
|
:key="item.symbol"
|
||||||
class="search-result-item"
|
class="search-result-item mobile-search-result-item"
|
||||||
@click="selectStock(item)"
|
@click="selectStock(item)"
|
||||||
>
|
>
|
||||||
<div class="result-symbol-name">
|
<div class="result-symbol-name">
|
||||||
<span class="result-symbol">{{ item.symbol }}</span>
|
<span class="result-symbol">{{ item.symbol }}</span>
|
||||||
<span class="result-name">{{ item.name }}</span>
|
<span class="result-name mobile-result-name">{{ item.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="result-meta">
|
<div class="result-meta">
|
||||||
<span class="result-market">{{ item.market }}</span>
|
<span class="result-market">{{ item.market }}</span>
|
||||||
<span v-if="item.marketValue" class="result-market-value">
|
<span v-if="item.market_value" class="result-market-value">
|
||||||
市值: {{ formatMarketValue(item.marketValue) }}
|
市值: {{ formatMarketValue(item.market_value) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,11 +87,13 @@ const debouncedSearch = debounce(async (keyword: string) => {
|
|||||||
// 限制只显示前10个结果
|
// 限制只显示前10个结果
|
||||||
results.value = searchResults.slice(0, 10);
|
results.value = searchResults.slice(0, 10);
|
||||||
} else {
|
} else {
|
||||||
// 其他市场搜索 (后端需要实现对应的接口)
|
// 基金搜索
|
||||||
results.value = [];
|
const searchResults = await apiService.searchFunds(keyword);
|
||||||
|
// 限制只显示前10个结果
|
||||||
|
results.value = searchResults.slice(0, 10);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('搜索股票时出错:', error);
|
console.error('搜索数据时出错:', error);
|
||||||
results.value = [];
|
results.value = [];
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -104,7 +106,10 @@ function handleSearchInput() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectStock(item: SearchResult) {
|
function selectStock(item: SearchResult) {
|
||||||
emit('select', item.symbol);
|
// 处理symbol,确保不包含序号
|
||||||
|
// 假设symbol格式可能是"1. AAPL"这样的格式,我们只需要"AAPL"部分
|
||||||
|
const cleanSymbol = item.symbol.replace(/^\d+\.\s*/, '');
|
||||||
|
emit('select', cleanSymbol);
|
||||||
searchKeyword.value = '';
|
searchKeyword.value = '';
|
||||||
showResults.value = false;
|
showResults.value = false;
|
||||||
}
|
}
|
||||||
@@ -219,4 +224,32 @@ onBeforeUnmount(() => {
|
|||||||
.result-market-value {
|
.result-market-value {
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 移动端适配 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.search-result-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保输入框在移动端正确显示 */
|
||||||
|
:deep(.n-input) {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.result-symbol-name, .result-meta {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-market, .result-market-value {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-results, .no-results {
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
import './assets/css/global.css'
|
import './assets/css/global.css'
|
||||||
|
import './assets/styles/mobile.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,19 @@ export const apiService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 搜索基金
|
||||||
|
searchFunds: async (keyword: string): Promise<SearchResult[]> => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get('/search_funds', {
|
||||||
|
params: { keyword }
|
||||||
|
});
|
||||||
|
return response.data.results || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索基金时出错:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// 获取配置
|
// 获取配置
|
||||||
getConfig: async () => {
|
getConfig: async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -24,10 +24,9 @@ a:hover {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
background-color: #f5f5f7;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@@ -63,6 +62,8 @@ button:focus-visible {
|
|||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,3 +114,97 @@ input::-moz-focus-inner {
|
|||||||
outline: none !important;
|
outline: none !important;
|
||||||
box-shadow: 0 0 0 2px rgba(32, 128, 240, 0.2) !important;
|
box-shadow: 0 0 0 2px rgba(32, 128, 240, 0.2) !important;
|
||||||
}
|
}
|
||||||
|
/* 组件宽度统一规范 */
|
||||||
|
.component-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端适配 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
body {
|
||||||
|
background-color: #f5f5f7;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-container {
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-container {
|
||||||
|
width: calc(100% - 16px);
|
||||||
|
max-width: 440px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
#app {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-container {
|
||||||
|
width: calc(100% - 16px);
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
width: calc(100% - 12px);
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-container {
|
||||||
|
width: calc(100% - 8px);
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单控件统一样式 */
|
||||||
|
.uniform-input,
|
||||||
|
.uniform-select,
|
||||||
|
.uniform-button {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,12 +43,13 @@ export interface SearchResult {
|
|||||||
symbol: string;
|
symbol: string;
|
||||||
name: string;
|
name: string;
|
||||||
market: string;
|
market: string;
|
||||||
marketValue?: number;
|
market_value?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MarketStatus {
|
export interface MarketStatus {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
nextTime: string;
|
nextTime: string;
|
||||||
|
progressPercentage?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MarketTimeInfo {
|
export interface MarketTimeInfo {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ pandas==2.2.2
|
|||||||
scipy==1.15.1
|
scipy==1.15.1
|
||||||
|
|
||||||
# 数据获取和分析库
|
# 数据获取和分析库
|
||||||
akshare==1.16.35
|
akshare==1.17.44
|
||||||
tqdm==4.67.1
|
tqdm==4.67.1
|
||||||
|
|
||||||
# Web框架与异步处理
|
# Web框架与异步处理
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import numpy as np
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import asyncio
|
|
||||||
import httpx
|
import httpx
|
||||||
import re
|
import re
|
||||||
from typing import Dict, List, Optional, Any, Generator, AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from logger import get_logger
|
from utils.logger import get_logger
|
||||||
from utils.api_utils import APIUtils
|
from utils.api_utils import APIUtils
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -268,13 +266,17 @@ class AIAnalyzer:
|
|||||||
chunk_data = json.loads(line)
|
chunk_data = json.loads(line)
|
||||||
|
|
||||||
# 检查是否有finish_reason
|
# 检查是否有finish_reason
|
||||||
finish_reason = chunk_data.get("choices", [{}])[0].get("finish_reason")
|
choices = chunk_data.get("choices", [])
|
||||||
|
if not choices:
|
||||||
|
logger.debug("收到空的choices数组,跳过")
|
||||||
|
continue
|
||||||
|
finish_reason = choices[0].get("finish_reason")
|
||||||
if finish_reason == "stop":
|
if finish_reason == "stop":
|
||||||
logger.debug("收到finish_reason=stop,流结束")
|
logger.debug("收到finish_reason=stop,流结束")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 获取delta内容
|
# 获取delta内容
|
||||||
delta = chunk_data.get("choices", [{}])[0].get("delta", {})
|
delta = choices[0].get("delta", {})
|
||||||
|
|
||||||
# 检查delta是否为空对象
|
# 检查delta是否为空对象
|
||||||
if not delta or delta == {}:
|
if not delta or delta == {}:
|
||||||
@@ -352,7 +354,16 @@ class AIAnalyzer:
|
|||||||
return
|
return
|
||||||
|
|
||||||
response_data = response.json()
|
response_data = response.json()
|
||||||
analysis_text = response_data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
choices = response_data.get("choices", [])
|
||||||
|
if not choices:
|
||||||
|
logger.error("API响应中没有choices数据")
|
||||||
|
yield json.dumps({
|
||||||
|
"stock_code": stock_code,
|
||||||
|
"error": "API响应格式错误:缺少choices数据",
|
||||||
|
"status": "error"
|
||||||
|
})
|
||||||
|
return
|
||||||
|
analysis_text = choices[0].get("message", {}).get("content", "")
|
||||||
|
|
||||||
# 尝试从分析内容中提取投资建议
|
# 尝试从分析内容中提取投资建议
|
||||||
recommendation = self._extract_recommendation(analysis_text)
|
recommendation = self._extract_recommendation(analysis_text)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from logger import get_logger
|
from utils.logger import get_logger
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
# 获取日志器
|
# 获取日志器
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import pandas as pd
|
|
||||||
import numpy as np
|
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, List, Optional, Tuple, Any, AsyncGenerator
|
from typing import List, AsyncGenerator
|
||||||
from logger import get_logger
|
from utils.logger import get_logger
|
||||||
from services.stock_data_provider import StockDataProvider
|
from services.stock_data_provider import StockDataProvider
|
||||||
from services.technical_indicator import TechnicalIndicator
|
from services.technical_indicator import TechnicalIndicator
|
||||||
from services.stock_scorer import StockScorer
|
from services.stock_scorer import StockScorer
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import numpy as np
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
from typing import Dict, List, Optional, Tuple, Any
|
from typing import Dict, List, Optional, Tuple, Any
|
||||||
from logger import get_logger
|
from utils.logger import get_logger
|
||||||
import re
|
|
||||||
|
|
||||||
# 获取日志器
|
# 获取日志器
|
||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
@@ -187,9 +184,16 @@ class StockDataProvider:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"日期过滤出错: {str(e)},返回原始数据")
|
logger.warning(f"日期过滤出错: {str(e)},返回原始数据")
|
||||||
|
|
||||||
elif market_type in ['ETF', 'LOF']:
|
elif market_type in ['ETF']:
|
||||||
logger.debug(f"获取{market_type}基金数据: {stock_code}")
|
logger.debug(f"获取{market_type}基金数据: {stock_code}")
|
||||||
df = ak.fund_etf_hist_sina(
|
df = ak.fund_etf_hist_em(
|
||||||
|
symbol=stock_code,
|
||||||
|
start_date=start_date.replace('-', ''),
|
||||||
|
end_date=end_date.replace('-', '')
|
||||||
|
)
|
||||||
|
elif market_type in ['LOF']:
|
||||||
|
logger.debug(f"获取{market_type}基金数据: {stock_code}")
|
||||||
|
df = ak.fund_lof_hist_em(
|
||||||
symbol=stock_code,
|
symbol=stock_code,
|
||||||
start_date=start_date.replace('-', ''),
|
start_date=start_date.replace('-', ''),
|
||||||
end_date=end_date.replace('-', '')
|
end_date=end_date.replace('-', '')
|
||||||
@@ -233,7 +237,7 @@ class StockDataProvider:
|
|||||||
|
|
||||||
elif market_type in ['ETF', 'LOF']:
|
elif market_type in ['ETF', 'LOF']:
|
||||||
# 基金数据可能有不同的列
|
# 基金数据可能有不同的列
|
||||||
df.columns = ['Date', 'Open', 'High', 'Low', 'Close', 'Volume', 'Amount']
|
df.columns = ['Date', 'Open', 'Close', 'High', 'Low', 'Volume', 'Amount', 'Amplitude', 'Change_pct', 'Change', 'Turnover']
|
||||||
|
|
||||||
# 确保日期列是日期类型
|
# 确保日期列是日期类型
|
||||||
if 'Date' in df.columns:
|
if 'Date' in df.columns:
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import numpy as np
|
from typing import Dict, List, Tuple
|
||||||
from typing import Dict, Optional, Any, List, Tuple
|
from utils.logger import get_logger
|
||||||
from logger import get_logger
|
|
||||||
|
|
||||||
# 获取日志器
|
# 获取日志器
|
||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
@@ -14,7 +13,7 @@ class StockScorer:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""初始化股票评分服务"""
|
"""初始化股票评分服务"""
|
||||||
logger.debug("初始化StockScorer")
|
logger.debug("初始化StockScorer股票评分服务")
|
||||||
|
|
||||||
def calculate_score(self, df: pd.DataFrame) -> int:
|
def calculate_score(self, df: pd.DataFrame) -> int:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import numpy as np
|
|
||||||
from typing import Dict, Optional, Any
|
from typing import Dict, Optional, Any
|
||||||
from logger import get_logger
|
from utils.logger import get_logger
|
||||||
|
|
||||||
# 获取日志器
|
# 获取日志器
|
||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
@@ -29,7 +28,7 @@ class TechnicalIndicator:
|
|||||||
'atr_period': 14
|
'atr_period': 14
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(f"初始化TechnicalIndicator,参数: {self.params}")
|
logger.debug(f"初始化TechnicalIndicator技术指标计算服务,参数: {self.params}")
|
||||||
|
|
||||||
def calculate_ema(self, series: pd.Series, period: int) -> pd.Series:
|
def calculate_ema(self, series: pd.Series, period: int) -> pd.Series:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from logger import get_logger
|
from utils.logger import get_logger
|
||||||
|
|
||||||
# 获取日志器
|
# 获取日志器
|
||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
|
|
||||||
class USStockServiceAsync:
|
class USStockServiceAsync:
|
||||||
"""
|
"""
|
||||||
异步美股服务
|
美股服务
|
||||||
提供美股数据的异步搜索和获取功能
|
提供美股数据的搜索和获取功能
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""初始化异步美股服务"""
|
"""初始化美股服务"""
|
||||||
logger.debug("初始化USStockServiceAsync")
|
logger.debug("初始化USStockServiceAsync")
|
||||||
|
|
||||||
# 可选:添加缓存以减少频繁请求
|
# 可选:添加缓存以减少频繁请求
|
||||||
|
|||||||
12
tests/test_akshare.py
Normal file
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 os
|
||||||
import requests
|
import requests
|
||||||
import json
|
import json
|
||||||
from logger import get_logger, get_stream_logger
|
from utils.logger import get_logger
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from utils.api_utils import APIUtils
|
from utils.api_utils import APIUtils
|
||||||
|
|
||||||
# 获取日志器
|
# 获取日志器
|
||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
stream_logger = get_stream_logger()
|
|
||||||
|
|
||||||
def _truncate_json_for_logging(json_obj, max_length=500):
|
def _truncate_json_for_logging(json_obj, max_length=500):
|
||||||
"""截断JSON对象用于日志记录,避免日志过大
|
"""截断JSON对象用于日志记录,避免日志过大
|
||||||
@@ -38,7 +38,7 @@ def test_api_stream():
|
|||||||
# 获取API配置
|
# 获取API配置
|
||||||
api_url = os.getenv('API_URL')
|
api_url = os.getenv('API_URL')
|
||||||
api_key = os.getenv('API_KEY')
|
api_key = os.getenv('API_KEY')
|
||||||
api_model = os.getenv('API_MODEL', 'gpt-3.5-turbo')
|
api_model = os.getenv('API_MODEL', 'gemini-2.0-flash')
|
||||||
|
|
||||||
logger.info(f"开始测试API流式响应,API URL: {api_url}, MODEL: {api_model}")
|
logger.info(f"开始测试API流式响应,API URL: {api_url}, MODEL: {api_model}")
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ def test_api_stream():
|
|||||||
json_data = json.loads(data_content)
|
json_data = json.loads(data_content)
|
||||||
logger.debug(f"JSON结构: {_truncate_json_for_logging(json_data)}")
|
logger.debug(f"JSON结构: {_truncate_json_for_logging(json_data)}")
|
||||||
|
|
||||||
if 'choices' in json_data:
|
if 'choices' in json_data and json_data['choices']:
|
||||||
delta = json_data['choices'][0].get('delta', {})
|
delta = json_data['choices'][0].get('delta', {})
|
||||||
content = delta.get('content', '')
|
content = delta.get('content', '')
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from loguru import logger
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import shutil
|
|
||||||
|
|
||||||
# 创建日志目录
|
# 创建日志目录
|
||||||
log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
|
log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
from fastapi import FastAPI, Request, Response, Depends, HTTPException, BackgroundTasks
|
from fastapi import FastAPI, Request, Response, Depends, HTTPException, BackgroundTasks
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse, FileResponse, RedirectResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import List, Optional, Dict, Any, Generator
|
from typing import List, Optional, Dict, Any, Generator
|
||||||
from services.stock_analyzer_service import StockAnalyzerService
|
from services.stock_analyzer_service import StockAnalyzerService
|
||||||
@@ -10,9 +10,9 @@ from services.us_stock_service_async import USStockServiceAsync
|
|||||||
from services.fund_service_async import FundServiceAsync
|
from services.fund_service_async import FundServiceAsync
|
||||||
import os
|
import os
|
||||||
import httpx
|
import httpx
|
||||||
from logger import get_logger
|
from utils.logger import get_logger
|
||||||
from utils.api_utils import APIUtils
|
from utils.api_utils import APIUtils
|
||||||
from dotenv import load_dotenv, dotenv_values
|
from dotenv import load_dotenv
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
|
|||||||
Reference in New Issue
Block a user