feat: 添加项目置顶功能
在仪表盘中添加置顶项目功能,包括: - 新增置顶项目展示区域 - 为AI推荐项目添加置顶/取消置顶操作 - 后端接口支持置顶状态管理
This commit is contained in:
@@ -7,6 +7,55 @@
|
|||||||
获取 AI 推荐
|
获取 AI 推荐
|
||||||
</el-button>
|
</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>Pined</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="togglePinnedPin(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>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :span="24">
|
<el-col :span="24">
|
||||||
<el-card class="box-card" shadow="hover">
|
<el-card class="box-card" shadow="hover">
|
||||||
@@ -27,6 +76,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<el-table :data="aiRecommendations" style="width: 100%" size="small">
|
<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="项目名称">
|
<el-table-column prop="title" label="项目名称">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<a :href="scope.row.url" target="_blank">{{ scope.row.title }}</a>
|
<a :href="scope.row.url" target="_blank">{{ scope.row.title }}</a>
|
||||||
@@ -114,7 +177,7 @@
|
|||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { MagicStick, Loading, InfoFilled, List, ArrowDown } from '@element-plus/icons-vue'
|
import { MagicStick, Loading, InfoFilled, List, ArrowDown, Paperclip } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
|
||||||
interface AIRecommendation {
|
interface AIRecommendation {
|
||||||
@@ -123,6 +186,7 @@ interface AIRecommendation {
|
|||||||
source: string
|
source: string
|
||||||
confidence: number
|
confidence: number
|
||||||
publishDate?: string
|
publishDate?: string
|
||||||
|
pin?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -137,6 +201,8 @@ const dateRange = ref<[string, string] | null>(null)
|
|||||||
const showAllBids = ref(false)
|
const showAllBids = ref(false)
|
||||||
const bidsLoading = ref(false)
|
const bidsLoading = ref(false)
|
||||||
const bidsByDateRange = ref<any[]>([])
|
const bidsByDateRange = ref<any[]>([])
|
||||||
|
const pinnedBids = ref<any[]>([])
|
||||||
|
const pinnedLoading = ref(false)
|
||||||
|
|
||||||
// 从 localStorage 加载保存的日期范围
|
// 从 localStorage 加载保存的日期范围
|
||||||
const loadSavedDateRange = () => {
|
const loadSavedDateRange = () => {
|
||||||
@@ -159,15 +225,39 @@ watch(dateRange, (newDateRange) => {
|
|||||||
const loadLatestRecommendations = async () => {
|
const loadLatestRecommendations = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/api/ai/latest-recommendations')
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to load latest recommendations:', error)
|
console.error('Failed to load latest recommendations:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化时加载保存的日期范围和最新的 AI 推荐
|
// 加载置顶项目
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化时加载保存的日期范围、最新的 AI 推荐和置顶项目
|
||||||
loadSavedDateRange()
|
loadSavedDateRange()
|
||||||
loadLatestRecommendations()
|
loadLatestRecommendations()
|
||||||
|
loadPinnedBids()
|
||||||
|
|
||||||
// 设置日期范围为最近3天
|
// 设置日期范围为最近3天
|
||||||
const setLast3Days = () => {
|
const setLast3Days = () => {
|
||||||
@@ -225,7 +315,8 @@ const fetchAIRecommendations = async () => {
|
|||||||
url: bid?.url || '',
|
url: bid?.url || '',
|
||||||
source: bid?.source || '',
|
source: bid?.source || '',
|
||||||
confidence: rec.confidence,
|
confidence: rec.confidence,
|
||||||
publishDate: bid?.publishDate
|
publishDate: bid?.publishDate,
|
||||||
|
pin: bid?.pin || false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -299,6 +390,53 @@ const getConfidenceType = (confidence: number) => {
|
|||||||
if (confidence >= 70) return 'warning'
|
if (confidence >= 70) return 'warning'
|
||||||
return 'info'
|
return 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 切换 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
|
||||||
|
if (newPinStatus) {
|
||||||
|
// 添加到置顶列表
|
||||||
|
pinnedBids.value.unshift({
|
||||||
|
title: item.title,
|
||||||
|
url: item.url,
|
||||||
|
source: item.source,
|
||||||
|
publishDate: item.publishDate,
|
||||||
|
pin: true
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 从置顶列表移除
|
||||||
|
const index = pinnedBids.value.findIndex(b => b.title === item.title)
|
||||||
|
if (index !== -1) {
|
||||||
|
pinnedBids.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ElMessage.success(newPinStatus ? '已置顶' : '已取消置顶')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换置顶列表的 Pin 状态
|
||||||
|
const togglePinnedPin = 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)
|
||||||
|
}
|
||||||
|
// 同时更新 AI 推荐列表中的状态
|
||||||
|
const aiItem = aiRecommendations.value.find(r => r.title === item.title)
|
||||||
|
if (aiItem) {
|
||||||
|
aiItem.pin = false
|
||||||
|
}
|
||||||
|
ElMessage.success('已取消置顶')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -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';
|
import { BidsService } from '../services/bid.service';
|
||||||
|
|
||||||
@Controller('api/bids')
|
@Controller('api/bids')
|
||||||
@@ -15,6 +15,11 @@ export class BidsController {
|
|||||||
return this.bidsService.getRecentBids();
|
return this.bidsService.getRecentBids();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('pinned')
|
||||||
|
getPinned() {
|
||||||
|
return this.bidsService.getPinnedBids();
|
||||||
|
}
|
||||||
|
|
||||||
@Get('sources')
|
@Get('sources')
|
||||||
getSources() {
|
getSources() {
|
||||||
return this.bidsService.getSources();
|
return this.bidsService.getSources();
|
||||||
@@ -30,4 +35,9 @@ export class BidsController {
|
|||||||
getCrawlInfoStats() {
|
getCrawlInfoStats() {
|
||||||
return this.bidsService.getCrawlInfoAddStats();
|
return this.bidsService.getCrawlInfoAddStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':title/pin')
|
||||||
|
updatePin(@Param('title') title: string, @Body() body: { pin: boolean }) {
|
||||||
|
return this.bidsService.updatePin(title, body.pin);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export class BidItem {
|
|||||||
@Column()
|
@Column()
|
||||||
source: string;
|
source: string;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
pin: boolean;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ export class BidsService {
|
|||||||
.getMany();
|
.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[]) {
|
async getBidsByDateRange(startDate?: string, endDate?: string, keywords?: string[]) {
|
||||||
const qb = this.bidRepository.createQueryBuilder('bid');
|
const qb = this.bidRepository.createQueryBuilder('bid');
|
||||||
|
|
||||||
@@ -101,6 +109,15 @@ export class BidsService {
|
|||||||
return qb.orderBy('bid.publishDate', 'DESC').getMany();
|
return qb.orderBy('bid.publishDate', 'DESC').getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
async getCrawlInfoAddStats() {
|
||||||
// 获取每个来源的最新一次爬虫记录(按 createdAt 降序)
|
// 获取每个来源的最新一次爬虫记录(按 createdAt 降序)
|
||||||
const query = `
|
const query = `
|
||||||
|
|||||||
Reference in New Issue
Block a user