feat(electron): 添加Electron桌面应用支持

- 新增Electron主进程、预加载脚本和构建配置
- 修改前端配置以支持Electron打包
- 更新项目文档和依赖
- 重构API调用使用统一axios实例
This commit is contained in:
dmy
2026-01-15 00:35:19 +08:00
parent f736f30248
commit eca3f4f9fd
22 changed files with 421 additions and 109 deletions

1
.gitignore vendored
View File

@@ -12,3 +12,4 @@ build
*-lock.json *-lock.json
*.woff2 *.woff2
widget/looker/frontend/src/assets/fonts/OFL.txt widget/looker/frontend/src/assets/fonts/OFL.txt
dist-electron

133
README.md
View File

@@ -15,23 +15,33 @@
### 前端 ### 前端
- **框架**: Vue.js 3 - **框架**: Vue.js 3
- **构建工具**: Vite - **构建工具**: Vite
- **UI**: Tailwind CSS - **UI**: Element Plus
- **状态管理**: Pinia - **图标**: @element-plus/icons-vue
- **HTTP 客户端**: Axios
- **Tailwind CSS**: 用于样式辅助
## 项目结构 ## 项目结构
``` ```
src/ src/
├── ai/ # AI 模块 ├── ai/ # AI 模块
│ ├── ai.controller.ts │ ├── entities/
├── ai.service.ts │ └── ai-recommendation.entity.ts
│ ├── Prompt.ts │ ├── Prompt.ts
── entities/ ── ai.controller.ts
│ ├── ai.module.ts
│ └── ai.service.ts
├── bids/ # 投标业务模块 ├── bids/ # 投标业务模块
│ ├── controllers/ │ ├── controllers/
│ │ └── bid.controller.ts
│ ├── entities/ │ ├── entities/
│ └── services/ │ └── bid-item.entity.ts
│ ├── services/
│ │ └── bid.service.ts
│ └── bids.module.ts
├── crawler/ # 爬虫模块 ├── crawler/ # 爬虫模块
│ ├── entities/
│ │ └── crawl-info-add.entity.ts
│ ├── services/ │ ├── services/
│ │ ├── bid-crawler.service.ts │ │ ├── bid-crawler.service.ts
│ │ ├── cdt_target.ts │ │ ├── cdt_target.ts
@@ -46,32 +56,63 @@ src/
│ │ ├── powerbeijing_target.ts │ │ ├── powerbeijing_target.ts
│ │ ├── sdicc_target.ts │ │ ├── sdicc_target.ts
│ │ └── szecp_target.ts │ │ └── szecp_target.ts
── entities/ ── crawler.controller.ts
│ └── crawler.module.ts
├── database/ # 数据库模块 ├── database/ # 数据库模块
│ └── database.module.ts
├── keywords/ # 关键词管理模块 ├── keywords/ # 关键词管理模块
│ ├── keyword.entity.ts
│ ├── keywords.controller.ts
│ ├── keywords.module.ts
│ └── keywords.service.ts
├── schedule/ # 定时任务 ├── schedule/ # 定时任务
── tasks/ ── tasks/
└── bid-crawl.task.ts └── bid-crawl.task.ts
│ └── schedule.module.ts
├── scripts/ # 脚本工具 ├── scripts/ # 脚本工具
│ ├── ai-recommendations.ts │ ├── ai-recommendations.ts
│ ├── crawl.ts │ ├── crawl.ts
│ ├── deploy.ps1 │ ├── deploy.ps1
│ ├── remove-duplicates.ts │ ├── remove-duplicates.ts
│ ├── sync.ts
│ └── update-source.ts │ └── update-source.ts
── common/ # 公共模块 ── common/ # 公共模块
└── logger/ └── logger/
│ ├── logger.module.ts
│ ├── logger.service.ts
│ └── winston.config.ts
├── app.controller.ts
├── app.module.ts
├── app.service.ts
└── main.ts
frontend/ frontend/
── src/ ── src/
├── components/ ├── assets/
── Dashboard.vue ── vue.svg
│ ├── Dashboard-AI.vue │ ├── components/
│ ├── PinnedProject.vue │ ├── Dashboard.vue
│ ├── Bids.vue │ ├── Dashboard-AI.vue
│ ├── Keywords.vue │ ├── PinnedProject.vue
── CrawlInfo.vue ── Bids.vue
├── App.vue │ │ ├── Keywords.vue
└── main.ts │ │ └── CrawlInfo.vue
│ ├── utils/
│ │ └── api.ts
│ ├── App.vue
│ ├── main.ts
│ └── style.css
├── .env
├── .env.example
├── .gitignore
├── README.md
├── index.html
├── package.json
├── postcss.config.js
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
``` ```
## 快速开始 ## 快速开始
@@ -141,26 +182,28 @@ npm run start:prod
### 智能爬虫模块 ### 智能爬虫模块
- **多源爬取**: 支持 12 个主流招标网站 - **多源爬取**: 支持 12 个主流招标网站
- 中国大唐集团电子商务平台 (CDT)
- 中国华能集团有限公司电子商务平台 (CHNG)
- 中国南方电网电子商务平台 (CSG)
- 中国海洋石油集团有限公司 (CNOOC)
- 中国华电集团有限公司电子商务平台 (CHDTP) - 中国华电集团有限公司电子商务平台 (CHDTP)
- 中国华能集团有限公司电子商务平台 (CHNG)
- 深圳交易集团有限公司 (SZECP)
- 中国大唐集团电子商务平台 (CDT)
- 中国电力招标网 (EPS)
- 国家能源投资集团有限责任公司 (CNNCECP) - 国家能源投资集团有限责任公司 (CNNCECP)
- 中国核工业集团有限公司 (CNNC) - 中国石油天然气集团有限公司 (CGNPC)
- 中国电力建设集团有限公司 (POWERCHINA)
- 中国能源建设集团有限公司 (CEIC) - 中国能源建设集团有限公司 (CEIC)
- 中国石油天然气集团有限公司 (CNPC) - 中国电力建设集团有限公司 (ESPIC)
- 国家电网有限公司 (SGCC)
- 北京电力交易中心 (POWERBEIJING) - 北京电力交易中心 (POWERBEIJING)
- 山东能源集团有限公司 (SDICC)
- 中国海洋石油集团有限公司 (CNOOC)
- **智能防封策略**: - **智能防封策略**:
- 随机请求间隔 (3-8 秒) - 随机请求间隔 (1-3 秒)
- 轮换 User-Agent - 固定 User-Agent
- 异常检测与自动重试机制 - 异常检测与自动重试机制
- 代理支持 - 代理支持
- **定时任务**: 每 30 分钟自动执行爬取 - **定时任务**:
- 爬虫任务:已暂停(默认每天午夜执行)
- 数据清理:每天午夜自动执行
### 数据处理与存储 ### 数据处理与存储
@@ -168,10 +211,10 @@ npm run start:prod
- 投标项目标题 - 投标项目标题
- 详细页面 URL - 详细页面 URL
- 发布时间 - 发布时间
- 招标单位 - 来源网站
- 截止日期 - 置顶标记
- 关键词匹配 - 创建时间
- 优先级评分 - 更新时间
- **增量存储**: - **增量存储**:
- 通过 URL 哈希值判断是否为新数据 - 通过 URL 哈希值判断是否为新数据
@@ -215,8 +258,12 @@ npm run start:prod
### 投标信息 ### 投标信息
- `GET /api/bids` - 获取投标列表(支持分页、筛选) - `GET /api/bids` - 获取投标列表(支持分页、筛选)
- `GET /api/bids/high-priority` - 获取高优先级投标 - `GET /api/bids/recent` - 获取最近投标
- `GET /api/bids/today` - 获取今日投标 - `GET /api/bids/pinned` - 获取置顶投标
- `GET /api/bids/sources` - 获取来源列表
- `GET /api/bids/by-date-range` - 按日期范围获取投标
- `GET /api/bids/crawl-info-stats` - 获取爬取信息统计
- `PATCH /api/bids/:title/pin` - 更新置顶状态
### 关键词管理 ### 关键词管理
- `GET /api/keywords` - 获取所有关键词 - `GET /api/keywords` - 获取所有关键词
@@ -224,12 +271,14 @@ npm run start:prod
- `DELETE /api/keywords/:id` - 删除关键词 - `DELETE /api/keywords/:id` - 删除关键词
### AI 服务 ### AI 服务
- `GET /api/ai/recommendations` - 获取 AI 推荐投标 - `POST /api/ai/recommendations` - 获取 AI 推荐
- `POST /api/ai/analyze` - 分析投标信息 - `POST /api/ai/save-recommendations` - 保存 AI 推荐
- `GET /api/ai/latest-recommendations` - 获取最新 AI 推荐
### 爬虫管理 ### 爬虫管理
- `GET /api/crawler/info` - 获取爬取信息 - `GET /api/crawler/status` - 获取爬虫状态
- `POST /api/crawler/trigger` - 手动触发爬取 - `POST /api/crawler/run` - 运行爬虫
- `POST /api/crawler/crawl/:sourceName` - 爬取单个来源
## 前端路由 ## 前端路由

