This commit is contained in:
aaron 2025-05-24 12:09:05 +08:00
parent 67c3c0bd6c
commit e296642ed9
5 changed files with 877 additions and 1 deletions

View File

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

View File

@ -320,6 +320,27 @@ onUnmounted(() => {
</svg>
<span class="agent-name">AI分析智能体</span>
</RouterLink>
<RouterLink
to="/analysis-history"
class="agent-item"
@click="showMobileMenu = false"
v-if="isAuthenticated"
>
<svg
class="agent-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
<span class="agent-name">分析历史</span>
</RouterLink>
</div>
<!-- 桌面端用户信息 -->

View File

@ -44,6 +44,15 @@ const router = createRouter({
title: '智能分析助理',
},
},
{
path: '/analysis-history',
name: 'analysis-history',
component: () => import('../views/AnalysisHistoryView.vue'),
meta: {
requiresAuth: true,
title: '分析历史记录',
},
},
],
})

View File

@ -0,0 +1,815 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { http } from '../services/api'
import { useUserStore } from '../stores/user'
import { marked } from 'marked'
// APIURL
const apiBaseUrl =
import.meta.env.MODE === 'development' ? 'http://127.0.0.1:8000' : 'https://api.ibtc.work'
const userStore = useUserStore()
const isAuthenticated = computed(() => userStore.isAuthenticated)
//
const historyList = ref<
Array<{
id: string
user_id: string
type: string
symbol: string
timeframe?: string
content: string
create_time: string
}>
>([])
//
const limit = ref(10)
const totalRecords = ref(0)
const isLoading = ref(false)
const currentPage = ref(1)
//
const currentOffset = computed(() => (currentPage.value - 1) * limit.value)
//
const getTypeName = (type: string) => {
return type === 'astock' ? 'A股分析' : type === 'crypto' ? '加密货币分析' : type
}
//
const formatDateTime = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
//
const getContentSummary = (content: string, maxLength: number = 100) => {
// Markdown
const cleanedContent = content
.replace(/^#+\s+.*$/gm, '') //
.replace(/\*\*(.*?)\*\*/g, '$1') //
.replace(/\*(.*?)\*/g, '$1') //
.replace(/\[(.*?)\]\(.*?\)/g, '$1') //
.replace(/^\s*[-*+]\s+/gm, '') //
.replace(/^\s*>\s+/gm, '') //
.replace(/---/g, '') // 线
.trim()
//
const firstParagraph =
cleanedContent
.split('\n')
.filter((line) => line.trim())
.shift() || ''
//
if (firstParagraph.length <= maxLength) {
return firstParagraph
}
//
const summary = firstParagraph.substring(0, maxLength)
const lastSpaceIndex = summary.lastIndexOf(' ')
return summary.substring(0, lastSpaceIndex > 0 ? lastSpaceIndex : maxLength) + '...'
}
//
const loadHistoryData = async () => {
if (!isAuthenticated.value) return
try {
isLoading.value = true
const response = await http.get(
`${apiBaseUrl}/analysis/analysis_histories?limit=${limit.value}&offset=${currentOffset.value}`,
)
if (response.ok) {
const data = await response.json()
//
historyList.value = Array.isArray(data) ? data : []
// limit
const hasMoreData = historyList.value.length === limit.value
//
if (historyList.value.length < limit.value) {
// limit
totalRecords.value = currentOffset.value + historyList.value.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
}
} else {
console.error('获取分析历史失败:', response.status)
}
} catch (error) {
console.error('获取分析历史异常:', error)
} finally {
isLoading.value = false
}
}
//
const handlePageChange = (page: number) => {
currentPage.value = page
loadHistoryData()
}
//
const selectedHistory = ref<(typeof historyList.value)[0] | null>(null)
const showDetail = ref(false)
const viewHistoryDetail = (history: (typeof historyList.value)[0]) => {
selectedHistory.value = history
showDetail.value = true
}
const closeDetail = () => {
showDetail.value = false
selectedHistory.value = null
}
// markdown
const parsedContent = computed(() => {
if (!selectedHistory.value?.content) {
return ''
}
// markdown
let html = marked(selectedHistory.value.content) as string
// div
html = html.replace(/<table>/g, '<div class="table-container"><table>')
html = html.replace(/<\/table>/g, '</table></div>')
return html
})
//
onMounted(() => {
// marked
marked.setOptions({
breaks: true,
gfm: true, // GitHubMarkdown
})
loadHistoryData()
})
</script>
<template>
<div class="analysis-history-view">
<div class="content-container">
<div class="header-section">
<h1 class="title">分析历史记录</h1>
<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 class="loading-spinner"></div>
<p>加载中...</p>
</div>
<div v-else-if="historyList.length === 0" class="empty-state">
<p>暂无分析历史记录</p>
</div>
<div v-else class="history-list">
<div
v-for="item in historyList"
:key="item.id"
class="history-item"
@click="viewHistoryDetail(item)"
>
<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"
viewBox="0 0 24 24"
width="14"
height="14"
stroke="currentColor"
stroke-width="2"
fill="none"
>
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
{{ formatDateTime(item.create_time) }}
</div>
</div>
</div>
</div>
<!-- 分页控件 -->
<div v-if="historyList.length > 0" class="pagination">
<button
v-if="currentPage > 1"
class="page-btn"
@click="handlePageChange(currentPage - 1)"
>
上一页
</button>
<span class="page-info"> {{ currentPage }} </span>
<button
v-if="historyList.length === limit"
class="page-btn"
@click="handlePageChange(currentPage + 1)"
>
加载更多
</button>
</div>
</div>
</div>
<!-- 详情弹窗 -->
<div v-if="showDetail && selectedHistory" class="detail-modal">
<div class="detail-content">
<div class="detail-header">
<h2>分析详情</h2>
<button class="close-btn" @click="closeDetail">×</button>
</div>
<div class="detail-info">
<div class="info-row">
<span class="info-label">标的代码:</span>
<span class="info-value">{{ selectedHistory.symbol }}</span>
</div>
<div class="info-row">
<span class="info-label">分析类型:</span>
<span class="info-value">{{ getTypeName(selectedHistory.type) }}</span>
</div>
<div v-if="selectedHistory.timeframe" class="info-row">
<span class="info-label">时间周期:</span>
<span class="info-value">{{ selectedHistory.timeframe }}</span>
</div>
<div class="info-row">
<span class="info-label">分析时间:</span>
<span class="info-value">{{ formatDateTime(selectedHistory.create_time) }}</span>
</div>
</div>
<div class="detail-content-wrapper">
<div class="analysis-content markdown-content" v-html="parsedContent"></div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.analysis-history-view {
min-height: 100vh;
background-color: var(--color-bg-primary);
padding: 1rem;
display: flex;
flex-direction: column;
}
.content-container {
max-width: 900px;
margin: 0 auto;
width: 100%;
padding: 2rem 1rem;
}
.header-section {
text-align: center;
margin-bottom: 2rem;
}
.title {
font-size: 2rem;
font-weight: 700;
color: var(--color-text-primary);
margin-bottom: 0.5rem;
}
.description {
font-size: 0.95rem;
color: var(--color-text-secondary);
}
.login-prompt {
text-align: center;
padding: 2rem;
background-color: var(--color-bg-secondary);
border-radius: var(--border-radius);
color: var(--color-text-secondary);
}
.history-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
color: var(--color-text-secondary);
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-border);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.empty-state {
text-align: center;
padding: 2rem;
background-color: var(--color-bg-secondary);
border-radius: var(--border-radius);
color: var(--color-text-secondary);
}
.history-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.history-item {
display: flex;
padding: 1rem;
background-color: var(--color-bg-secondary);
border-radius: var(--border-radius);
transition: all 0.2s ease;
cursor: pointer;
position: relative;
overflow: hidden;
gap: 1rem;
align-items: flex-start;
border-left: 3px solid transparent;
}
.history-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
background-color: var(--color-bg-hover);
border-left-color: var(--color-accent);
}
.history-type-badge {
font-size: 0.9rem;
font-weight: 600;
padding: 0.4rem 0.8rem;
border-radius: var(--border-radius);
background-color: rgba(51, 85, 255, 0.1);
color: var(--color-accent);
text-align: center;
min-width: 90px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.history-type-badge.crypto {
background-color: rgba(255, 152, 0, 0.1);
color: #ff9800;
}
.history-type-badge.astock {
background-color: rgba(76, 175, 80, 0.1);
color: #4caf50;
}
.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);
display: flex;
align-items: center;
gap: 0.3rem;
flex-wrap: wrap;
}
.symbol-timeframe {
font-size: 0.9rem;
font-weight: 500;
color: var(--color-text-secondary);
}
.history-summary {
font-size: 0.85rem;
color: var(--color-text-secondary);
margin-top: 0.2rem;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
padding-right: 0.5rem;
}
.history-time {
font-size: 0.85rem;
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;
transition: all 0.2s ease;
}
.page-btn:hover:not(:disabled) {
background-color: var(--color-accent-hover);
}
.page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-info {
font-size: 0.9rem;
color: var(--color-text-secondary);
}
/* 详情弹窗样式 */
.detail-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.detail-content {
background-color: var(--color-bg-primary);
border-radius: var(--border-radius);
width: 100%;
max-width: 900px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--color-border);
}
.detail-header h2 {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
color: var(--color-text-secondary);
cursor: pointer;
padding: 0.25rem 0.5rem;
line-height: 1;
}
.detail-info {
padding: 1rem;
background-color: var(--color-bg-secondary);
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.info-row {
display: flex;
gap: 0.5rem;
}
.info-label {
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.info-value {
color: var(--color-text-primary);
font-weight: 500;
font-size: 0.9rem;
}
.detail-content-wrapper {
flex: 1;
overflow-y: auto;
padding: 0;
}
.analysis-content {
padding: 1.5rem;
}
/* 响应式样式 */
@media (max-width: 768px) {
.title {
font-size: 1.75rem;
}
.history-item {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.history-type-badge {
align-self: flex-start;
}
.history-summary {
-webkit-line-clamp: 3;
}
}
@media (max-width: 480px) {
.content-container {
padding: 1rem 0.5rem;
}
.title {
font-size: 1.5rem;
}
.history-primary-info {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.detail-content {
max-height: 95vh;
}
.detail-header h2 {
font-size: 1.1rem;
}
}
</style>
<style>
/* Markdown 内容样式 - 与分析页面保持一致 */
.markdown-content {
white-space: normal !important;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
margin-top: 1.5rem;
margin-bottom: 1rem;
font-weight: 600;
line-height: 1.25;
color: var(--color-text-primary);
font-size: 1.2rem;
}
.markdown-content h1 {
border-bottom: 1px solid var(--color-border);
padding-bottom: 0.3em;
}
.markdown-content h2 {
border-bottom: 1px solid var(--color-border);
padding-bottom: 0.3em;
}
.markdown-content p {
margin-top: 0;
margin-bottom: 1.5rem;
line-height: 1.8;
}
.markdown-content ul,
.markdown-content ol {
margin-top: 0;
margin-bottom: 1.5rem;
padding-left: 2rem;
}
.markdown-content li {
margin-bottom: 0.5rem;
line-height: 1.7;
}
.markdown-content li p {
margin-bottom: 0.7rem;
}
.markdown-content li:last-child {
margin-bottom: 0;
}
.markdown-content blockquote {
padding: 0.5rem 1.5rem;
color: var(--color-text-secondary);
border-left: 0.3rem solid var(--color-border);
margin: 0 0 1.5rem;
}
.markdown-content blockquote p:last-child {
margin-bottom: 0;
}
.markdown-content pre {
margin-top: 0;
margin-bottom: 1rem;
padding: 1rem;
overflow: auto;
font-size: 85%;
line-height: 1.45;
background-color: var(--color-bg-secondary);
border-radius: var(--border-radius);
}
.markdown-content code {
font-family:
SFMono-Regular,
Consolas,
Liberation Mono,
Menlo,
monospace;
font-size: 85%;
padding: 0.2em 0.4em;
margin: 0;
background-color: var(--color-bg-secondary);
border-radius: 3px;
}
.markdown-content pre code {
padding: 0;
background-color: transparent;
}
.markdown-content .table-container {
width: 100%;
overflow-x: auto;
margin-bottom: 1.5rem;
}
.markdown-content table {
width: 100%;
margin-top: 0;
margin-bottom: 0;
border-spacing: 0;
border-collapse: collapse;
max-width: 100%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
border: 1px solid var(--color-border);
overflow-x: auto;
display: block;
}
.markdown-content table th,
.markdown-content table td {
padding: 0.75rem 1rem;
border: 1px solid var(--color-border);
text-align: left;
min-width: 100px;
vertical-align: top;
white-space: normal;
word-break: break-word;
}
.markdown-content table th {
font-weight: 600;
background-color: var(--color-bg-secondary);
white-space: nowrap;
position: sticky;
top: 0;
z-index: 1;
}
.markdown-content table tr {
background-color: var(--color-bg-primary);
border-top: 1px solid var(--color-border);
}
.markdown-content table tr:nth-child(2n) {
background-color: var(--color-bg-secondary);
}
.markdown-content a {
color: var(--color-accent);
text-decoration: none;
}
.markdown-content a:hover {
text-decoration: underline;
}
.markdown-content img {
max-width: 100%;
height: auto;
display: block;
margin: 1rem auto;
}
.markdown-content hr {
height: 0.25em;
padding: 0;
margin: 1.5rem 0;
background-color: var(--color-border);
border: 0;
}
</style>

View File

@ -134,6 +134,34 @@ const setErrorMessage = (message: string) => {
currentThought.value = '分析失败'
}
//
const saveAnalysisHistory = async () => {
try {
//
const payload: Record<string, string> = {
content: analysisContent.value,
type: isStockMode.value ? 'astock' : 'crypto',
}
//
if (isStockMode.value) {
payload.symbol = symbolCode.value.trim()
} else {
payload.symbol = symbolCode.value.toUpperCase().trim()
payload.timeframe = selectedTimeframe.value
}
// 使http.post
const response = await http.post(`${apiBaseUrl}/analysis/analysis_history`, payload)
if (!response.ok) {
console.error('保存分析历史失败:', response.status)
}
} catch (error) {
console.error('保存分析历史异常:', error)
}
}
//
const handleAnalysis = async () => {
const code = symbolCode.value.trim()
@ -235,6 +263,9 @@ const handleAnalysis = async () => {
await scrollToBottom()
}
currentThought.value = `分析完成 (用时 ${Math.round(data.data.elapsed_time)}秒)`
//
await saveAnalysisHistory()
}
break