This commit is contained in:
aaron 2025-05-25 10:46:58 +08:00
parent 91320adf26
commit 6a3ee5e9ce
3 changed files with 486 additions and 127 deletions

View File

@ -5,7 +5,7 @@ services:
build:
context: .
dockerfile: Dockerfile
image: tradus-web:1.3.17
image: tradus-web:1.3.18
container_name: tradus-web
ports:
- '6000:80'

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, computed, nextTick, onUnmounted } from 'vue'
import { http } from '../services/api'
import { useUserStore } from '../stores/user'
import { marked } from 'marked'
@ -29,6 +29,10 @@ const limit = ref(10)
const totalRecords = ref(0)
const isLoading = ref(false)
const currentPage = ref(1)
const hasMoreData = ref(true)
//
const historyContentRef = ref<HTMLElement | null>(null)
//
const currentOffset = computed(() => (currentPage.value - 1) * limit.value)
@ -82,8 +86,26 @@ const getContentSummary = (content: string, maxLength: number = 100) => {
return summary.substring(0, lastSpaceIndex > 0 ? lastSpaceIndex : maxLength) + '...'
}
//
const handleScroll = () => {
if (!historyContentRef.value || isLoading.value || !hasMoreData.value) return
const { scrollTop, scrollHeight, clientHeight } = historyContentRef.value
// 50px
if (scrollTop + clientHeight >= scrollHeight - 50) {
loadMore()
}
}
//
const loadMore = () => {
if (isLoading.value || !hasMoreData.value) return
currentPage.value++
loadHistoryData(true)
}
//
const loadHistoryData = async () => {
const loadHistoryData = async (append = false) => {
if (!isAuthenticated.value) return
try {
@ -96,23 +118,31 @@ const loadHistoryData = async () => {
if (response.ok) {
const data = await response.json()
//
historyList.value = Array.isArray(data) ? data : []
const newData = Array.isArray(data) ? data : []
if (append && currentPage.value > 1) {
//
historyList.value = [...historyList.value, ...newData]
} else {
//
historyList.value = newData
}
// limit
const hasMoreData = historyList.value.length === limit.value
hasMoreData.value = newData.length === limit.value
//
if (historyList.value.length < limit.value) {
if (newData.length < limit.value) {
// limit
totalRecords.value = currentOffset.value + historyList.value.length
totalRecords.value = currentOffset.value + newData.length
} else if (currentPage.value === 1) {
// limit
totalRecords.value = Math.max(totalRecords.value, limit.value * 2)
}
//
if (!hasMoreData && currentPage.value > 1) {
totalRecords.value = currentOffset.value + historyList.value.length
if (!hasMoreData.value && currentPage.value > 1) {
totalRecords.value = currentOffset.value + newData.length
}
} else {
console.error('获取分析历史失败:', response.status)
@ -124,12 +154,6 @@ const loadHistoryData = async () => {
}
}
//
const handlePageChange = (page: number) => {
currentPage.value = page
loadHistoryData()
}
//
const selectedHistory = ref<(typeof historyList.value)[0] | null>(null)
const showDetail = ref(false)
@ -168,7 +192,26 @@ onMounted(() => {
gfm: true, // GitHubMarkdown
})
//
currentPage.value = 1
hasMoreData.value = true
historyList.value = []
loadHistoryData()
//
nextTick(() => {
if (historyContentRef.value) {
historyContentRef.value.addEventListener('scroll', handleScroll)
}
})
})
onUnmounted(() => {
//
if (historyContentRef.value) {
historyContentRef.value.removeEventListener('scroll', handleScroll)
}
})
</script>
@ -177,15 +220,15 @@ onMounted(() => {
<div class="content-container">
<div class="header-section">
<h1 class="title">分析历史记录</h1>
<p class="description">查看您的所有AI分析结果历史</p>
<!-- <p class="description">查看您的所有AI分析结果历史</p> -->
</div>
<div v-if="!isAuthenticated" class="login-prompt">
<p>请先登录后查看分析历史记录</p>
</div>
<div v-else class="history-content">
<div v-if="isLoading" class="loading-container">
<div v-else class="history-content" ref="historyContentRef">
<div v-if="isLoading && currentPage === 1" class="loading-container">
<div class="loading-spinner"></div>
<p>加载中...</p>
</div>
@ -201,25 +244,19 @@ onMounted(() => {
class="history-item"
@click="viewHistoryDetail(item)"
>
<div class="history-header">
<div class="history-main-info">
<div class="history-symbol">
{{ item.symbol }}
<span v-if="item.timeframe" class="symbol-timeframe">{{ item.timeframe }}</span>
</div>
<div
class="history-type-badge"
:class="{ crypto: item.type === 'crypto', astock: item.type === 'astock' }"
>
{{ getTypeName(item.type) }}
</div>
<div class="history-content">
<div class="history-primary-info">
<div class="history-symbol">
{{ item.symbol }}
<span v-if="item.timeframe" class="symbol-timeframe">({{ item.timeframe }})</span>
</div>
</div>
<div class="history-summary">
{{ getContentSummary(item.content) }}
</div>
<div class="history-time">
<svg
class="time-icon"
@ -236,26 +273,39 @@ onMounted(() => {
{{ formatDateTime(item.create_time) }}
</div>
</div>
<div class="history-summary">
{{ getContentSummary(item.content) }}
</div>
<div class="history-footer">
<div class="view-detail-hint">
<svg
class="arrow-icon"
viewBox="0 0 24 24"
width="16"
height="16"
stroke="currentColor"
stroke-width="2"
fill="none"
>
<path d="M9 18l6-6-6-6"></path>
</svg>
点击查看详情
</div>
</div>
</div>
<!-- 分页控件 -->
<div v-if="historyList.length > 0" class="pagination">
<button
v-if="currentPage > 1"
class="page-btn"
@click="handlePageChange(currentPage - 1)"
>
上一页
</button>
<!-- 底部加载状态 -->
<div v-if="isLoading && currentPage > 1" class="loading-more">
<div class="loading-spinner-small"></div>
<span>加载更多中...</span>
</div>
<button
v-if="historyList.length === limit"
class="page-btn"
@click="handlePageChange(currentPage + 1)"
>
加载更多
</button>
<!-- 没有更多数据提示 -->
<div v-else-if="!hasMoreData && historyList.length > 0" class="no-more-data">
<span>已加载全部数据</span>
</div>
</div>
</div>
</div>
@ -347,7 +397,7 @@ onMounted(() => {
flex: 1;
overflow-y: auto; /* 启用内容区域滚动 */
padding-right: 0.5rem; /* 为滚动条留出空间 */
max-height: calc(100vh - 200px); /* 限制最大高度,确保可滚动 */
max-height: calc(100vh); /* 限制最大高度,确保可滚动 */
}
.loading-container {
@ -392,127 +442,175 @@ onMounted(() => {
.history-item {
display: flex;
padding: 1rem;
flex-direction: column;
padding: 1.25rem;
background-color: var(--color-bg-secondary);
border-radius: var(--border-radius);
transition: all 0.2s ease;
transition: all 0.3s ease;
cursor: pointer;
position: relative;
overflow: hidden;
gap: 1rem;
align-items: flex-start;
border-left: 3px solid transparent;
border: 1px solid var(--color-border);
}
.history-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
background-color: var(--color-bg-hover);
border-left-color: var(--color-accent);
border-color: var(--color-accent);
}
.history-item:hover .view-detail-hint {
color: var(--color-accent);
}
.history-item:hover .arrow-icon {
transform: translateX(2px);
}
.history-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.history-main-info {
display: flex;
gap: 1rem;
align-items: center;
flex: 1;
min-width: 0;
}
.history-symbol {
font-weight: 600;
font-size: 1.2rem;
color: var(--color-text-primary);
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: nowrap;
min-width: 0;
}
.symbol-timeframe {
font-size: 0.85rem;
font-weight: 500;
color: var(--color-accent);
background-color: rgba(51, 85, 255, 0.1);
padding: 0.2rem 0.5rem;
border-radius: 4px;
white-space: nowrap;
flex-shrink: 0;
}
.history-type-badge {
font-size: 0.9rem;
font-size: 0.8rem;
font-weight: 600;
padding: 0.4rem 0.8rem;
border-radius: var(--border-radius);
padding: 0.3rem 0.7rem;
border-radius: 20px;
background-color: rgba(51, 85, 255, 0.1);
color: var(--color-accent);
text-align: center;
min-width: 90px;
white-space: nowrap;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
border: 1px solid rgba(51, 85, 255, 0.2);
}
.history-type-badge.crypto {
background-color: rgba(255, 152, 0, 0.1);
color: #ff9800;
border-color: rgba(255, 152, 0, 0.2);
}
.history-type-badge.astock {
background-color: rgba(76, 175, 80, 0.1);
color: #4caf50;
border-color: rgba(76, 175, 80, 0.2);
}
.history-content {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
}
.history-primary-info {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.history-symbol {
font-weight: 600;
font-size: 1.1rem;
color: var(--color-text-primary);
.history-time {
font-size: 0.8rem;
color: var(--color-text-secondary);
display: flex;
align-items: center;
gap: 0.3rem;
flex-wrap: wrap;
white-space: nowrap;
flex-shrink: 0;
}
.symbol-timeframe {
font-size: 0.9rem;
font-weight: 500;
color: var(--color-text-secondary);
.time-icon {
opacity: 0.7;
flex-shrink: 0;
}
.history-summary {
font-size: 0.85rem;
font-size: 0.9rem;
color: var(--color-text-secondary);
margin-top: 0.2rem;
line-height: 1.4;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
padding-right: 0.5rem;
margin: 0.5rem 0;
}
.history-time {
font-size: 0.85rem;
.history-footer {
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border);
}
.view-detail-hint {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--color-text-secondary);
display: flex;
align-items: center;
gap: 0.3rem;
}
.time-icon {
opacity: 0.7;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
margin-top: 2rem;
gap: 1rem;
}
.page-btn {
padding: 0.5rem 1rem;
background-color: var(--color-accent);
color: white;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s ease;
}
.page-btn:hover:not(:disabled) {
background-color: var(--color-accent-hover);
.arrow-icon {
width: 16px;
height: 16px;
transition: transform 0.2s ease;
}
.page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
.loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 1.5rem;
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.loading-spinner-small {
width: 20px;
height: 20px;
border: 2px solid var(--color-border);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.no-more-data {
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
color: var(--color-text-secondary);
font-size: 0.85rem;
opacity: 0.7;
}
/* 详情弹窗样式 */
@ -522,7 +620,8 @@ onMounted(() => {
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
background-color: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
@ -539,7 +638,27 @@ onMounted(() => {
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
border: 1px solid var(--color-border);
position: relative;
}
.detail-content::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.1) 0%,
rgba(255, 255, 255, 0.05) 50%,
rgba(255, 255, 255, 0.02) 100%
);
border-radius: var(--border-radius);
pointer-events: none;
z-index: 1;
}
.detail-header {
@ -548,6 +667,9 @@ onMounted(() => {
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--color-border);
background-color: var(--color-bg-secondary);
position: relative;
z-index: 2;
}
.detail-header h2 {
@ -565,6 +687,13 @@ onMounted(() => {
cursor: pointer;
padding: 0.25rem 0.5rem;
line-height: 1;
border-radius: 4px;
transition: all 0.2s ease;
}
.close-btn:hover {
background-color: var(--color-bg-hover);
color: var(--color-text-primary);
}
.detail-info {
@ -573,6 +702,9 @@ onMounted(() => {
display: flex;
flex-wrap: wrap;
gap: 1rem;
border-bottom: 1px solid var(--color-border);
position: relative;
z-index: 2;
}
.info-row {
@ -595,6 +727,9 @@ onMounted(() => {
flex: 1;
overflow-y: auto;
padding: 0;
background-color: var(--color-bg-primary);
position: relative;
z-index: 2;
}
.analysis-content {
@ -602,48 +737,200 @@ onMounted(() => {
}
/* 响应式样式 */
@media (max-width: 1024px) {
.content-container {
max-width: 100%;
padding: 1.5rem 1rem;
}
.history-item {
padding: 1.25rem;
}
.history-symbol {
font-size: 1.1rem;
}
.history-summary {
font-size: 0.9rem;
}
}
@media (max-width: 768px) {
.title {
font-size: 1.75rem;
}
.history-item {
flex-direction: column;
align-items: flex-start;
padding: 1rem;
gap: 0.75rem;
}
.history-header {
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.history-main-info {
flex-direction: row;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.history-symbol {
font-size: 1rem;
flex-direction: row;
align-items: center;
gap: 0.5rem;
flex-wrap: nowrap;
}
.symbol-timeframe {
font-size: 0.75rem;
padding: 0.15rem 0.4rem;
flex-shrink: 0;
}
.history-type-badge {
align-self: flex-start;
font-size: 0.75rem;
padding: 0.25rem 0.6rem;
}
.history-time {
font-size: 0.75rem;
flex-shrink: 0;
}
.history-summary {
-webkit-line-clamp: 3;
-webkit-line-clamp: 2;
font-size: 0.85rem;
margin: 0.5rem 0;
}
.view-detail-hint {
font-size: 0.8rem;
}
.history-footer {
margin-top: 0.5rem;
padding-top: 0.5rem;
}
}
@media (max-width: 480px) {
.content-container {
padding: 4.5rem 0.5rem;
padding: 1rem 0.75rem;
}
.title {
font-size: 1.5rem;
margin-bottom: 1rem;
}
.history-primary-info {
.header-section {
margin-bottom: 1.5rem;
}
.history-item {
padding: 1rem;
gap: 0.75rem;
border-radius: 8px;
}
.history-header {
flex-direction: column;
align-items: flex-start;
align-items: stretch;
gap: 0.75rem;
}
.history-main-info {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
width: 100%;
}
.history-symbol {
font-size: 1.1rem;
flex-direction: row;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
flex-wrap: nowrap;
}
.symbol-timeframe {
font-size: 0.8rem;
padding: 0.2rem 0.5rem;
flex-shrink: 0;
}
.history-type-badge {
font-size: 0.8rem;
padding: 0.3rem 0.7rem;
flex-shrink: 0;
}
.history-time {
font-size: 0.8rem;
align-self: flex-start;
text-align: right;
flex-shrink: 0;
}
.history-summary {
font-size: 0.85rem;
margin: 0.5rem 0;
-webkit-line-clamp: 3;
line-height: 1.4;
}
.history-footer {
margin-top: 0.5rem;
padding-top: 0.5rem;
justify-content: center;
}
.view-detail-hint {
font-size: 0.8rem;
}
.detail-content {
max-height: 95vh;
margin: 0.5rem;
}
.detail-header {
padding: 1rem;
}
.detail-header h2 {
font-size: 1.1rem;
}
.detail-info {
padding: 1rem;
gap: 0.75rem;
}
.info-row {
flex-direction: column;
gap: 0.25rem;
}
.info-label {
font-size: 0.8rem;
}
.info-value {
font-size: 0.9rem;
}
}
/* 优化滚动条样式 */
@ -834,3 +1121,73 @@ onMounted(() => {
border: 0;
}
</style>
<style>
/* 深色主题下的弹窗优化 */
:global(html[data-theme='dark']) .detail-modal {
background-color: rgba(0, 0, 0, 0.8);
}
:global(html[data-theme='dark']) .detail-content {
background-color: #0a0a0a;
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
}
:global(html[data-theme='dark']) .detail-content::before {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.08) 0%,
rgba(255, 255, 255, 0.04) 50%,
rgba(255, 255, 255, 0.02) 100%
);
}
:global(html[data-theme='dark']) .detail-header {
background-color: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
}
:global(html[data-theme='dark']) .detail-info {
background-color: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
}
:global(html[data-theme='dark']) .detail-content-wrapper {
background-color: #0a0a0a;
}
/* 浅色主题下的弹窗优化 */
:global(html[data-theme='light']) .detail-modal {
background-color: rgba(0, 0, 0, 0.5);
}
:global(html[data-theme='light']) .detail-content {
background-color: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
}
:global(html[data-theme='light']) .detail-content::before {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.8) 0%,
rgba(255, 255, 255, 0.4) 50%,
rgba(255, 255, 255, 0.2) 100%
);
}
:global(html[data-theme='light']) .detail-header {
background-color: rgba(0, 0, 0, 0.02);
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
:global(html[data-theme='light']) .detail-info {
background-color: rgba(0, 0, 0, 0.02);
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
:global(html[data-theme='light']) .detail-content-wrapper {
background-color: #ffffff;
}
</style>

View File

@ -8,7 +8,8 @@
<div class="header-section">
<h1 class="title">联系我们</h1>
<p class="description">
我们是一支专注于加密货币和股票市场分析的专业团队致力于通过人工智能技术为投资者提供最前沿的分析工具
Tradus
是一个专注于加密货币和股票市场分析的团队致力于通过人工智能技术为投资者提供最前沿的分析工具
无论您是寻求技术合作商业合作还是投资机会我们都非常欢迎与您联系交流
</p>
</div>
@ -77,6 +78,7 @@
color: var(--color-text-secondary);
max-width: 700px;
margin: 0 auto 1.5rem;
text-align: left;
}
.contact-content {