39
app/electron-builder.json Normal file
View File

@@ -0,0 +1,39 @@
{
"productName": "投标应用",
"appId": "com.bidding.app",
"directories": {
"output": "dist-electron",
"app": "./app"
},
"files": [
"dist/**/*",
"frontend/**/*",
".env",
"node_modules/**/*",
"package.json",
"app/**/*"
],
"win": {
"target": "nsis",
"icon": "frontend/public/favicon.ico",
"requestedExecutionLevel": "asInvoker"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "投标应用"
},
"extraResources": [
{
"from": ".env",
"to": ".env",
"filter": ["**/*"]
}
],
"publish": {
"provider": "generic",
"url": "http://localhost:3000/"
}
}

172
app/main.js Normal file
View File

@@ -0,0 +1,172 @@
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
const { spawn } = require('child_process');
const dotenv = require('dotenv');
const fs = require('fs');
// 加载环境变量
const envPath = path.join(__dirname, '..', '.env');
if (fs.existsSync(envPath)) {
dotenv.config({ path: envPath });
}
let mainWindow;
let backendProcess;
/**
* 创建Electron主窗口
*/
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
},
autoHideMenuBar: false,
title: '投标应用',
});
// 加载前端页面
const indexPath = path.join(__dirname, '..', 'frontend', 'dist', 'index.html');
mainWindow.loadFile(indexPath);
// 开发环境下打开开发者工具
if (process.env.NODE_ENV === 'development') {
mainWindow.webContents.openDevTools();
}
mainWindow.on('closed', () => {
mainWindow = null;
});
}
/**
* 等待后端服务启动
*/
function waitForBackend(port = 3000, maxRetries = 30, interval = 1000) {
return new Promise((resolve, reject) => {
let retries = 0;
const checkBackend = () => {
const net = require('net');
const client = new net.Socket();
client.once('connect', () => {
client.destroy();
console.log('后端服务已启动');
resolve();
});
client.once('error', () => {
client.destroy();
retry();
});
client.once('timeout', () => {
client.destroy();
retry();
});
client.connect(port, 'localhost');
client.setTimeout(1000);
function retry() {
retries++;
if (retries >= maxRetries) {
reject(new Error('后端服务启动超时'));
} else {
console.log(`等待后端服务启动... (${retries}/${maxRetries})`);
setTimeout(checkBackend, interval);
}
}
};
checkBackend();
});
}
/**
* 启动后端服务
*/
async function startBackend() {
const backendPath = path.join(__dirname, '..', 'dist', 'main.js');
// 检查后端构建文件是否存在
if (!fs.existsSync(backendPath)) {
console.error('后端服务构建文件不存在,请先执行 npm run build');
return;
}
// 启动后端服务
backendProcess = spawn('node', [backendPath], {
env: {
...process.env,
NODE_ENV: process.env.NODE_ENV || 'production',
},
stdio: 'inherit',
});
backendProcess.on('error', (error) => {
console.error('后端服务启动失败:', error);
});
backendProcess.on('exit', (code) => {
console.log(`后端服务退出,退出码: ${code}`);
backendProcess = null;
});
// 等待后端服务启动完成
try {
await waitForBackend();
} catch (error) {
console.error('等待后端服务启动失败:', error.message);
}
}
/**
* 停止后端服务
*/
function stopBackend() {
if (backendProcess) {
console.log('正在停止后端服务...');
backendProcess.kill();
backendProcess = null;
}
}
// 应用就绪时启动后端服务,然后创建窗口
app.on('ready', async () => {
await startBackend();
createWindow();
});
// 所有窗口关闭时退出应用
app.on('window-all-closed', () => {
stopBackend();
if (process.platform !== 'darwin') {
app.quit();
}
});
// MacOS上点击dock图标时重新创建窗口
app.on('activate', async () => {
if (BrowserWindow.getAllWindows().length === 0) {
if (!backendProcess) {
await startBackend();
}
createWindow();
}
});
// 应用退出前停止后端服务
app.on('before-quit', () => {
stopBackend();
});
// 处理来自渲染进程的IPC消息
ipcMain.handle('get-env', (event, key) => {
return process.env[key];
});

