Compare commits
15 Commits
996289c671
...
3b3cef582e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b3cef582e | ||
|
|
d1e64596aa | ||
|
|
f050d38140 | ||
|
|
feb18c01bb | ||
|
|
50bc930663 | ||
|
|
4f4355c1cd | ||
|
|
6825885005 | ||
|
|
894976e680 | ||
|
|
333748a6b9 | ||
|
|
72e5230584 | ||
|
|
b3d784f1e3 | ||
|
|
b261ff074c | ||
|
|
7f36e014e6 | ||
|
|
5024d2c502 | ||
|
|
5f186bfb2a |
@@ -17,9 +17,13 @@
|
||||
"vue": "^3.5.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.2.4",
|
||||
"vue-tsc": "^3.1.4"
|
||||
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -2,32 +2,37 @@
|
||||
<el-container class="layout-container" style="height: 100vh">
|
||||
<el-aside width="200px" style="background-color: #545c64">
|
||||
<div class="logo">投标信息一览</div>
|
||||
<el-menu
|
||||
active-text-color="#ffd04b"
|
||||
background-color="#545c64"
|
||||
class="el-menu-vertical-demo"
|
||||
default-active="1"
|
||||
text-color="#fff"
|
||||
@select="handleSelect"
|
||||
>
|
||||
<el-menu active-text-color="#ffd04b" background-color="#545c64" class="el-menu-vertical-demo" default-active="1"
|
||||
text-color="#fff" @select="handleSelect">
|
||||
|
||||
<el-menu-item index="1">
|
||||
<el-icon><DataBoard /></el-icon>
|
||||
<span>Dashboard</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="2">
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
<el-icon>
|
||||
<MagicStick />
|
||||
</el-icon>
|
||||
<span>Dashboard AI</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="2">
|
||||
<el-icon>
|
||||
<DataBoard />
|
||||
</el-icon>
|
||||
<span>Dashboard</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="3">
|
||||
<el-icon><Document /></el-icon>
|
||||
<el-icon>
|
||||
<Document />
|
||||
</el-icon>
|
||||
<span>Bids</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="4">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<el-icon>
|
||||
<Setting />
|
||||
</el-icon>
|
||||
<span>Keywords</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="5">
|
||||
<el-icon><Connection /></el-icon>
|
||||
<el-icon>
|
||||
<Connection />
|
||||
</el-icon>
|
||||
<span>Crawl Info</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
@@ -39,41 +44,19 @@
|
||||
</el-header>
|
||||
|
||||
<el-main>
|
||||
<Dashboard
|
||||
v-if="activeIndex === '1'"
|
||||
:today-bids="todayBids"
|
||||
:high-priority-bids="highPriorityBids"
|
||||
:keywords="keywords"
|
||||
:loading="loading"
|
||||
:is-crawling="isCrawling"
|
||||
@refresh="fetchData"
|
||||
@update-bids="updateBidsByDateRange"
|
||||
/>
|
||||
<DashboardAI v-if="activeIndex === '1'" :bids="bids" />
|
||||
<Dashboard v-if="activeIndex === '2'" :today-bids="todayBids"
|
||||
:keywords="keywords" :loading="loading" :is-crawling="isCrawling" @refresh="fetchData"
|
||||
@update-bids="updateBidsByDateRange" />
|
||||
|
||||
<DashboardAI
|
||||
v-if="activeIndex === '2'"
|
||||
:bids="bids"
|
||||
/>
|
||||
|
||||
<Bids
|
||||
v-if="activeIndex === '3'"
|
||||
:bids="bids"
|
||||
:source-options="sourceOptions"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
@fetch="handleFetchBids"
|
||||
/>
|
||||
|
||||
<Keywords
|
||||
v-if="activeIndex === '4'"
|
||||
:keywords="keywords"
|
||||
:loading="loading"
|
||||
@refresh="fetchData"
|
||||
/>
|
||||
<Bids v-if="activeIndex === '3'" :bids="bids" :source-options="sourceOptions" :loading="loading" :total="total"
|
||||
@fetch="handleFetchBids" />
|
||||
|
||||
<CrawlInfo
|
||||
v-if="activeIndex === '5'"
|
||||
/>
|
||||
<Keywords v-if="activeIndex === '4'" :keywords="keywords" :loading="loading" @refresh="fetchData" />
|
||||
|
||||
<CrawlInfo v-if="activeIndex === '5'" />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
@@ -93,7 +76,6 @@ import CrawlInfo from './components/CrawlInfo.vue'
|
||||
const activeIndex = ref('1')
|
||||
const bids = ref<any[]>([])
|
||||
const todayBids = ref<any[]>([])
|
||||
const highPriorityBids = ref<any[]>([])
|
||||
const keywords = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const isCrawling = ref(false)
|
||||
@@ -126,7 +108,7 @@ const handleFetchBids = async (page: number, limit: number, source?: string) =>
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [bidsRes, recentRes, highRes, kwRes, sourcesRes, statusRes] = await Promise.all([
|
||||
const [bidsRes, recentRes, kwRes, sourcesRes, statusRes] = await Promise.all([
|
||||
axios.get('/api/bids', {
|
||||
params: {
|
||||
page: 1,
|
||||
@@ -134,7 +116,6 @@ const fetchData = async () => {
|
||||
}
|
||||
}),
|
||||
axios.get('/api/bids/recent'),
|
||||
axios.get('/api/bids/high-priority'),
|
||||
axios.get('/api/keywords'),
|
||||
axios.get('/api/bids/sources'),
|
||||
axios.get('/api/crawler/status')
|
||||
@@ -142,7 +123,6 @@ const fetchData = async () => {
|
||||
bids.value = bidsRes.data.items
|
||||
total.value = bidsRes.data.total
|
||||
todayBids.value = recentRes.data
|
||||
highPriorityBids.value = highRes.data
|
||||
keywords.value = kwRes.data
|
||||
sourceOptions.value = sourcesRes.data
|
||||
isCrawling.value = statusRes.data.isCrawling
|
||||
@@ -188,9 +168,11 @@ onMounted(() => {
|
||||
line-height: 60px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.layout-container .el-aside {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 60px;
|
||||
line-height: 60px;
|
||||
|
||||
@@ -12,6 +12,20 @@
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table :data="bids" v-loading="loading" style="width: 100%">
|
||||
<el-table-column label="Pin" width="60" align="center">
|
||||
<template #default="scope">
|
||||
<el-icon
|
||||
:style="{
|
||||
color: scope.row.pin ? '#f56c6c' : '#909399',
|
||||
cursor: 'pointer',
|
||||
fontSize: '18px'
|
||||
}"
|
||||
@click="togglePin(scope.row)"
|
||||
>
|
||||
<Paperclip />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="Title">
|
||||
<template #default="scope">
|
||||
<a :href="scope.row.url" target="_blank">{{ scope.row.title }}</a>
|
||||
@@ -37,6 +51,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Paperclip } from '@element-plus/icons-vue'
|
||||
|
||||
interface Props {
|
||||
bids: any[]
|
||||
@@ -75,6 +92,18 @@ const handleSizeChange = (size: number) => {
|
||||
currentPage.value = 1
|
||||
emit('fetch', currentPage.value, pageSize.value, selectedSource.value || undefined)
|
||||
}
|
||||
|
||||
// 切换 Pin 状态
|
||||
const togglePin = async (item: any) => {
|
||||
try {
|
||||
const newPinStatus = !item.pin
|
||||
await axios.patch(`/api/bids/${encodeURIComponent(item.title)}/pin`, { pin: newPinStatus })
|
||||
item.pin = newPinStatus
|
||||
ElMessage.success(newPinStatus ? '已置顶' : '已取消置顶')
|
||||
} catch (error) {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -7,37 +7,16 @@
|
||||
获取 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>
|
||||
<PinnedProject ref="pinnedProjectRef" @pin-changed="handlePinChanged" />
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="24">
|
||||
<el-card class="box-card" shadow="hover">
|
||||
<template #header>
|
||||
<!-- <template #header> -->
|
||||
<div class="card-header">
|
||||
<span>AI 推荐项目</span>
|
||||
<el-tag type="success">{{ aiRecommendations.length }} 个推荐</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<!-- </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>
|
||||
@@ -49,6 +28,20 @@
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-table :data="aiRecommendations" style="width: 100%" size="small">
|
||||
<el-table-column label="Pin" width="60" align="center">
|
||||
<template #default="scope">
|
||||
<el-icon
|
||||
:style="{
|
||||
color: scope.row.pin ? '#f56c6c' : '#909399',
|
||||
cursor: 'pointer',
|
||||
fontSize: '18px'
|
||||
}"
|
||||
@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>
|
||||
@@ -72,6 +65,28 @@
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<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 v-if="showAllBids" :gutter="20" style="margin-top: 20px;">
|
||||
<el-col :span="24">
|
||||
<el-card class="box-card" shadow="hover">
|
||||
@@ -114,7 +129,8 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { MagicStick, Loading, InfoFilled, List } from '@element-plus/icons-vue'
|
||||
import { MagicStick, Loading, InfoFilled, List, ArrowDown, Paperclip } from '@element-plus/icons-vue'
|
||||
import PinnedProject from './PinnedProject.vue'
|
||||
|
||||
|
||||
interface AIRecommendation {
|
||||
@@ -123,6 +139,7 @@ interface AIRecommendation {
|
||||
source: string
|
||||
confidence: number
|
||||
publishDate?: string
|
||||
pin?: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -159,7 +176,17 @@ watch(dateRange, (newDateRange) => {
|
||||
const loadLatestRecommendations = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/ai/latest-recommendations')
|
||||
aiRecommendations.value = response.data
|
||||
const recommendations = response.data
|
||||
|
||||
// 获取所有置顶的项目
|
||||
const pinnedResponse = await axios.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)
|
||||
}
|
||||
@@ -224,10 +251,19 @@ const fetchAIRecommendations = async () => {
|
||||
title: rec.title,
|
||||
url: bid?.url || '',
|
||||
source: bid?.source || '',
|
||||
confidence: rec.confidence
|
||||
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
|
||||
|
||||
// 保存推荐结果到数据库
|
||||
@@ -291,6 +327,34 @@ const getConfidenceType = (confidence: number) => {
|
||||
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 axios.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>
|
||||
@@ -298,6 +362,12 @@ const getConfidenceType = (confidence: number) => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
}
|
||||
|
||||
.box-card{
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
a {
|
||||
|
||||
@@ -7,38 +7,7 @@
|
||||
立刻抓取
|
||||
</el-button>
|
||||
</div>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="24">
|
||||
<el-card class="box-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header" @click="toggleHighPriority" style="cursor: pointer;">
|
||||
<span>High Priority Bids</span>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<el-tag type="danger">Top 10</el-tag>
|
||||
<el-icon :style="{ transform: highPriorityCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)', transition: 'transform 0.3s' }">
|
||||
<ArrowDown />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-collapse-transition>
|
||||
<div v-show="!highPriorityCollapsed">
|
||||
<el-table :data="highPriorityBids" style="width: 100%" size="small">
|
||||
<el-table-column prop="title" label="Title">
|
||||
<template #default="scope">
|
||||
<a :href="scope.row.url" target="_blank">{{ scope.row.title }}</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="source" label="Source" width="240" />
|
||||
<el-table-column prop="publishDate" label="Date" width="120">
|
||||
<template #default="scope">{{ formatDate(scope.row.publishDate) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-collapse-transition>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<PinnedProject ref="pinnedProjectRef" @pin-changed="handlePinChanged" />
|
||||
<el-divider />
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<h3 style="margin: 0;">Today's Bids</h3>
|
||||
@@ -79,6 +48,20 @@
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="filteredTodayBids" v-loading="loading" style="width: 100%">
|
||||
<el-table-column label="Pin" width="60" align="center">
|
||||
<template #default="scope">
|
||||
<el-icon
|
||||
:style="{
|
||||
color: scope.row.pin ? '#f56c6c' : '#909399',
|
||||
cursor: 'pointer',
|
||||
fontSize: '18px'
|
||||
}"
|
||||
@click="togglePin(scope.row)"
|
||||
>
|
||||
<Paperclip />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="Title">
|
||||
<template #default="scope">
|
||||
<a :href="scope.row.url" target="_blank">{{ scope.row.title }}</a>
|
||||
@@ -96,11 +79,11 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh, ArrowDown } from '@element-plus/icons-vue'
|
||||
import { Refresh, ArrowDown, Paperclip } from '@element-plus/icons-vue'
|
||||
import PinnedProject from './PinnedProject.vue'
|
||||
|
||||
interface Props {
|
||||
todayBids: any[]
|
||||
highPriorityBids: any[]
|
||||
keywords: any[]
|
||||
loading: boolean
|
||||
isCrawling: boolean
|
||||
@@ -118,7 +101,6 @@ const selectedKeywords = ref<string[]>([])
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
const crawling = ref(false)
|
||||
const updating = ref(false)
|
||||
const highPriorityCollapsed = ref(false)
|
||||
const isInitialized = ref(false)
|
||||
const isManualClick = ref(false)
|
||||
|
||||
@@ -139,18 +121,6 @@ watch(dateRange, (newDateRange) => {
|
||||
localStorage.setItem('dashboard_dateRange', JSON.stringify(newDateRange))
|
||||
}, { deep: true })
|
||||
|
||||
// 切换 High Priority Bids 的折叠状态
|
||||
const toggleHighPriority = () => {
|
||||
highPriorityCollapsed.value = !highPriorityCollapsed.value
|
||||
}
|
||||
|
||||
// 监听 highPriorityBids,当没有数据时自动折叠
|
||||
watch(() => props.highPriorityBids, (newBids) => {
|
||||
if (newBids.length === 0) {
|
||||
highPriorityCollapsed.value = true
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 从 localStorage 加载保存的关键字
|
||||
const loadSavedKeywords = () => {
|
||||
const saved = localStorage.getItem('selectedKeywords')
|
||||
@@ -315,6 +285,34 @@ const handleCrawl = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// PinnedProject 组件引用
|
||||
const pinnedProjectRef = ref<any>(null)
|
||||
|
||||
// 处理 PinnedProject 组件的 pin 状态改变事件
|
||||
const handlePinChanged = async (title: string) => {
|
||||
// 更新 todayBids 中对应项目的 pin 状态
|
||||
const bid = props.todayBids.find(b => b.title === title)
|
||||
if (bid) {
|
||||
bid.pin = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换 Today's Bids 的 Pin 状态
|
||||
const togglePin = async (item: any) => {
|
||||
try {
|
||||
const newPinStatus = !item.pin
|
||||
await axios.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('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化时加载保存的关键字和日期范围
|
||||
loadSavedKeywords()
|
||||
loadSavedDateRange()
|
||||
|
||||
132
frontend/src/components/PinnedProject.vue
Normal file
132
frontend/src/components/PinnedProject.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<el-card class="box-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Pinned</span>
|
||||
<el-tag type="danger">{{ pinnedBids.length }} 个置顶</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="pinnedLoading" 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="pinnedBids.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="pinnedBids" style="width: 100%" size="small">
|
||||
<el-table-column label="Pin" width="60" align="center">
|
||||
<template #default="scope">
|
||||
<el-icon
|
||||
:style="{
|
||||
color: '#f56c6c',
|
||||
cursor: 'pointer',
|
||||
fontSize: '18px'
|
||||
}"
|
||||
@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="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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Loading, InfoFilled, Paperclip } from '@element-plus/icons-vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
pinChanged: [title: string]
|
||||
}>()
|
||||
|
||||
const pinnedBids = ref<any[]>([])
|
||||
const pinnedLoading = ref(false)
|
||||
|
||||
// 加载置顶项目
|
||||
const loadPinnedBids = async () => {
|
||||
pinnedLoading.value = true
|
||||
try {
|
||||
const response = await axios.get('/api/bids/pinned')
|
||||
pinnedBids.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to load pinned bids:', error)
|
||||
} finally {
|
||||
pinnedLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换置顶列表的 Pin 状态
|
||||
const togglePin = async (item: any) => {
|
||||
try {
|
||||
await axios.patch(`/api/bids/${encodeURIComponent(item.title)}/pin`, { pin: false })
|
||||
const index = pinnedBids.value.findIndex(b => b.title === item.title)
|
||||
if (index !== -1) {
|
||||
pinnedBids.value.splice(index, 1)
|
||||
}
|
||||
ElMessage.success('已取消置顶')
|
||||
// 通知父组件 pin 状态已改变,传递 title
|
||||
emit('pinChanged', item.title)
|
||||
} catch (error) {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期,只显示年月日
|
||||
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}`
|
||||
}
|
||||
|
||||
// 初始化时加载置顶项目
|
||||
onMounted(() => {
|
||||
loadPinnedBids()
|
||||
})
|
||||
|
||||
// 暴露方法给父组件调用
|
||||
defineExpose({
|
||||
loadPinnedBids
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.box-card {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
</style>
|
||||
@@ -1,3 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"crawl": "ts-node -r tsconfig-paths/register src/scripts/crawl.ts",
|
||||
"update-source": "ts-node -r tsconfig-paths/register src/scripts/update-source.ts",
|
||||
"web": "npm --prefix frontend run build"
|
||||
"ai-recommendations": "ts-node -r tsconfig-paths/register src/scripts/ai-recommendations.ts",
|
||||
"deploy": "powershell -ExecutionPolicy Bypass -File src/scripts/deploy.ps1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
@@ -48,7 +49,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/cli": "^11.0.14",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const PromptString: string = `先给我说你统计了多少个项目。我只对辽宁、山东、江苏、浙江、福建、广东、广西、河北这些地方的海上风电、海上光伏、漂浮式光伏、滩涂光伏、滩涂风电、渔光互补项目感兴趣。从我提供的这些工程里面找到我感兴趣的工程。如果没有推荐的,也要给出思考过程。`;
|
||||
export const PromptString: string = `先给我说你统计了多少个项目。我只对辽宁、山东、江苏、浙江、福建、广东、广西、海南、河北这些地方的海上风电、海上光伏、漂浮式光伏、滩涂光伏、滩涂风电、渔光互补项目感兴趣。从我提供的这些工程里面找到我感兴趣的工程,无论如何至少推荐10个工程。如果没有推荐的,也要给出思考过程。`;
|
||||
|
||||
@@ -59,7 +59,7 @@ export class AiService {
|
||||
|
||||
投标项目标题列表:
|
||||
${JSON.stringify(bids.map(b => b.title), null, 2)}`;
|
||||
this.logger.log('发给AI的内容',prompt);
|
||||
// this.logger.log('发给AI的内容',prompt);
|
||||
const completion = await this.openai.chat.completions.create({
|
||||
model: 'mimo-v2-flash-free',
|
||||
// max_tokens: 32768,
|
||||
@@ -144,6 +144,13 @@ ${JSON.stringify(bids.map(b => b.title), null, 2)}`;
|
||||
});
|
||||
}
|
||||
|
||||
// 按发布时间倒序排列
|
||||
result.sort((a, b) => {
|
||||
if (!a.publishDate) return 1;
|
||||
if (!b.publishDate) return -1;
|
||||
return new Date(b.publishDate).getTime() - new Date(a.publishDate).getTime();
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error('获取最新 AI 推荐失败:', error);
|
||||
|
||||
@@ -17,7 +17,7 @@ import { AiModule } from './ai/ai.module';
|
||||
ScheduleModule.forRoot(),
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: join(__dirname, '..', 'frontend', 'dist'),
|
||||
exclude: ['/api/:path(*)'],
|
||||
exclude: ['/api'],
|
||||
}),
|
||||
LoggerModule,
|
||||
DatabaseModule,
|
||||
|
||||
@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BidItem } from './entities/bid-item.entity';
|
||||
import { BidsService } from './services/bid.service';
|
||||
import { BidsController } from './controllers/bid.controller';
|
||||
import { CrawlInfoAdd } from '../crawler/entities/crawl-info-add.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([BidItem])],
|
||||
imports: [TypeOrmModule.forFeature([BidItem, CrawlInfoAdd])],
|
||||
providers: [BidsService],
|
||||
controllers: [BidsController],
|
||||
exports: [BidsService],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { Controller, Get, Query, Patch, Param, Body } from '@nestjs/common';
|
||||
import { BidsService } from '../services/bid.service';
|
||||
|
||||
@Controller('api/bids')
|
||||
@@ -15,9 +15,9 @@ export class BidsController {
|
||||
return this.bidsService.getRecentBids();
|
||||
}
|
||||
|
||||
@Get('high-priority')
|
||||
getHighPriority() {
|
||||
return this.bidsService.getHighPriorityCorrected();
|
||||
@Get('pinned')
|
||||
getPinned() {
|
||||
return this.bidsService.getPinnedBids();
|
||||
}
|
||||
|
||||
@Get('sources')
|
||||
@@ -35,4 +35,9 @@ export class BidsController {
|
||||
getCrawlInfoStats() {
|
||||
return this.bidsService.getCrawlInfoAddStats();
|
||||
}
|
||||
|
||||
@Patch(':title/pin')
|
||||
updatePin(@Param('title') title: string, @Body() body: { pin: boolean }) {
|
||||
return this.bidsService.updatePin(title, body.pin);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,13 +18,7 @@ export class BidItem {
|
||||
source: string;
|
||||
|
||||
@Column({ default: false })
|
||||
isRead: boolean;
|
||||
|
||||
@Column({ default: 0 })
|
||||
priority: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
unit: string;
|
||||
pin: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@@ -2,12 +2,15 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, LessThan, MoreThanOrEqual } from 'typeorm';
|
||||
import { BidItem } from '../entities/bid-item.entity';
|
||||
import { CrawlInfoAdd } from '../../crawler/entities/crawl-info-add.entity';
|
||||
|
||||
@Injectable()
|
||||
export class BidsService {
|
||||
constructor(
|
||||
@InjectRepository(BidItem)
|
||||
private bidRepository: Repository<BidItem>,
|
||||
@InjectRepository(CrawlInfoAdd)
|
||||
private crawlInfoRepository: Repository<CrawlInfoAdd>,
|
||||
) {}
|
||||
|
||||
async findAll(query?: any) {
|
||||
@@ -30,27 +33,9 @@ export class BidsService {
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
getHighPriority() {
|
||||
return this.bidRepository.find({
|
||||
where: { priority: LessThan(0) }, // This is just a placeholder logic, priority should be > 0
|
||||
order: { priority: 'DESC', publishDate: 'DESC' },
|
||||
take: 10,
|
||||
});
|
||||
}
|
||||
|
||||
// Update logic for priority
|
||||
async getHighPriorityCorrected() {
|
||||
return this.bidRepository.createQueryBuilder('bid')
|
||||
.where('bid.priority > 0')
|
||||
.orderBy('bid.priority', 'DESC')
|
||||
.addOrderBy('bid.publishDate', 'DESC')
|
||||
.limit(10)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async createOrUpdate(data: Partial<BidItem>) {
|
||||
// Use URL or a hash of URL to check for duplicates
|
||||
let item = await this.bidRepository.findOne({ where: { url: data.url } });
|
||||
// Use title or a hash of title to check for duplicates
|
||||
let item = await this.bidRepository.findOne({ where: { title: data.title } });
|
||||
if (item) {
|
||||
Object.assign(item, data);
|
||||
return this.bidRepository.save(item);
|
||||
@@ -88,6 +73,14 @@ export class BidsService {
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async getPinnedBids() {
|
||||
return this.bidRepository
|
||||
.createQueryBuilder('bid')
|
||||
.where('bid.pin = :pin', { pin: true })
|
||||
.orderBy('bid.publishDate', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async getBidsByDateRange(startDate?: string, endDate?: string, keywords?: string[]) {
|
||||
const qb = this.bidRepository.createQueryBuilder('bid');
|
||||
|
||||
@@ -116,12 +109,17 @@ export class BidsService {
|
||||
return qb.orderBy('bid.publishDate', 'DESC').getMany();
|
||||
}
|
||||
|
||||
async getCrawlInfoAddStats() {
|
||||
const { InjectRepository } = require('@nestjs/typeorm');
|
||||
const { Repository } = require('typeorm');
|
||||
const { CrawlInfoAdd } = require('../../crawler/entities/crawl-info-add.entity');
|
||||
async updatePin(title: string, pin: boolean) {
|
||||
const item = await this.bidRepository.findOne({ where: { title } });
|
||||
if (!item) {
|
||||
throw new Error('Bid not found');
|
||||
}
|
||||
item.pin = pin;
|
||||
return this.bidRepository.save(item);
|
||||
}
|
||||
|
||||
// 获取每个来源的最新一次爬虫记录
|
||||
async getCrawlInfoAddStats() {
|
||||
// 获取每个来源的最新一次爬虫记录(按 createdAt 降序)
|
||||
const query = `
|
||||
SELECT
|
||||
source,
|
||||
@@ -130,15 +128,15 @@ export class BidsService {
|
||||
error,
|
||||
createdAt as latestUpdate
|
||||
FROM crawl_info_add
|
||||
WHERE id IN (
|
||||
SELECT MAX(id)
|
||||
WHERE (source, createdAt) IN (
|
||||
SELECT source, MAX(createdAt)
|
||||
FROM crawl_info_add
|
||||
GROUP BY source
|
||||
)
|
||||
ORDER BY source ASC
|
||||
`;
|
||||
|
||||
const results = await this.bidRepository.query(query);
|
||||
const results = await this.crawlInfoRepository.query(query);
|
||||
|
||||
return results.map((item: any) => ({
|
||||
source: item.source,
|
||||
|
||||
@@ -112,7 +112,6 @@ export class BidCrawlerService {
|
||||
url: item.url,
|
||||
publishDate: item.publishDate,
|
||||
source: crawler.name,
|
||||
unit: '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -164,7 +163,6 @@ export class BidCrawlerService {
|
||||
url: item.url,
|
||||
publishDate: item.publishDate,
|
||||
source: crawler.name,
|
||||
unit: '',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ export interface CdtResult {
|
||||
export const CdtCrawler = {
|
||||
name: '中国大唐集团电子商务平台',
|
||||
url: 'https://tang.cdt-ec.com/home/index.html',
|
||||
baseUrl: 'https://tang.cdt-ec.com/',
|
||||
baseUrl: 'https://tang.cdt-ec.com',
|
||||
|
||||
async crawl(browser: puppeteer.Browser): Promise<CdtResult[]> {
|
||||
const logger = new Logger('CdtCrawler');
|
||||
@@ -252,10 +252,11 @@ export const CdtCrawler = {
|
||||
const dateStr = match[3]?.trim();
|
||||
|
||||
if (title && url) {
|
||||
const fullUrl = url.startsWith('http') ? url : this.baseUrl + url;
|
||||
results.push({
|
||||
title,
|
||||
publishDate: dateStr ? new Date(dateStr) : new Date(),
|
||||
url: url.startsWith('http') ? url : this.baseUrl + url
|
||||
url: fullUrl.replace(/\/\//g, '/')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ export const CeicCrawler = {
|
||||
allResults.push(...pageResults.map(r => ({
|
||||
title: r.title,
|
||||
publishDate: r.dateStr ? new Date(r.dateStr) : new Date(),
|
||||
url: r.url
|
||||
url: r.url.replace(/\/\//g, '/')
|
||||
})));
|
||||
|
||||
logger.log(`Extracted ${pageResults.length} items.`);
|
||||
|
||||
@@ -190,10 +190,11 @@ export const CgnpcCrawler = {
|
||||
const dateStr = match[3]?.trim();
|
||||
|
||||
if (title && url) {
|
||||
const fullUrl = url.startsWith('http') ? url : this.baseUrl + url;
|
||||
results.push({
|
||||
title,
|
||||
publishDate: dateStr ? new Date(dateStr) : new Date(),
|
||||
url: url.startsWith('http') ? url : this.baseUrl + url
|
||||
url: fullUrl.replace(/\/\//g, '/')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,10 +105,11 @@ export const ChdtpCrawler = {
|
||||
const dateStr = match[5]?.trim();
|
||||
|
||||
if (title && urlSuffix) {
|
||||
const fullUrl = this.baseUrl + urlSuffix;
|
||||
results.push({
|
||||
title,
|
||||
publishDate: dateStr ? new Date(dateStr) : new Date(),
|
||||
url: this.baseUrl + urlSuffix
|
||||
url: fullUrl.replace(/\/\//g, '/')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export const ChngCrawler = {
|
||||
await page.waitForNavigation({ waitUntil: 'networkidle2' });
|
||||
|
||||
logger.log('Clicking search result...');
|
||||
await page.screenshot({ path: 'bing.png' });
|
||||
// await page.screenshot({ path: 'bing.png' });
|
||||
const firstResultSelector = '#b_results .b_algo h2 a';
|
||||
await page.waitForSelector(firstResultSelector);
|
||||
|
||||
@@ -99,7 +99,7 @@ export const ChngCrawler = {
|
||||
const newPage = await newTarget.page();
|
||||
|
||||
if (newPage) {
|
||||
await newPage.screenshot({ path: 'newPage.png' });
|
||||
// await newPage.screenshot({ path: 'newPage.png' });
|
||||
await page.close();
|
||||
page = newPage;
|
||||
if (username && password) {
|
||||
@@ -125,7 +125,7 @@ export const ChngCrawler = {
|
||||
// PAUSE 15 SECONDS as requested
|
||||
logger.log('Pausing 15 seconds before looking for "采购专栏"...');
|
||||
await new Promise(r => setTimeout(r, 15000));
|
||||
await page.screenshot({ path: 'huaneng.png' });
|
||||
// await page.screenshot({ path: 'huaneng.png' });
|
||||
|
||||
logger.log('Looking for "采购专栏" link...');
|
||||
await page.waitForFunction(() => {
|
||||
@@ -206,7 +206,7 @@ export const ChngCrawler = {
|
||||
allResults.push(...pageResults.map(r => ({
|
||||
title: r!.title,
|
||||
publishDate: new Date(r!.dateStr),
|
||||
url: r!.url
|
||||
url: r!.url.replace(/\/\//g, '/')
|
||||
})));
|
||||
|
||||
logger.log(`Extracted ${pageResults.length} items.`);
|
||||
|
||||
@@ -181,10 +181,11 @@ export const CnncecpCrawler = {
|
||||
const title = match[3]?.trim();
|
||||
|
||||
if (title && url) {
|
||||
const fullUrl = url.startsWith('http') ? url : this.baseUrl + url;
|
||||
results.push({
|
||||
title,
|
||||
publishDate: dateStr ? new Date(dateStr) : new Date(),
|
||||
url: url.startsWith('http') ? url : this.baseUrl + url
|
||||
url: fullUrl.replace(/\/\//g, '/')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,10 +182,11 @@ export const CnoocCrawler = {
|
||||
const dateStr = match[3]?.trim();
|
||||
|
||||
if (title && url) {
|
||||
const fullUrl = url.startsWith('http') ? url : this.baseUrl + url;
|
||||
results.push({
|
||||
title,
|
||||
publishDate: dateStr ? new Date(dateStr) : new Date(),
|
||||
url: url.startsWith('http') ? url : this.baseUrl + url
|
||||
url: fullUrl.replace(/\/\//g, '/')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,10 +188,11 @@ export const EpsCrawler = {
|
||||
const dateStr = match[3]?.trim();
|
||||
|
||||
if (title && url) {
|
||||
const fullUrl = url.startsWith('http') ? url : this.baseUrl + url;
|
||||
results.push({
|
||||
title,
|
||||
publishDate: dateStr ? new Date(dateStr) : new Date(),
|
||||
url: url.startsWith('http') ? url : this.baseUrl + url
|
||||
url: fullUrl.replace(/\/\//g, '/')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,10 +234,11 @@ export const EspicCrawler = {
|
||||
const dateStr = match[3]?.trim();
|
||||
|
||||
if (title && url) {
|
||||
const fullUrl = url.startsWith('http') ? url : this.baseUrl + url;
|
||||
results.push({
|
||||
title,
|
||||
publishDate: dateStr ? new Date(dateStr) : new Date(),
|
||||
url: url.startsWith('http') ? url : this.baseUrl + url
|
||||
url: fullUrl.replace(/\/\//g, '/')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,10 +185,11 @@ export const PowerbeijingCrawler = {
|
||||
const dateStr = match[3]?.trim();
|
||||
|
||||
if (title && url) {
|
||||
const fullUrl = url.startsWith('http') ? url : this.baseUrl + url;
|
||||
results.push({
|
||||
title,
|
||||
publishDate: dateStr ? new Date(dateStr) : new Date(),
|
||||
url: url.startsWith('http') ? url : this.baseUrl + url
|
||||
url: fullUrl.replace(/\/\//g, '/')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,10 +190,11 @@ export const SdiccCrawler = {
|
||||
const dateStr = match[4]?.trim();
|
||||
|
||||
if (title && ggGuid && gcGuid) {
|
||||
const fullUrl = `${this.baseUrl}/cgxx/ggDetail?gcGuid=${gcGuid}&ggGuid=${ggGuid}`;
|
||||
results.push({
|
||||
title,
|
||||
publishDate: dateStr ? new Date(dateStr) : new Date(),
|
||||
url: `${this.baseUrl}/cgxx/ggDetail?gcGuid=${gcGuid}&ggGuid=${ggGuid}`
|
||||
url: fullUrl.replace(/\/\//g, '/')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ export const SzecpCrawler = {
|
||||
allResults.push(...pageResults.map(r => ({
|
||||
title: r!.title,
|
||||
publishDate: new Date(r!.dateStr),
|
||||
url: r!.url
|
||||
url: r!.url.replace(/\/\//g, '/')
|
||||
})));
|
||||
|
||||
logger.log(`Extracted ${pageResults.length} items.`);
|
||||
|
||||
12
src/main.ts
12
src/main.ts
@@ -3,12 +3,22 @@ import { AppModule } from './app.module';
|
||||
import { CustomLogger } from './common/logger/logger.service';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
bodyParser: true,
|
||||
});
|
||||
|
||||
// 使用自定义日志服务
|
||||
const logger = await app.resolve(CustomLogger);
|
||||
app.useLogger(logger);
|
||||
|
||||
// 增加请求体大小限制(默认 100kb,增加到 50mb)
|
||||
const express = require('express');
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
||||
|
||||
// 启用 CORS
|
||||
app.enableCors();
|
||||
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
78
src/scripts/ai-recommendations.ts
Normal file
78
src/scripts/ai-recommendations.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from '../app.module';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BidItem } from '../bids/entities/bid-item.entity';
|
||||
import { AiService } from '../ai/ai.service';
|
||||
import { CustomLogger } from '../common/logger/logger.service';
|
||||
|
||||
async function generateAiRecommendations() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule);
|
||||
|
||||
// 设置自定义 logger
|
||||
const logger = await app.resolve(CustomLogger);
|
||||
app.useLogger(logger);
|
||||
logger.setContext('AiRecommendationsScript');
|
||||
|
||||
try {
|
||||
// 获取 BidItem 的 repository 和 AiService
|
||||
const bidItemRepository = app.get<Repository<BidItem>>(getRepositoryToken(BidItem));
|
||||
const aiService = app.get(AiService);
|
||||
|
||||
logger.log('开始查询 bid_items 表...');
|
||||
|
||||
// 计算起始日期:3天前
|
||||
const threeDaysAgo = new Date();
|
||||
threeDaysAgo.setDate(threeDaysAgo.getDate() - 2);
|
||||
threeDaysAgo.setHours(0, 0, 0, 0);
|
||||
|
||||
// 使用本地时间格式化输出,避免时区问题
|
||||
const localDateStr = threeDaysAgo.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}).replace(/\//g, '-');
|
||||
logger.log(`查询起始日期: ${localDateStr}`);
|
||||
|
||||
// 查询起始日期3天前,截止日期不限制的所有记录
|
||||
const bidItems = await bidItemRepository
|
||||
.createQueryBuilder('bid')
|
||||
.where('bid.publishDate >= :startDate', { startDate: threeDaysAgo })
|
||||
.orderBy('bid.publishDate', 'DESC')
|
||||
.getMany();
|
||||
|
||||
logger.log(`查询到 ${bidItems.length} 条记录`);
|
||||
|
||||
if (bidItems.length === 0) {
|
||||
logger.log('没有符合条件的记录');
|
||||
await app.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 提取 title
|
||||
const bidData = bidItems.map(item => ({
|
||||
title: item.title
|
||||
}));
|
||||
|
||||
logger.log('开始调用 AI 获取推荐...');
|
||||
|
||||
// 调用 getRecommendations 函数
|
||||
const recommendations = await aiService.getRecommendations(bidData);
|
||||
|
||||
logger.log(`AI 返回了 ${recommendations.length} 条推荐结果`);
|
||||
|
||||
// 调用 saveRecommendations 函数保存结果
|
||||
await aiService.saveRecommendations(recommendations);
|
||||
|
||||
logger.log('AI 推荐结果保存成功');
|
||||
|
||||
await app.close();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error('生成 AI 推荐失败:', error);
|
||||
await app.close();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
generateAiRecommendations();
|
||||
73
src/scripts/deploy.ps1
Normal file
73
src/scripts/deploy.ps1
Normal file
@@ -0,0 +1,73 @@
|
||||
# Deploy script - Upload files to remote server using scp
|
||||
|
||||
# Configuration
|
||||
$remoteHost = "127.0.0.1"
|
||||
$remotePort = "1122"
|
||||
$remoteUser = "cubie"
|
||||
$keyPath = "d:\163"
|
||||
$serverDest = "/home/cubie/down/document/bidding/publish/server"
|
||||
$frontendDest = "/home/cubie/down/document/bidding/publish/frontend"
|
||||
$srcDest = "/home/cubie/down/document/bidding/"
|
||||
|
||||
# Check if key file exists
|
||||
if (-not (Test-Path $keyPath)) {
|
||||
Write-Error "Private key file not found: $keyPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check if dist directory exists
|
||||
if (-not (Test-Path "dist")) {
|
||||
Write-Error "dist directory not found, please run npm run build first"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check if frontend directory exists
|
||||
if (-not (Test-Path "frontend")) {
|
||||
Write-Error "frontend directory not found"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check if src directory exists
|
||||
if (-not (Test-Path "src")) {
|
||||
Write-Error "src directory not found"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Starting deployment..." -ForegroundColor Green
|
||||
Write-Host "Remote server: ${remoteHost}:${remotePort}" -ForegroundColor Cyan
|
||||
Write-Host "Private key: $keyPath" -ForegroundColor Cyan
|
||||
|
||||
# Upload dist directory contents to server directory
|
||||
Write-Host "`nUploading dist directory to ${serverDest}..." -ForegroundColor Yellow
|
||||
scp -i $keyPath -P $remotePort -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -r dist/* "${remoteUser}@${remoteHost}:${serverDest}"
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Failed to upload dist directory"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "dist directory uploaded successfully" -ForegroundColor Green
|
||||
|
||||
# Upload entire frontend directory to publish directory
|
||||
Write-Host "`nUploading frontend directory to ${frontendDest}..." -ForegroundColor Yellow
|
||||
scp -i $keyPath -P $remotePort -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -r frontend/dist "${remoteUser}@${remoteHost}:${frontendDest}"
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Failed to upload frontend directory"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "frontend directory uploaded successfully" -ForegroundColor Green
|
||||
|
||||
# Upload entire src directory to bidding directory
|
||||
Write-Host "`nUploading src directory to ${srcDest}..." -ForegroundColor Yellow
|
||||
scp -i $keyPath -P $remotePort -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -r src "${remoteUser}@${remoteHost}:${srcDest}"
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Failed to upload src directory"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "src directory uploaded successfully" -ForegroundColor Green
|
||||
|
||||
Write-Host "`nDeployment completed!" -ForegroundColor Green
|
||||
77
src/scripts/remove-duplicates.ts
Normal file
77
src/scripts/remove-duplicates.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from '../app.module';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BidItem } from '../bids/entities/bid-item.entity';
|
||||
import { CustomLogger } from '../common/logger/logger.service';
|
||||
|
||||
async function removeDuplicates() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule);
|
||||
|
||||
// 设置自定义 logger
|
||||
const logger = await app.resolve(CustomLogger);
|
||||
app.useLogger(logger);
|
||||
logger.setContext('RemoveDuplicatesScript');
|
||||
|
||||
try {
|
||||
// 获取 BidItem 的 repository
|
||||
const bidItemRepository = app.get<Repository<BidItem>>(getRepositoryToken(BidItem));
|
||||
|
||||
logger.log('开始查找重复的title...');
|
||||
|
||||
// 查找所有重复的title
|
||||
const duplicates = await bidItemRepository
|
||||
.createQueryBuilder('bid')
|
||||
.select('bid.title', 'title')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy('bid.title')
|
||||
.having('COUNT(*) > 1')
|
||||
.getRawMany();
|
||||
|
||||
if (duplicates.length === 0) {
|
||||
logger.log('没有发现重复的title');
|
||||
await app.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
logger.log(`发现 ${duplicates.length} 个重复的title`);
|
||||
|
||||
let totalDeleted = 0;
|
||||
|
||||
// 对每个重复的title,只保留最晚创建的一条记录
|
||||
for (const duplicate of duplicates) {
|
||||
const title = duplicate.title;
|
||||
const count = duplicate.count;
|
||||
|
||||
logger.log(`处理重复title: "${title}" (共 ${count} 条)`);
|
||||
|
||||
// 获取该title的所有记录,按创建时间降序排列
|
||||
const items = await bidItemRepository
|
||||
.createQueryBuilder('bid')
|
||||
.where('bid.title = :title', { title })
|
||||
.orderBy('bid.createdAt', 'DESC')
|
||||
.getMany();
|
||||
|
||||
// 保留第一条(最晚创建的),删除其余的
|
||||
const itemsToDelete = items.slice(1);
|
||||
|
||||
if (itemsToDelete.length > 0) {
|
||||
const idsToDelete = itemsToDelete.map(item => item.id);
|
||||
const deleteResult = await bidItemRepository.delete(idsToDelete);
|
||||
totalDeleted += deleteResult.affected || 0;
|
||||
logger.log(` 删除了 ${deleteResult.affected} 条重复记录,保留ID: ${items[0].id} (最晚创建)`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.log(`总共删除了 ${totalDeleted} 条重复记录`);
|
||||
|
||||
await app.close();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error('删除重复记录失败:', error);
|
||||
await app.close();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
removeDuplicates();
|
||||
Reference in New Issue
Block a user