feat: 添加 AI 推荐功能

新增 AI 推荐模块,包括前端界面和后端服务
添加 OpenAI API 密钥配置
实现工程数据分析和推荐功能
This commit is contained in:
dmy
2026-01-12 18:36:08 +08:00
parent 3647b9a2e5
commit 61520e9ebf
12 changed files with 362 additions and 9 deletions

2
frontend/.env Normal file
View File

@@ -0,0 +1,2 @@
# ARK API Key (用于 AI 推荐)
VITE_ARK_API_KEY=a63d58b6-cf56-434b-8a42-5c781ba0822a

2
frontend/.env.example Normal file
View File

@@ -0,0 +1,2 @@
# OpenAI API Key (用于 AI 推荐)
VITE_OPENAI_API_KEY=your_openai_api_key_here

View File

@@ -12,6 +12,7 @@
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.2",
"element-plus": "^2.13.1",
"openai": "^6.16.0",
"vue": "^3.5.24"
},
"devDependencies": {

View File

@@ -15,10 +15,14 @@
<span>Dashboard</span>
</el-menu-item>
<el-menu-item index="2">
<el-icon><MagicStick /></el-icon>
<span>Dashboard AI</span>
</el-menu-item>
<el-menu-item index="3">
<el-icon><Document /></el-icon>
<span>Bids</span>
</el-menu-item>
<el-menu-item index="3">
<el-menu-item index="4">
<el-icon><Setting /></el-icon>
<span>Keywords</span>
</el-menu-item>
@@ -41,9 +45,14 @@
@refresh="fetchData"
/>
<Bids
<DashboardAI
v-if="activeIndex === '2'"
:bids="bids"
/>
<Bids
v-if="activeIndex === '3'"
:bids="bids"
:source-options="sourceOptions"
:loading="loading"
:total="total"
@@ -51,7 +60,7 @@
/>
<Keywords
v-if="activeIndex === '3'"
v-if="activeIndex === '4'"
:keywords="keywords"
:loading="loading"
@refresh="fetchData"
@@ -64,8 +73,9 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import axios from 'axios'
import { DataBoard, Document, Setting } from '@element-plus/icons-vue'
import { DataBoard, Document, Setting, MagicStick } from '@element-plus/icons-vue'
import Dashboard from './components/Dashboard.vue'
import DashboardAI from './components/Dashboard-AI.vue'
import Bids from './components/Bids.vue'
import Keywords from './components/Keywords.vue'

View File

@@ -0,0 +1,294 @@
<template>
<div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2 style="margin: 0;">Dashboard AI</h2>
<el-button type="primary" :loading="loading" @click="fetchAIRecommendations">
<el-icon style="margin-right: 5px"><MagicStick /></el-icon>
获取 AI 推荐
</el-button>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h3 style="margin: 0;">选择日期范围</h3>
<div style="display: flex; gap: 10px;">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="To"
start-placeholder="Start Date"
end-placeholder="End Date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
clearable
style="width: 240px;"
/>
<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>
<el-row :gutter="20">
<el-col :span="24">
<el-card class="box-card" shadow="hover">
<template #header>
<div class="card-header">
<span>AI 推荐项目</span>
<el-tag type="success">{{ aiRecommendations.length }} 个推荐</el-tag>
</div>
</template>
<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">
<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="200" />
<el-table-column prop="confidence" label="推荐度" width="120">
<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>
<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">
<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="200" />
<el-table-column prop="publishDate" label="发布日期" width="180">
<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, computed, watch } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { MagicStick, Loading, InfoFilled, List } from '@element-plus/icons-vue'
interface AIRecommendation {
title: string
url: string
source: string
confidence: number
}
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[]>([])
// 根据日期范围过滤 bids
const filteredBids = computed(() => {
let result = props.bids
// 按日期范围筛选(只限制开始时间,不限制结束时间)
if (dateRange.value && dateRange.value.length === 2) {
const [startDate] = dateRange.value
result = result.filter(bid => {
if (!bid.publishDate) return false
const bidDate = new Date(bid.publishDate)
const start = new Date(startDate)
// 设置时间为当天的开始
start.setHours(0, 0, 0, 0)
return bidDate >= start
})
}
return result
})
// 监听日期范围变化并显示提示
watch(dateRange, () => {
const totalBids = props.bids.length
const filteredCount = filteredBids.value.length
if (totalBids > 0 && filteredCount < totalBids) {
ElMessage.info(`筛选结果:共 ${filteredCount} 条数据(总共 ${totalBids} 条)`)
}
})
// 设置日期范围为最近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 {
// 准备发送给后端的数据(使用过滤后的 bids
const bidsData = filteredBids.value.map(bid => ({
title: bid.title,
url: bid.url,
source: bid.source,
publishDate: bid.publishDate
}))
// 调用后端 API
const response = await axios.post('/api/ai/recommendations', {
bids: bidsData
})
aiRecommendations.value = response.data
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 axios.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'
}
// 初始化时设置默认日期范围为最近3天
setLast3Days()
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
a {
text-decoration: none;
color: inherit;
}
a:hover {
color: #409eff;
}
</style>

View File

@@ -12,5 +12,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "../src/ai/Prompt.ts"]
}