13
app/package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "bidding-app",
"version": "0.0.1",
"description": "投标应用Electron版本",
"main": "main.js",
"scripts": {
"start": "electron .",
"build": "electron-builder"
},
"keywords": ["electron", "bidding", "app"],
"author": "",
"license": "UNLICENSED"
}

14
app/preload.js Normal file
View File

@@ -0,0 +1,14 @@
const { contextBridge, ipcRenderer } = require('electron');
/**
* 预加载脚本,用于在渲染进程和主进程之间通信
* 提供安全的API给渲染进程访问主进程功能
*/
contextBridge.exposeInMainWorld('electronAPI', {
/**
* 获取环境变量值
* @param {string} key - 环境变量名称
* @returns {Promise<string>} - 环境变量值
*/
getEnv: (key) => ipcRenderer.invoke('get-env', key),
});

View File

@@ -1,5 +0,0 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

View File

@@ -64,7 +64,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import axios from 'axios' import api from './utils/api'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { DataBoard, Document, Setting, MagicStick, Connection } from '@element-plus/icons-vue' import { DataBoard, Document, Setting, MagicStick, Connection } from '@element-plus/icons-vue'
import Dashboard from './components/Dashboard.vue' import Dashboard from './components/Dashboard.vue'
@@ -89,7 +89,7 @@ const handleSelect = (key: string) => {
const handleFetchBids = async (page: number, limit: number, source?: string) => { const handleFetchBids = async (page: number, limit: number, source?: string) => {
loading.value = true loading.value = true
try { try {
const res = await axios.get('/api/bids', { const res = await api.get('/api/bids', {
params: { params: {
page, page,
limit, limit,
@@ -109,16 +109,16 @@ const fetchData = async () => {
loading.value = true loading.value = true
try { try {
const [bidsRes, recentRes, kwRes, sourcesRes, statusRes] = await Promise.all([ const [bidsRes, recentRes, kwRes, sourcesRes, statusRes] = await Promise.all([
axios.get('/api/bids', { api.get('/api/bids', {
params: { params: {
page: 1, page: 1,
limit: 10 limit: 10
} }
}), }),
axios.get('/api/bids/recent'), api.get('/api/bids/recent'),
axios.get('/api/keywords'), api.get('/api/keywords'),
axios.get('/api/bids/sources'), api.get('/api/bids/sources'),
axios.get('/api/crawler/status') api.get('/api/crawler/status')
]) ])
bids.value = bidsRes.data.items bids.value = bidsRes.data.items
total.value = bidsRes.data.total total.value = bidsRes.data.total
@@ -145,7 +145,7 @@ const updateBidsByDateRange = async (startDate: string, endDate?: string, keywor
params.keywords = keywords.join(',') params.keywords = keywords.join(',')
} }
const response = await axios.get('/api/bids/by-date-range', { params }) const response = await api.get('/api/bids/by-date-range', { params })
todayBids.value = response.data todayBids.value = response.data
ElMessage.success(`更新成功,共 ${response.data.length} 条数据`) ElMessage.success(`更新成功,共 ${response.data.length} 条数据`)
} catch (error) { } catch (error) {

View File

@@ -51,7 +51,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import axios from 'axios' import api from '../utils/api'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Paperclip } from '@element-plus/icons-vue' import { Paperclip } from '@element-plus/icons-vue'
@@ -97,7 +97,7 @@ const handleSizeChange = (size: number) => {
const togglePin = async (item: any) => { const togglePin = async (item: any) => {
try { try {
const newPinStatus = !item.pin const newPinStatus = !item.pin
await axios.patch(`/api/bids/${encodeURIComponent(item.title)}/pin`, { pin: newPinStatus }) await api.patch(`/api/bids/${encodeURIComponent(item.title)}/pin`, { pin: newPinStatus })
item.pin = newPinStatus item.pin = newPinStatus
ElMessage.success(newPinStatus ? '已置顶' : '已取消置顶') ElMessage.success(newPinStatus ? '已置顶' : '已取消置顶')
} catch (error) { } catch (error) {

View File

@@ -75,7 +75,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import axios from 'axios' import api from '../utils/api'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue' import { Refresh } from '@element-plus/icons-vue'
@@ -118,7 +118,7 @@ const formatDate = (dateStr: string | null) => {
const fetchCrawlStats = async () => { const fetchCrawlStats = async () => {
loading.value = true loading.value = true
try { try {
const res = await axios.get('/api/bids/crawl-info-stats') const res = await api.get('/api/bids/crawl-info-stats')
crawlStats.value = res.data crawlStats.value = res.data
} catch (error) { } catch (error) {
console.error('Failed to fetch crawl stats:', error) console.error('Failed to fetch crawl stats:', error)
@@ -132,7 +132,7 @@ const crawlSingleSource = async (sourceName: string) => {
crawlingSources.value.add(sourceName) crawlingSources.value.add(sourceName)
try { try {
ElMessage.info(`正在更新 ${sourceName}...`) ElMessage.info(`正在更新 ${sourceName}...`)
const res = await axios.post(`/api/crawler/crawl/${encodeURIComponent(sourceName)}`) const res = await api.post(`/api/crawler/crawl/${encodeURIComponent(sourceName)}`)
if (res.data.success) { if (res.data.success) {
ElMessage.success(`${sourceName} 更新成功,获取 ${res.data.count} 条数据`) ElMessage.success(`${sourceName} 更新成功,获取 ${res.data.count} 条数据`)

View File

@@ -127,7 +127,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import axios from 'axios' import api from '../utils/api'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { MagicStick, Loading, InfoFilled, List, Paperclip } from '@element-plus/icons-vue' import { MagicStick, Loading, InfoFilled, List, Paperclip } from '@element-plus/icons-vue'
import PinnedProject from './PinnedProject.vue' import PinnedProject from './PinnedProject.vue'
@@ -175,11 +175,11 @@ watch(dateRange, (newDateRange) => {
// 从数据库加载最新的 AI 推荐 // 从数据库加载最新的 AI 推荐
const loadLatestRecommendations = async () => { const loadLatestRecommendations = async () => {
try { try {
const response = await axios.get('/api/ai/latest-recommendations') const response = await api.get('/api/ai/latest-recommendations')
const recommendations = response.data const recommendations = response.data
// 获取所有置顶的项目 // 获取所有置顶的项目
const pinnedResponse = await axios.get('/api/bids/pinned') const pinnedResponse = await api.get('/api/bids/pinned')
const pinnedTitles = new Set(pinnedResponse.data.map((b: any) => b.title)) const pinnedTitles = new Set(pinnedResponse.data.map((b: any) => b.title))
// 更新每个推荐项目的 pin 状态 // 更新每个推荐项目的 pin 状态
@@ -240,7 +240,7 @@ const fetchAIRecommendations = async () => {
})) }))
// 调用后端 API // 调用后端 API
const response = await axios.post('/api/ai/recommendations', { const response = await api.post('/api/ai/recommendations', {
bids: bidsData bids: bidsData
}) })
@@ -267,7 +267,7 @@ const fetchAIRecommendations = async () => {
aiRecommendations.value = recommendations aiRecommendations.value = recommendations
// 保存推荐结果到数据库 // 保存推荐结果到数据库
await axios.post('/api/ai/save-recommendations', { await api.post('/api/ai/save-recommendations', {
recommendations recommendations
}) })
@@ -301,7 +301,7 @@ const fetchBidsByDateRange = async () => {
params.endDate = endDate params.endDate = endDate
} }
const response = await axios.get('/api/bids/by-date-range', { params }) const response = await api.get('/api/bids/by-date-range', { params })
bidsByDateRange.value = response.data bidsByDateRange.value = response.data
ElMessage.success(`获取成功,共 ${response.data.length} 个工程`) ElMessage.success(`获取成功,共 ${response.data.length} 个工程`)
} catch (error: any) { } catch (error: any) {
@@ -344,7 +344,7 @@ const handlePinChanged = async (title: string) => {
const togglePin = async (item: AIRecommendation) => { const togglePin = async (item: AIRecommendation) => {
try { try {
const newPinStatus = !item.pin const newPinStatus = !item.pin
await axios.patch(`/api/bids/${encodeURIComponent(item.title)}/pin`, { pin: newPinStatus }) await api.patch(`/api/bids/${encodeURIComponent(item.title)}/pin`, { pin: newPinStatus })
item.pin = newPinStatus item.pin = newPinStatus
ElMessage.success(newPinStatus ? '已置顶' : '已取消置顶') ElMessage.success(newPinStatus ? '已置顶' : '已取消置顶')
// 刷新 PinnedProject 组件的数据 // 刷新 PinnedProject 组件的数据

View File

@@ -77,7 +77,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import axios from 'axios' import api from '../utils/api'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Refresh, Paperclip } from '@element-plus/icons-vue' import { Refresh, Paperclip } from '@element-plus/icons-vue'
import PinnedProject from './PinnedProject.vue' import PinnedProject from './PinnedProject.vue'
@@ -275,7 +275,7 @@ const handleCrawl = async () => {
} }
crawling.value = true crawling.value = true
try { try {
await axios.post('/api/crawler/run') await api.post('/api/crawler/run')
ElMessage.success('Crawl completed successfully') ElMessage.success('Crawl completed successfully')
emit('refresh') // Refresh data after crawl emit('refresh') // Refresh data after crawl
} catch (error) { } catch (error) {
@@ -301,7 +301,7 @@ const handlePinChanged = async (title: string) => {
const togglePin = async (item: any) => { const togglePin = async (item: any) => {
try { try {
const newPinStatus = !item.pin const newPinStatus = !item.pin
await axios.patch(`/api/bids/${encodeURIComponent(item.title)}/pin`, { pin: newPinStatus }) await api.patch(`/api/bids/${encodeURIComponent(item.title)}/pin`, { pin: newPinStatus })
item.pin = newPinStatus item.pin = newPinStatus
ElMessage.success(newPinStatus ? '已置顶' : '已取消置顶') ElMessage.success(newPinStatus ? '已置顶' : '已取消置顶')
// 刷新 PinnedProject 组件的数据 // 刷新 PinnedProject 组件的数据

View File

@@ -40,7 +40,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive } from 'vue' import { ref, reactive } from 'vue'
import axios from 'axios' import api from '../utils/api'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
interface Props { interface Props {
@@ -76,7 +76,7 @@ const handleAddKeyword = async () => {
return return
} }
try { try {
await axios.post('/api/keywords', form) await api.post('/api/keywords', form)
ElMessage.success('Keyword added') ElMessage.success('Keyword added')
dialogVisible.value = false dialogVisible.value = false
form.word = '' form.word = ''
@@ -89,7 +89,7 @@ const handleAddKeyword = async () => {
const handleDeleteKeyword = async (id: string) => { const handleDeleteKeyword = async (id: string) => {
try { try {
await axios.delete(`/api/keywords/${id}`) await api.delete(`/api/keywords/${id}`)
ElMessage.success('Keyword deleted') ElMessage.success('Keyword deleted')
emit('refresh') emit('refresh')
} catch (error) { } catch (error) {

View File

@@ -48,7 +48,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import axios from 'axios' import api from '../utils/api'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Loading, InfoFilled, Paperclip } from '@element-plus/icons-vue' import { Loading, InfoFilled, Paperclip } from '@element-plus/icons-vue'
@@ -63,7 +63,7 @@ const pinnedLoading = ref(false)
const loadPinnedBids = async () => { const loadPinnedBids = async () => {
pinnedLoading.value = true pinnedLoading.value = true
try { try {
const response = await axios.get('/api/bids/pinned') const response = await api.get('/api/bids/pinned')
pinnedBids.value = response.data pinnedBids.value = response.data
} catch (error) { } catch (error) {
console.error('Failed to load pinned bids:', error) console.error('Failed to load pinned bids:', error)
@@ -75,7 +75,7 @@ const loadPinnedBids = async () => {
// 切换置顶列表的 Pin 状态 // 切换置顶列表的 Pin 状态
const togglePin = async (item: any) => { const togglePin = async (item: any) => {
try { try {
await axios.patch(`/api/bids/${encodeURIComponent(item.title)}/pin`, { pin: false }) await api.patch(`/api/bids/${encodeURIComponent(item.title)}/pin`, { pin: false })
const index = pinnedBids.value.findIndex(b => b.title === item.title) const index = pinnedBids.value.findIndex(b => b.title === item.title)
if (index !== -1) { if (index !== -1) {
pinnedBids.value.splice(index, 1) pinnedBids.value.splice(index, 1)

34
frontend/src/utils/api.ts Normal file
View File

@@ -0,0 +1,34 @@
import axios from 'axios'
/**
* API配置
* 配置axios实例设置baseURL和请求拦截器
*/
const api = axios.create({
baseURL: 'http://localhost:3000', // 设置后端服务地址
timeout: 10000, // 请求超时时间
})
// 请求拦截器
api.interceptors.request.use(
(config) => {
// 可以在这里添加认证信息等
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
api.interceptors.response.use(
(response) => {
return response
},
(error) => {
console.error('API请求错误:', error)
return Promise.reject(error)
}
)
export default api

View File

@@ -3,6 +3,7 @@ import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
base: './',
plugins: [vue()], plugins: [vue()],
server: { server: {
proxy: { proxy: {

View File

@@ -22,7 +22,9 @@
"update-source": "ts-node -r tsconfig-paths/register src/scripts/update-source.ts", "update-source": "ts-node -r tsconfig-paths/register src/scripts/update-source.ts",
"ai-recommendations": "ts-node -r tsconfig-paths/register src/scripts/ai-recommendations.ts", "ai-recommendations": "ts-node -r tsconfig-paths/register src/scripts/ai-recommendations.ts",
"sync": "ts-node -r tsconfig-paths/register src/scripts/sync.ts", "sync": "ts-node -r tsconfig-paths/register src/scripts/sync.ts",
"deploy": "powershell -ExecutionPolicy Bypass -File src/scripts/deploy.ps1" "deploy": "powershell -ExecutionPolicy Bypass -File src/scripts/deploy.ps1",
"electron:start": "npm run build && set NODE_ENV=development&& electron ./app",
"electron:build": "npm run build && electron-builder --config ./app/electron-builder.json"
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
@@ -53,11 +55,17 @@
"@nestjs/cli": "^11.0.14", "@nestjs/cli": "^11.0.14",
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/cacheable-request": "^6.0.3",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/fs-extra": "^11.0.4",
"@types/http-cache-semantics": "^4.0.4",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/responselike": "^1.0.3",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"electron": "^39.2.7",
"electron-builder": "^26.4.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2", "eslint-plugin-prettier": "^5.2.2",

View File

@@ -60,7 +60,6 @@ const appLogTransport = new DailyRotateFile({
datePattern: 'YYYY-MM-DD', datePattern: 'YYYY-MM-DD',
maxSize: '20m', maxSize: '20m',
maxFiles: '30d', maxFiles: '30d',
format: logFormat,
}); });
// 错误日志传输(按天轮转) // 错误日志传输(按天轮转)
@@ -68,16 +67,15 @@ const errorLogTransport = new DailyRotateFile({
dirname: logDir, dirname: logDir,
filename: 'error-%DATE%.log', filename: 'error-%DATE%.log',
datePattern: 'YYYY-MM-DD', datePattern: 'YYYY-MM-DD',
level: 'error',
maxSize: '20m', maxSize: '20m',
maxFiles: '30d', maxFiles: '30d',
format: logFormat, level: 'error',
}); });
// 创建 winston logger 实例 // 创建 winston logger 实例
export const winstonLogger = winston.createLogger({ export const winstonLogger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info', level: process.env.LOG_LEVEL || 'info',
format: logFormat, format: logFormat,
transports: [consoleTransport, appLogTransport, errorLogTransport], transports: [consoleTransport, appLogTransport as any, errorLogTransport as any],
exitOnError: false, exitOnError: false,
}); });

View File

@@ -24,7 +24,19 @@ interface CrawlResult {
url: string; url: string;
} }
type AnyCrawler = typeof ChdtpCrawler | typeof ChngCrawler | typeof SzecpCrawler | typeof CdtCrawler | typeof EpsCrawler | typeof CnncecpCrawler | typeof CgnpcCrawler | typeof CeicCrawler | typeof EspicCrawler | typeof PowerbeijingCrawler | typeof SdiccCrawler | typeof CnoocCrawler; type AnyCrawler =
| typeof ChdtpCrawler
| typeof ChngCrawler
| typeof SzecpCrawler
| typeof CdtCrawler
| typeof EpsCrawler
| typeof CnncecpCrawler
| typeof CgnpcCrawler
| typeof CeicCrawler
| typeof EspicCrawler
| typeof PowerbeijingCrawler
| typeof SdiccCrawler
| typeof CnoocCrawler;
@Injectable() @Injectable()
export class BidCrawlerService { export class BidCrawlerService {

View File

@@ -1,10 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { BidItem } from '../bids/entities/bid-item.entity';
import { Keyword } from '../keywords/keyword.entity';
import { AiRecommendation } from '../ai/entities/ai-recommendation.entity';
import { CrawlInfoAdd } from '../crawler/entities/crawl-info-add.entity';
@Module({ @Module({
imports: [ imports: [
@@ -22,7 +18,7 @@ import { CrawlInfoAdd } from '../crawler/entities/crawl-info-add.entity';
username: configService.get<string>('DATABASE_USERNAME', 'root'), username: configService.get<string>('DATABASE_USERNAME', 'root'),
password: configService.get<string>('DATABASE_PASSWORD', 'root'), password: configService.get<string>('DATABASE_PASSWORD', 'root'),
database: configService.get<string>('DATABASE_NAME', 'bidding'), database: configService.get<string>('DATABASE_NAME', 'bidding'),
entities: [BidItem, Keyword, AiRecommendation, CrawlInfoAdd], entities: [__dirname + '/../**/*.entity{.ts,.js}'],
synchronize: false, synchronize: false,
}), }),
}), }),

View File

@@ -1,8 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"module": "nodenext", "module": "commonjs",
"moduleResolution": "nodenext", "moduleResolution": "node",
"resolvePackageJsonExports": true,
"esModuleInterop": true, "esModuleInterop": true,
"isolatedModules": true, "isolatedModules": true,
"declaration": true, "declaration": true,

View File

@@ -1,19 +0,0 @@
# README
## About
This is the official Wails Vue-TS template.
You can configure the project by editing `wails.json`. More information about the project settings can be found
here: https://wails.io/docs/reference/project-config
## Live Development
To run in live development mode, run `wails dev` in the project directory. This will run a Vite development
server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser
and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect
to this in your browser, and you can call your Go code from devtools.
## Building
To build a redistributable, production mode package, use `wails build`.