Files
bidding_watcher/frontend/src/components/Dashboard-AI.vue

475 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="dashboard-ai-container">
<div class="dashboard-header">
<h2 class="dashboard-title">Dashboard AI</h2>
<el-button type="primary" :loading="loading" @click="fetchAIRecommendations">
<el-icon style="margin-right: 5px"><MagicStick /></el-icon>
获取 AI 推荐
</el-button>
</div>
<PinnedProject ref="pinnedProjectRef" @pin-changed="handlePinChanged" />
<el-row :gutter="20" class="ai-section">
<el-col :span="24">
<el-card class="box-card" shadow="hover">
<div class="card-header">
<span>AI 推荐项目</span>
<el-tag type="success">{{ aiRecommendations.length }} 个推荐</el-tag>
</div>
<div v-if="loading" style="text-align: center; padding: 40px;">
<el-icon class="is-loading" :size="30"><Loading /></el-icon>
<p style="margin-top: 10px; color: #909399;">AI 正在分析中...</p>
</div>
<div v-else-if="aiRecommendations.length === 0" style="text-align: center; padding: 40px; color: #909399;">
<el-icon :size="40"><InfoFilled /></el-icon>
<p style="margin-top: 10px;">暂无 AI 推荐项目</p>
<p style="font-size: 12px; margin-top: 5px;">点击上方按钮获取 AI 推荐</p>
</div>
<div v-else>
<el-table :data="aiRecommendations" style="width: 100%" size="small" class="ai-table">
<el-table-column label="Pin" width="50" align="center">
<template #default="scope">
<el-icon
:style="{
color: scope.row.pin ? '#f56c6c' : '#909399',
cursor: 'pointer',
fontSize: '16px'
}"
@click="togglePin(scope.row)"
>
<Paperclip />
</el-icon>
</template>
</el-table-column>
<el-table-column prop="title" label="项目名称">
<template #default="scope">
<a :href="scope.row.url" target="_blank">{{ scope.row.title }}</a>
</template>
</el-table-column>
<el-table-column prop="source" label="来源" width="100" />
<el-table-column prop="publishDate" label="发布日期" width="100">
<template #default="scope">
{{ formatDate(scope.row.publishDate) }}
</template>
</el-table-column>
<el-table-column prop="confidence" label="推荐度" width="80">
<template #default="scope">
<el-tag :type="getConfidenceType(scope.row.confidence)">
{{ scope.row.confidence }}%
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</el-col>
</el-row>
<div class="filter-section">
<h3 class="filter-title">选择日期范围</h3>
<div class="filter-controls">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
clearable
class="date-picker"
/>
<div class="button-group">
<el-button type="primary" @click="setLast3Days">3天</el-button>
<el-button type="primary" @click="setLast7Days">7天</el-button>
<el-button type="success" :loading="bidsLoading" @click="fetchBidsByDateRange">
<el-icon style="margin-right: 5px"><List /></el-icon>
列出时间范围内所有工程
</el-button>
</div>
</div>
</div>
<el-row v-if="showAllBids" :gutter="20" style="margin-top: 20px;">
<el-col :span="24">
<el-card class="box-card" shadow="hover">
<template #header>
<div class="card-header">
<span>时间范围内所有工程</span>
<el-tag type="info">{{ bidsByDateRange.length }} 个工程</el-tag>
</div>
</template>
<div v-if="bidsLoading" style="text-align: center; padding: 40px;">
<el-icon class="is-loading" :size="30"><Loading /></el-icon>
<p style="margin-top: 10px; color: #909399;">加载中...</p>
</div>
<div v-else-if="bidsByDateRange.length === 0" style="text-align: center; padding: 40px; color: #909399;">
<el-icon :size="40"><InfoFilled /></el-icon>
<p style="margin-top: 10px;">该时间范围内暂无工程</p>
</div>
<div v-else>
<el-table :data="bidsByDateRange" style="width: 100%" size="small" class="bids-table">
<el-table-column prop="title" label="项目名称">
<template #default="scope">
<a :href="scope.row.url" target="_blank">{{ scope.row.title }}</a>
</template>
</el-table-column>
<el-table-column prop="source" label="来源" width="100" />
<el-table-column prop="publishDate" label="发布日期" width="100">
<template #default="scope">
{{ formatDate(scope.row.publishDate) }}
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import api from '../utils/api'
import { ElMessage } from 'element-plus'
import { MagicStick, Loading, InfoFilled, List, Paperclip } from '@element-plus/icons-vue'
import PinnedProject from './PinnedProject.vue'
interface AIRecommendation {
title: string
url: string
source: string
confidence: number
publishDate?: string
pin?: boolean
}
interface Props {
bids: any[]
}
const props = defineProps<Props>()
const loading = ref(false)
const aiRecommendations = ref<AIRecommendation[]>([])
const dateRange = ref<[string, string] | null>(null)
const showAllBids = ref(false)
const bidsLoading = ref(false)
const bidsByDateRange = ref<any[]>([])
// 从 localStorage 加载保存的日期范围
const loadSavedDateRange = () => {
const saved = localStorage.getItem('dashboardAI_dateRange')
if (saved) {
try {
dateRange.value = JSON.parse(saved)
} catch (e) {
console.error('Failed to parse saved date range:', e)
}
}
}
// 监听日期范围变化并保存到 localStorage
watch(dateRange, (newDateRange) => {
localStorage.setItem('dashboardAI_dateRange', JSON.stringify(newDateRange))
}, { deep: true })
// 从数据库加载最新的 AI 推荐
const loadLatestRecommendations = async () => {
try {
const response = await api.get('/api/ai/latest-recommendations')
const recommendations = response.data
// 获取所有置顶的项目
const pinnedResponse = await api.get('/api/bids/pinned')
const pinnedTitles = new Set(pinnedResponse.data.map((b: any) => b.title))
// 更新每个推荐项目的 pin 状态
aiRecommendations.value = recommendations.map((rec: any) => ({
...rec,
pin: pinnedTitles.has(rec.title)
}))
} catch (error) {
console.error('Failed to load latest recommendations:', error)
}
}
// 初始化时加载保存的日期范围和最新的 AI 推荐
loadSavedDateRange()
loadLatestRecommendations()
// 设置日期范围为最近3天
const setLast3Days = () => {
const endDate = new Date()
const startDate = new Date()
startDate.setDate(startDate.getDate() - 2) // 最近3天包括今天
const formatDateForPicker = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
dateRange.value = [formatDateForPicker(startDate), formatDateForPicker(endDate)]
fetchBidsByDateRange()
}
// 设置日期范围为最近7天
const setLast7Days = () => {
const endDate = new Date()
const startDate = new Date()
startDate.setDate(startDate.getDate() - 6) // 最近7天包括今天
const formatDateForPicker = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
dateRange.value = [formatDateForPicker(startDate), formatDateForPicker(endDate)]
fetchBidsByDateRange()
}
// 获取 AI 推荐项目
const fetchAIRecommendations = async () => {
loading.value = true
try {
// 准备发送给后端的数据(只包含 title
const bidsData = bidsByDateRange.value.map(bid => ({
title: bid.title
}))
// 调用后端 API
const response = await api.post('/api/ai/recommendations', {
bids: bidsData
})
// 根据 title 从 bidsByDateRange 中更新 url 和 source
const recommendations = response.data.map((rec: any) => {
const bid = bidsByDateRange.value.find(b => b.title === rec.title)
return {
title: rec.title,
url: bid?.url || '',
source: bid?.source || '',
confidence: rec.confidence,
publishDate: bid?.publishDate,
pin: bid?.pin || false
}
})
// 按发布时间倒序排列
recommendations.sort((a: AIRecommendation, b: AIRecommendation) => {
if (!a.publishDate) return 1
if (!b.publishDate) return -1
return new Date(b.publishDate).getTime() - new Date(a.publishDate).getTime()
})
aiRecommendations.value = recommendations
// 保存推荐结果到数据库
await api.post('/api/ai/save-recommendations', {
recommendations
})
ElMessage.success('AI 推荐获取成功')
} catch (error: any) {
ElMessage.error('获取 AI 推荐失败')
} finally {
loading.value = false
}
}
// 获取时间范围内的所有工程
const fetchBidsByDateRange = async () => {
if (!dateRange.value || dateRange.value.length !== 2) {
ElMessage.warning('请先选择日期范围')
return
}
showAllBids.value = true
bidsLoading.value = true
try {
const [startDate, endDate] = dateRange.value
// 检查 endDate 是否是今天
const today = new Date()
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
// 如果 endDate 是今天,则不传递 endDate 参数(不限制截止时间)
const params: any = { startDate }
if (endDate !== todayStr) {
params.endDate = endDate
}
const response = await api.get('/api/bids/by-date-range', { params })
bidsByDateRange.value = response.data
ElMessage.success(`获取成功,共 ${response.data.length} 个工程`)
} catch (error: any) {
ElMessage.error('获取工程列表失败')
} finally {
bidsLoading.value = false
}
}
// 格式化日期,只显示年月日
const formatDate = (dateStr: string) => {
if (!dateStr) return ''
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
// 根据推荐度返回标签类型
const getConfidenceType = (confidence: number) => {
if (confidence >= 90) return 'success'
if (confidence >= 70) return 'warning'
return 'info'
}
// PinnedProject 组件引用
const pinnedProjectRef = ref<any>(null)
// 处理 PinnedProject 组件的 pin 状态改变事件
const handlePinChanged = async (title: string) => {
// 更新对应推荐项目的 pin 状态
const rec = aiRecommendations.value.find(r => r.title === title)
if (rec) {
rec.pin = false
}
}
// 切换 AI 推荐项目的 Pin 状态
const togglePin = async (item: AIRecommendation) => {
try {
const newPinStatus = !item.pin
await api.patch(`/api/bids/${encodeURIComponent(item.title)}/pin`, { pin: newPinStatus })
item.pin = newPinStatus
ElMessage.success(newPinStatus ? '已置顶' : '已取消置顶')
// 刷新 PinnedProject 组件的数据
if (pinnedProjectRef.value) {
pinnedProjectRef.value.loadPinnedBids()
}
} catch (error) {
ElMessage.error('操作失败')
}
}
</script>
<style scoped>
.dashboard-ai-container {
padding: 0;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 10px;
}
.dashboard-title {
margin: 0;
font-size: 1.25rem;
}
.ai-section {
margin-top: 16px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.box-card {
margin-top: 10px;
margin-bottom: 10px;
}
.filter-section {
margin-bottom: 16px;
margin-top: 20px;
}
.filter-title {
margin: 0 0 12px 0;
font-size: 1rem;
}
.filter-controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.date-picker {
width: 100%;
max-width: 280px;
}
.button-group {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.ai-table,
.bids-table {
width: 100%;
}
a {
text-decoration: none;
color: inherit;
}
a:hover {
color: #409eff;
}
/* 移动端响应式样式 */
@media (max-width: 768px) {
.dashboard-header {
flex-direction: column;
align-items: flex-start;
}
.dashboard-title {
font-size: 1.1rem;
}
.filter-controls {
flex-direction: column;
align-items: stretch;
}
.date-picker {
max-width: 100%;
}
.button-group {
justify-content: center;
}
.el-table {
font-size: 12px;
}
.el-table .cell {
padding: 6px;
}
.card-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
</style>