Compare commits
11 Commits
82f5a81887
...
36cbb6fda1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36cbb6fda1 | ||
|
|
20c7c0da0c | ||
|
|
37200aa115 | ||
|
|
af78fd0682 | ||
|
|
20619bb87b | ||
|
|
70f0498c44 | ||
|
|
0f510554ed | ||
|
|
5a7cbc6daa | ||
|
|
e804e3998f | ||
|
|
eca3f4f9fd | ||
|
|
f736f30248 |
18
.env
18
.env
@@ -6,11 +6,21 @@
|
||||
# DATABASE_NAME=bidding
|
||||
# DATABASE_SYNCHRONIZE=true
|
||||
|
||||
# DATABASE_TYPE=mysql
|
||||
# DATABASE_HOST=bj-cynosdbmysql-grp-r3a4c658.sql.tencentcdb.com
|
||||
# DATABASE_PORT=21741
|
||||
# DATABASE_USERNAME=root
|
||||
# DATABASE_PASSWORD=}?cRa1f[,}`J
|
||||
# DATABASE_NAME=bidding
|
||||
# DATABASE_SYNCHRONIZE=false
|
||||
|
||||
|
||||
|
||||
DATABASE_TYPE=mysql
|
||||
DATABASE_HOST=bj-cynosdbmysql-grp-r3a4c658.sql.tencentcdb.com
|
||||
DATABASE_PORT=21741
|
||||
DATABASE_USERNAME=root
|
||||
DATABASE_PASSWORD=}?cRa1f[,}`J
|
||||
DATABASE_HOST=mysql-35aea0ff-ijustforregister-858d.h.aivencloud.com
|
||||
DATABASE_PORT=14129
|
||||
DATABASE_USERNAME=avnadmin
|
||||
DATABASE_PASSWORD=AVNS_PJLxfsWSKa4_FAq_PBt
|
||||
DATABASE_NAME=bidding
|
||||
DATABASE_SYNCHRONIZE=false
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,3 +12,4 @@ build
|
||||
*-lock.json
|
||||
*.woff2
|
||||
widget/looker/frontend/src/assets/fonts/OFL.txt
|
||||
dist-electron
|
||||
133
README.md
133
README.md
@@ -15,23 +15,33 @@
|
||||
### 前端
|
||||
- **框架**: Vue.js 3
|
||||
- **构建工具**: Vite
|
||||
- **UI**: Tailwind CSS
|
||||
- **状态管理**: Pinia
|
||||
- **UI**: Element Plus
|
||||
- **图标**: @element-plus/icons-vue
|
||||
- **HTTP 客户端**: Axios
|
||||
- **Tailwind CSS**: 用于样式辅助
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── ai/ # AI 模块
|
||||
│ ├── ai.controller.ts
|
||||
│ ├── ai.service.ts
|
||||
│ ├── entities/
|
||||
│ │ └── ai-recommendation.entity.ts
|
||||
│ ├── Prompt.ts
|
||||
│ └── entities/
|
||||
│ ├── ai.controller.ts
|
||||
│ ├── ai.module.ts
|
||||
│ └── ai.service.ts
|
||||
├── bids/ # 投标业务模块
|
||||
│ ├── controllers/
|
||||
│ │ └── bid.controller.ts
|
||||
│ ├── entities/
|
||||
│ └── services/
|
||||
│ │ └── bid-item.entity.ts
|
||||
│ ├── services/
|
||||
│ │ └── bid.service.ts
|
||||
│ └── bids.module.ts
|
||||
├── crawler/ # 爬虫模块
|
||||
│ ├── entities/
|
||||
│ │ └── crawl-info-add.entity.ts
|
||||
│ ├── services/
|
||||
│ │ ├── bid-crawler.service.ts
|
||||
│ │ ├── cdt_target.ts
|
||||
@@ -46,32 +56,63 @@ src/
|
||||
│ │ ├── powerbeijing_target.ts
|
||||
│ │ ├── sdicc_target.ts
|
||||
│ │ └── szecp_target.ts
|
||||
│ └── entities/
|
||||
│ ├── crawler.controller.ts
|
||||
│ └── crawler.module.ts
|
||||
├── database/ # 数据库模块
|
||||
│ └── database.module.ts
|
||||
├── keywords/ # 关键词管理模块
|
||||
│ ├── keyword.entity.ts
|
||||
│ ├── keywords.controller.ts
|
||||
│ ├── keywords.module.ts
|
||||
│ └── keywords.service.ts
|
||||
├── schedule/ # 定时任务
|
||||
│ └── tasks/
|
||||
│ └── bid-crawl.task.ts
|
||||
│ ├── tasks/
|
||||
│ │ └── bid-crawl.task.ts
|
||||
│ └── schedule.module.ts
|
||||
├── scripts/ # 脚本工具
|
||||
│ ├── ai-recommendations.ts
|
||||
│ ├── crawl.ts
|
||||
│ ├── deploy.ps1
|
||||
│ ├── remove-duplicates.ts
|
||||
│ ├── sync.ts
|
||||
│ └── update-source.ts
|
||||
└── common/ # 公共模块
|
||||
└── logger/
|
||||
├── common/ # 公共模块
|
||||
│ └── logger/
|
||||
│ ├── logger.module.ts
|
||||
│ ├── logger.service.ts
|
||||
│ └── winston.config.ts
|
||||
├── app.controller.ts
|
||||
├── app.module.ts
|
||||
├── app.service.ts
|
||||
└── main.ts
|
||||
|
||||
frontend/
|
||||
└── src/
|
||||
├── components/
|
||||
│ ├── Dashboard.vue
|
||||
│ ├── Dashboard-AI.vue
|
||||
│ ├── PinnedProject.vue
|
||||
│ ├── Bids.vue
|
||||
│ ├── Keywords.vue
|
||||
│ └── CrawlInfo.vue
|
||||
├── App.vue
|
||||
└── main.ts
|
||||
├── src/
|
||||
│ ├── assets/
|
||||
│ │ └── vue.svg
|
||||
│ ├── components/
|
||||
│ │ ├── Dashboard.vue
|
||||
│ │ ├── Dashboard-AI.vue
|
||||
│ │ ├── PinnedProject.vue
|
||||
│ │ ├── Bids.vue
|
||||
│ │ ├── Keywords.vue
|
||||
│ │ └── 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 个主流招标网站
|
||||
- 中国大唐集团电子商务平台 (CDT)
|
||||
- 中国华能集团有限公司电子商务平台 (CHNG)
|
||||
- 中国南方电网电子商务平台 (CSG)
|
||||
- 中国海洋石油集团有限公司 (CNOOC)
|
||||
- 中国华电集团有限公司电子商务平台 (CHDTP)
|
||||
- 中国华能集团有限公司电子商务平台 (CHNG)
|
||||
- 深圳交易集团有限公司 (SZECP)
|
||||
- 中国大唐集团电子商务平台 (CDT)
|
||||
- 中国电力招标网 (EPS)
|
||||
- 国家能源投资集团有限责任公司 (CNNCECP)
|
||||
- 中国核工业集团有限公司 (CNNC)
|
||||
- 中国电力建设集团有限公司 (POWERCHINA)
|
||||
- 中国石油天然气集团有限公司 (CGNPC)
|
||||
- 中国能源建设集团有限公司 (CEIC)
|
||||
- 中国石油天然气集团有限公司 (CNPC)
|
||||
- 国家电网有限公司 (SGCC)
|
||||
- 中国电力建设集团有限公司 (ESPIC)
|
||||
- 北京电力交易中心 (POWERBEIJING)
|
||||
- 山东能源集团有限公司 (SDICC)
|
||||
- 中国海洋石油集团有限公司 (CNOOC)
|
||||
|
||||
- **智能防封策略**:
|
||||
- 随机请求间隔 (3-8 秒)
|
||||
- 轮换 User-Agent
|
||||
- 随机请求间隔 (1-3 秒)
|
||||
- 固定 User-Agent
|
||||
- 异常检测与自动重试机制
|
||||
- 代理支持
|
||||
|
||||
- **定时任务**: 每 30 分钟自动执行爬取
|
||||
- **定时任务**:
|
||||
- 爬虫任务:已暂停(默认每天午夜执行)
|
||||
- 数据清理:每天午夜自动执行
|
||||
|
||||
### 数据处理与存储
|
||||
|
||||
@@ -168,10 +211,10 @@ npm run start:prod
|
||||
- 投标项目标题
|
||||
- 详细页面 URL
|
||||
- 发布时间
|
||||
- 招标单位
|
||||
- 截止日期
|
||||
- 关键词匹配
|
||||
- 优先级评分
|
||||
- 来源网站
|
||||
- 置顶标记
|
||||
- 创建时间
|
||||
- 更新时间
|
||||
|
||||
- **增量存储**:
|
||||
- 通过 URL 哈希值判断是否为新数据
|
||||
@@ -215,8 +258,12 @@ npm run start:prod
|
||||
|
||||
### 投标信息
|
||||
- `GET /api/bids` - 获取投标列表(支持分页、筛选)
|
||||
- `GET /api/bids/high-priority` - 获取高优先级投标
|
||||
- `GET /api/bids/today` - 获取今日投标
|
||||
- `GET /api/bids/recent` - 获取最近投标
|
||||
- `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` - 获取所有关键词
|
||||
@@ -224,12 +271,14 @@ npm run start:prod
|
||||
- `DELETE /api/keywords/:id` - 删除关键词
|
||||
|
||||
### AI 服务
|
||||
- `GET /api/ai/recommendations` - 获取 AI 推荐投标
|
||||
- `POST /api/ai/analyze` - 分析投标信息
|
||||
- `POST /api/ai/recommendations` - 获取 AI 推荐
|
||||
- `POST /api/ai/save-recommendations` - 保存 AI 推荐
|
||||
- `GET /api/ai/latest-recommendations` - 获取最新 AI 推荐
|
||||
|
||||
### 爬虫管理
|
||||
- `GET /api/crawler/info` - 获取爬取信息
|
||||
- `POST /api/crawler/trigger` - 手动触发爬取
|
||||
- `GET /api/crawler/status` - 获取爬虫状态
|
||||
- `POST /api/crawler/run` - 运行爬虫
|
||||
- `POST /api/crawler/crawl/:sourceName` - 爬取单个来源
|
||||
|
||||
## 前端路由
|
||||
|
||||
|
||||
42
app/electron-builder.json
Normal file
42
app/electron-builder.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"productName": "投标应用",
|
||||
"appId": "com.bidding.app",
|
||||
"directories": {
|
||||
"output": "dist-electron",
|
||||
"app": "."
|
||||
},
|
||||
"files": [
|
||||
"app/**/*",
|
||||
"dist/**/*",
|
||||
"frontend/dist/**/*",
|
||||
".env",
|
||||
"package.json"
|
||||
],
|
||||
"asarUnpack": [
|
||||
"dist/**/*",
|
||||
"node_modules/**/*"
|
||||
],
|
||||
"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/"
|
||||
}
|
||||
}
|
||||
267
app/main.js
Normal file
267
app/main.js
Normal file
@@ -0,0 +1,267 @@
|
||||
const { app, BrowserWindow, ipcMain, Menu } = require('electron');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const dotenv = require('dotenv');
|
||||
const fs = require('fs');
|
||||
|
||||
// 设置控制台输出编码为 UTF-8(Windows 兼容)
|
||||
if (process.platform === 'win32') {
|
||||
// 设置 stdout 和 stderr 的编码
|
||||
if (process.stdout.setDefaultEncoding) {
|
||||
process.stdout.setDefaultEncoding('utf8');
|
||||
}
|
||||
if (process.stderr.setDefaultEncoding) {
|
||||
process.stderr.setDefaultEncoding('utf8');
|
||||
}
|
||||
|
||||
// 尝试设置控制台代码页为 UTF-8
|
||||
try {
|
||||
require('child_process').exec('chcp 65001', { encoding: 'utf8' });
|
||||
} catch (e) {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
// 加载环境变量
|
||||
const envPath = path.join(__dirname, '..', '.env');
|
||||
if (fs.existsSync(envPath)) {
|
||||
dotenv.config({ path: envPath });
|
||||
}
|
||||
|
||||
let mainWindow;
|
||||
let backendProcess;
|
||||
|
||||
// 判断是否为开发模式
|
||||
// 主要依赖 app.isPackaged,如果为 false 则是开发环境
|
||||
// 或者检查路径是否包含 app.asar(打包后的应用)
|
||||
const isDevelopment = !app.isPackaged || process.env.NODE_ENV === 'development';
|
||||
|
||||
console.log('运行模式检测:');
|
||||
console.log(' - app.isPackaged:', app.isPackaged);
|
||||
console.log(' - process.resourcesPath:', process.resourcesPath);
|
||||
console.log(' - process.env.NODE_ENV:', process.env.NODE_ENV);
|
||||
console.log(' - isDevelopment:', isDevelopment);
|
||||
|
||||
/**
|
||||
* 创建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 (isDevelopment || process.env.NODE_ENV === 'development') {
|
||||
console.log('开发模式:打开开发者工具');
|
||||
mainWindow.webContents.openDevTools();
|
||||
|
||||
// 过滤掉 DevTools 的 Autofill 相关错误
|
||||
mainWindow.webContents.on('console-message', (event, level, message, line, sourceId) => {
|
||||
if (message.includes('Autofill.enable') || message.includes('Autofill.setAddresses')) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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() {
|
||||
let backendPath;
|
||||
|
||||
if (app.isPackaged) {
|
||||
// 生产环境:使用 app.asar.unpacked 中的文件
|
||||
backendPath = path.join(process.resourcesPath, 'app.asar.unpacked', 'dist', 'main.js');
|
||||
} else {
|
||||
// 开发环境:使用项目根目录下的 dist 文件夹
|
||||
backendPath = path.join(__dirname, '..', 'dist', 'main.js');
|
||||
}
|
||||
|
||||
// 检查后端构建文件是否存在
|
||||
if (!fs.existsSync(backendPath)) {
|
||||
console.error('后端服务构建文件不存在,路径:', backendPath);
|
||||
console.error('请先执行 npm run build');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('启动后端服务:', backendPath);
|
||||
|
||||
// 启动后端服务
|
||||
backendProcess = spawn('node', [backendPath], {
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: isDevelopment ? 'development' : (process.env.NODE_ENV || 'production'),
|
||||
// 设置编码环境变量(Windows)
|
||||
...(process.platform === 'win32' && {
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
LANG: 'en_US.UTF-8'
|
||||
}),
|
||||
},
|
||||
stdio: 'pipe',
|
||||
// Windows 上设置 shell 选项以确保编码正确
|
||||
...(process.platform === 'win32' && { shell: false }),
|
||||
});
|
||||
|
||||
// 捕获并显示后端进程的输出
|
||||
backendProcess.stdout.on('data', (data) => {
|
||||
// 确保正确解码 UTF-8 编码的数据
|
||||
const output = Buffer.isBuffer(data) ? data.toString('utf8') : data.toString();
|
||||
console.log(`[后端输出] ${output}`);
|
||||
});
|
||||
|
||||
backendProcess.stderr.on('data', (data) => {
|
||||
// 确保正确解码 UTF-8 编码的数据
|
||||
const output = Buffer.isBuffer(data) ? data.toString('utf8') : data.toString();
|
||||
console.error(`[后端错误] ${output}`);
|
||||
});
|
||||
|
||||
backendProcess.on('error', (error) => {
|
||||
console.error('后端服务启动失败:', error);
|
||||
});
|
||||
|
||||
let backendExited = false;
|
||||
backendProcess.on('exit', (code, signal) => {
|
||||
backendExited = true;
|
||||
if (code !== 0 && code !== null) {
|
||||
console.error(`后端服务异常退出,退出码: ${code}, 信号: ${signal}`);
|
||||
} else {
|
||||
console.log(`后端服务退出,退出码: ${code}`);
|
||||
}
|
||||
backendProcess = null;
|
||||
});
|
||||
|
||||
// 等待后端服务启动完成
|
||||
try {
|
||||
await waitForBackend();
|
||||
// 检查后端是否在等待期间就退出了
|
||||
if (backendExited) {
|
||||
throw new Error('后端服务在启动过程中退出');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('等待后端服务启动失败:', error.message);
|
||||
if (backendProcess) {
|
||||
console.error('正在停止后端进程...');
|
||||
backendProcess.kill();
|
||||
backendProcess = null;
|
||||
}
|
||||
throw error; // 重新抛出错误,让调用者知道启动失败
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止后端服务
|
||||
*/
|
||||
function stopBackend() {
|
||||
if (backendProcess) {
|
||||
console.log('正在停止后端服务...');
|
||||
backendProcess.kill();
|
||||
backendProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 应用就绪时启动后端服务,然后创建窗口
|
||||
app.on('ready', async () => {
|
||||
try {
|
||||
await startBackend();
|
||||
createWindow();
|
||||
Menu.setApplicationMenu(null);
|
||||
} catch (error) {
|
||||
console.error('应用启动失败:', error);
|
||||
// 显示错误对话框
|
||||
const { dialog } = require('electron');
|
||||
dialog.showErrorBox(
|
||||
'启动失败',
|
||||
`后端服务启动失败: ${error.message}\n\n请检查控制台输出以获取更多信息。`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 所有窗口关闭时退出应用
|
||||
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
13
app/package.json
Normal 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
14
app/preload.js
Normal 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),
|
||||
});
|
||||
@@ -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).
|
||||
@@ -64,7 +64,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
import api from './utils/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { DataBoard, Document, Setting, MagicStick, Connection } from '@element-plus/icons-vue'
|
||||
import Dashboard from './components/Dashboard.vue'
|
||||
@@ -89,7 +89,7 @@ const handleSelect = (key: string) => {
|
||||
const handleFetchBids = async (page: number, limit: number, source?: string) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await axios.get('/api/bids', {
|
||||
const res = await api.get('/api/bids', {
|
||||
params: {
|
||||
page,
|
||||
limit,
|
||||
@@ -109,16 +109,16 @@ const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [bidsRes, recentRes, kwRes, sourcesRes, statusRes] = await Promise.all([
|
||||
axios.get('/api/bids', {
|
||||
api.get('/api/bids', {
|
||||
params: {
|
||||
page: 1,
|
||||
limit: 10
|
||||
}
|
||||
}),
|
||||
axios.get('/api/bids/recent'),
|
||||
axios.get('/api/keywords'),
|
||||
axios.get('/api/bids/sources'),
|
||||
axios.get('/api/crawler/status')
|
||||
api.get('/api/bids/recent'),
|
||||
api.get('/api/keywords'),
|
||||
api.get('/api/bids/sources'),
|
||||
api.get('/api/crawler/status')
|
||||
])
|
||||
bids.value = bidsRes.data.items
|
||||
total.value = bidsRes.data.total
|
||||
@@ -145,7 +145,7 @@ const updateBidsByDateRange = async (startDate: string, endDate?: string, keywor
|
||||
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
|
||||
ElMessage.success(`更新成功,共 ${response.data.length} 条数据`)
|
||||
} catch (error) {
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
import api from '../utils/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Paperclip } from '@element-plus/icons-vue'
|
||||
|
||||
@@ -97,7 +97,7 @@ const handleSizeChange = (size: number) => {
|
||||
const togglePin = async (item: any) => {
|
||||
try {
|
||||
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
|
||||
ElMessage.success(newPinStatus ? '已置顶' : '已取消置顶')
|
||||
} catch (error) {
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="primary"
|
||||
@@ -75,7 +75,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
import api from '../utils/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
|
||||
@@ -118,7 +118,7 @@ const formatDate = (dateStr: string | null) => {
|
||||
const fetchCrawlStats = async () => {
|
||||
loading.value = true
|
||||
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
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch crawl stats:', error)
|
||||
@@ -132,7 +132,7 @@ const crawlSingleSource = async (sourceName: string) => {
|
||||
crawlingSources.value.add(sourceName)
|
||||
try {
|
||||
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) {
|
||||
ElMessage.success(`${sourceName} 更新成功,获取 ${res.data.count} 条数据`)
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import api from '../utils/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { MagicStick, Loading, InfoFilled, List, Paperclip } from '@element-plus/icons-vue'
|
||||
import PinnedProject from './PinnedProject.vue'
|
||||
@@ -175,11 +175,11 @@ watch(dateRange, (newDateRange) => {
|
||||
// 从数据库加载最新的 AI 推荐
|
||||
const loadLatestRecommendations = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/ai/latest-recommendations')
|
||||
const response = await api.get('/api/ai/latest-recommendations')
|
||||
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))
|
||||
|
||||
// 更新每个推荐项目的 pin 状态
|
||||
@@ -240,7 +240,7 @@ const fetchAIRecommendations = async () => {
|
||||
}))
|
||||
|
||||
// 调用后端 API
|
||||
const response = await axios.post('/api/ai/recommendations', {
|
||||
const response = await api.post('/api/ai/recommendations', {
|
||||
bids: bidsData
|
||||
})
|
||||
|
||||
@@ -267,7 +267,7 @@ const fetchAIRecommendations = async () => {
|
||||
aiRecommendations.value = recommendations
|
||||
|
||||
// 保存推荐结果到数据库
|
||||
await axios.post('/api/ai/save-recommendations', {
|
||||
await api.post('/api/ai/save-recommendations', {
|
||||
recommendations
|
||||
})
|
||||
|
||||
@@ -301,7 +301,7 @@ const fetchBidsByDateRange = async () => {
|
||||
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
|
||||
ElMessage.success(`获取成功,共 ${response.data.length} 个工程`)
|
||||
} catch (error: any) {
|
||||
@@ -344,7 +344,7 @@ const handlePinChanged = async (title: string) => {
|
||||
const togglePin = async (item: AIRecommendation) => {
|
||||
try {
|
||||
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
|
||||
ElMessage.success(newPinStatus ? '已置顶' : '已取消置顶')
|
||||
// 刷新 PinnedProject 组件的数据
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import api from '../utils/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh, Paperclip } from '@element-plus/icons-vue'
|
||||
import PinnedProject from './PinnedProject.vue'
|
||||
@@ -275,7 +275,7 @@ const handleCrawl = async () => {
|
||||
}
|
||||
crawling.value = true
|
||||
try {
|
||||
await axios.post('/api/crawler/run')
|
||||
await api.post('/api/crawler/run')
|
||||
ElMessage.success('Crawl completed successfully')
|
||||
emit('refresh') // Refresh data after crawl
|
||||
} catch (error) {
|
||||
@@ -301,7 +301,7 @@ const handlePinChanged = async (title: string) => {
|
||||
const togglePin = async (item: any) => {
|
||||
try {
|
||||
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
|
||||
ElMessage.success(newPinStatus ? '已置顶' : '已取消置顶')
|
||||
// 刷新 PinnedProject 组件的数据
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import axios from 'axios'
|
||||
import api from '../utils/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
interface Props {
|
||||
@@ -76,7 +76,7 @@ const handleAddKeyword = async () => {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await axios.post('/api/keywords', form)
|
||||
await api.post('/api/keywords', form)
|
||||
ElMessage.success('Keyword added')
|
||||
dialogVisible.value = false
|
||||
form.word = ''
|
||||
@@ -89,7 +89,7 @@ const handleAddKeyword = async () => {
|
||||
|
||||
const handleDeleteKeyword = async (id: string) => {
|
||||
try {
|
||||
await axios.delete(`/api/keywords/${id}`)
|
||||
await api.delete(`/api/keywords/${id}`)
|
||||
ElMessage.success('Keyword deleted')
|
||||
emit('refresh')
|
||||
} catch (error) {
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
import api from '../utils/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Loading, InfoFilled, Paperclip } from '@element-plus/icons-vue'
|
||||
|
||||
@@ -63,7 +63,7 @@ const pinnedLoading = ref(false)
|
||||
const loadPinnedBids = async () => {
|
||||
pinnedLoading.value = true
|
||||
try {
|
||||
const response = await axios.get('/api/bids/pinned')
|
||||
const response = await api.get('/api/bids/pinned')
|
||||
pinnedBids.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to load pinned bids:', error)
|
||||
@@ -75,7 +75,7 @@ const loadPinnedBids = async () => {
|
||||
// 切换置顶列表的 Pin 状态
|
||||
const togglePin = async (item: any) => {
|
||||
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)
|
||||
if (index !== -1) {
|
||||
pinnedBids.value.splice(index, 1)
|
||||
|
||||
34
frontend/src/utils/api.ts
Normal file
34
frontend/src/utils/api.ts
Normal 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
|
||||
@@ -3,6 +3,7 @@ import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
|
||||
11
package.json
11
package.json
@@ -5,6 +5,7 @@
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"main": "app/main.js",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
@@ -22,7 +23,9 @@
|
||||
"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",
|
||||
"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:dev": "chcp 65001 >nul 2>&1 & npm run -prefix frontend build && npm run build && set NODE_ENV=development && electron ./app",
|
||||
"electron:build": "npm run -prefix frontend build && npm run build && electron-builder --config ./app/electron-builder.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
@@ -53,11 +56,17 @@
|
||||
"@nestjs/cli": "^11.0.14",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/cacheable-request": "^6.0.3",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/http-cache-semantics": "^4.0.4",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/responselike": "^1.0.3",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"concurrently": "^9.2.1",
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^26.4.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
|
||||
@@ -15,7 +15,7 @@ interface SourceResult {
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface CrawlInfoAddStats {
|
||||
export interface CrawlInfoAddStats {
|
||||
source: string;
|
||||
count: number;
|
||||
latestUpdate: Date | string;
|
||||
|
||||
@@ -60,7 +60,6 @@ const appLogTransport = new DailyRotateFile({
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '20m',
|
||||
maxFiles: '30d',
|
||||
format: logFormat,
|
||||
});
|
||||
|
||||
// 错误日志传输(按天轮转)
|
||||
@@ -68,16 +67,15 @@ const errorLogTransport = new DailyRotateFile({
|
||||
dirname: logDir,
|
||||
filename: 'error-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
level: 'error',
|
||||
maxSize: '20m',
|
||||
maxFiles: '30d',
|
||||
format: logFormat,
|
||||
level: 'error',
|
||||
});
|
||||
|
||||
// 创建 winston logger 实例
|
||||
export const winstonLogger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: logFormat,
|
||||
transports: [consoleTransport, appLogTransport, errorLogTransport],
|
||||
transports: [consoleTransport, appLogTransport as any, errorLogTransport as any],
|
||||
exitOnError: false,
|
||||
});
|
||||
|
||||
@@ -24,10 +24,19 @@ interface CrawlResult {
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface Crawler {
|
||||
name: string;
|
||||
crawl(browser: puppeteer.Browser): Promise<CrawlResult[]>;
|
||||
}
|
||||
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()
|
||||
export class BidCrawlerService {
|
||||
@@ -50,7 +59,7 @@ export class BidCrawlerService {
|
||||
const crawlResults: Record<string, { success: number; error?: string }> =
|
||||
{};
|
||||
// 记录数据为0的爬虫,用于重试
|
||||
const zeroDataCrawlers: Crawler[] = [];
|
||||
const zeroDataCrawlers: AnyCrawler[] = [];
|
||||
// 从环境变量读取代理配置
|
||||
const proxyHost = this.configService.get<string>('PROXY_HOST');
|
||||
const proxyPort = this.configService.get<string>('PROXY_PORT');
|
||||
@@ -114,7 +123,7 @@ export class BidCrawlerService {
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await crawler.crawl(browser);
|
||||
const results = await (crawler as any).crawl(browser);
|
||||
this.logger.log(
|
||||
`Extracted ${results.length} items from ${crawler.name}`,
|
||||
);
|
||||
@@ -184,7 +193,7 @@ export class BidCrawlerService {
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await crawler.crawl(browser);
|
||||
const results = await (crawler as any).crawl(browser);
|
||||
this.logger.log(
|
||||
`Retry extracted ${results.length} items from ${crawler.name}`,
|
||||
);
|
||||
@@ -337,7 +346,7 @@ export class BidCrawlerService {
|
||||
try {
|
||||
this.logger.log(`Crawling: ${targetCrawler.name}`);
|
||||
|
||||
const results = await targetCrawler.crawl(browser);
|
||||
const results = await (targetCrawler as any).crawl(browser);
|
||||
this.logger.log(
|
||||
`Extracted ${results.length} items from ${targetCrawler.name}`,
|
||||
);
|
||||
|
||||
@@ -46,6 +46,56 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
// 检查错误是否为代理隧道连接失败
|
||||
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 延迟重试函数
|
||||
async function delayRetry(
|
||||
operation: () => Promise<void>,
|
||||
maxRetries: number = 3,
|
||||
delayMs: number = 5000,
|
||||
logger?: Logger,
|
||||
): Promise<void> {
|
||||
let lastError: Error | unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await operation();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (isTunnelConnectionFailedError(error)) {
|
||||
if (attempt < maxRetries) {
|
||||
const delay = delayMs * attempt; // 递增延迟
|
||||
logger?.warn(
|
||||
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
} else {
|
||||
logger?.error(
|
||||
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// 非代理错误,直接抛出
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export interface CdtResult {
|
||||
title: string;
|
||||
publishDate: Date;
|
||||
@@ -87,7 +137,14 @@ export const CdtCrawler = {
|
||||
|
||||
try {
|
||||
logger.log(`Navigating to ${this.url}...`);
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
await delayRetry(
|
||||
async () => {
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
},
|
||||
3,
|
||||
5000,
|
||||
logger,
|
||||
);
|
||||
|
||||
// 模拟人类行为
|
||||
logger.log('Simulating human mouse movements...');
|
||||
@@ -280,7 +337,7 @@ export const CdtCrawler = {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Failed to crawl ${this.name}: ${errorMessage}`);
|
||||
return allResults;
|
||||
throw error;
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
|
||||
@@ -47,6 +47,56 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
// 检查错误是否为代理隧道连接失败
|
||||
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 延迟重试函数
|
||||
async function delayRetry(
|
||||
operation: () => Promise<void>,
|
||||
maxRetries: number = 3,
|
||||
delayMs: number = 5000,
|
||||
logger?: Logger,
|
||||
): Promise<void> {
|
||||
let lastError: Error | unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await operation();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (isTunnelConnectionFailedError(error)) {
|
||||
if (attempt < maxRetries) {
|
||||
const delay = delayMs * attempt; // 递增延迟
|
||||
logger?.warn(
|
||||
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
} else {
|
||||
logger?.error(
|
||||
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// 非代理错误,直接抛出
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
interface CeicCrawlerType {
|
||||
name: string;
|
||||
url: string;
|
||||
@@ -90,7 +140,14 @@ export const CeicCrawler = {
|
||||
|
||||
try {
|
||||
logger.log(`Navigating to ${this.url}...`);
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
await delayRetry(
|
||||
async () => {
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
},
|
||||
3,
|
||||
5000,
|
||||
logger,
|
||||
);
|
||||
|
||||
// 模拟人类行为
|
||||
logger.log('Simulating human mouse movements...');
|
||||
@@ -185,7 +242,7 @@ export const CeicCrawler = {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Crawl failed: ${errorMessage}`);
|
||||
return allResults;
|
||||
throw error;
|
||||
} finally {
|
||||
if (page) await page.close();
|
||||
}
|
||||
|
||||
@@ -46,6 +46,56 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
// 检查错误是否为代理隧道连接失败
|
||||
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 延迟重试函数
|
||||
async function delayRetry(
|
||||
operation: () => Promise<void>,
|
||||
maxRetries: number = 3,
|
||||
delayMs: number = 5000,
|
||||
logger?: Logger,
|
||||
): Promise<void> {
|
||||
let lastError: Error | unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await operation();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (isTunnelConnectionFailedError(error)) {
|
||||
if (attempt < maxRetries) {
|
||||
const delay = delayMs * attempt; // 递增延迟
|
||||
logger?.warn(
|
||||
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
} else {
|
||||
logger?.error(
|
||||
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// 非代理错误,直接抛出
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export interface CgnpcResult {
|
||||
title: string;
|
||||
publishDate: Date;
|
||||
@@ -96,7 +146,14 @@ export const CgnpcCrawler = {
|
||||
|
||||
try {
|
||||
logger.log(`Navigating to ${this.url}...`);
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
await delayRetry(
|
||||
async () => {
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
},
|
||||
3,
|
||||
5000,
|
||||
logger,
|
||||
);
|
||||
|
||||
// 模拟人类行为
|
||||
logger.log('Simulating human mouse movements...');
|
||||
@@ -172,7 +229,7 @@ export const CgnpcCrawler = {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Failed to crawl ${this.name}: ${errorMessage}`);
|
||||
return allResults;
|
||||
throw error;
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,56 @@ interface ChdtpCrawlerType {
|
||||
extract(html: string): ChdtpResult[];
|
||||
}
|
||||
|
||||
// 检查错误是否为代理隧道连接失败
|
||||
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 延迟重试函数
|
||||
async function delayRetry(
|
||||
operation: () => Promise<void>,
|
||||
maxRetries: number = 3,
|
||||
delayMs: number = 5000,
|
||||
logger?: Logger,
|
||||
): Promise<void> {
|
||||
let lastError: Error | unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await operation();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (isTunnelConnectionFailedError(error)) {
|
||||
if (attempt < maxRetries) {
|
||||
const delay = delayMs * attempt; // 递增延迟
|
||||
logger?.warn(
|
||||
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
} else {
|
||||
logger?.error(
|
||||
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// 非代理错误,直接抛出
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export const ChdtpCrawler = {
|
||||
name: '华电集团电子商务平台 ',
|
||||
url: 'https://www.chdtp.com/webs/queryWebZbgg.action?zbggType=1',
|
||||
@@ -42,7 +92,14 @@ export const ChdtpCrawler = {
|
||||
|
||||
try {
|
||||
logger.log(`Navigating to ${this.url}...`);
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
await delayRetry(
|
||||
async () => {
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
},
|
||||
3,
|
||||
5000,
|
||||
logger,
|
||||
);
|
||||
|
||||
while (currentPage <= maxPages) {
|
||||
const content = await page.content();
|
||||
@@ -102,7 +159,7 @@ export const ChdtpCrawler = {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Failed to crawl ${this.name}: ${errorMessage}`);
|
||||
return allResults; // Return what we have so far
|
||||
throw error;
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
|
||||
@@ -71,6 +71,56 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查错误是否为代理隧道连接失败
|
||||
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 延迟重试函数
|
||||
async function delayRetry(
|
||||
operation: () => Promise<void>,
|
||||
maxRetries: number = 3,
|
||||
delayMs: number = 5000,
|
||||
logger?: Logger,
|
||||
): Promise<void> {
|
||||
let lastError: Error | unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await operation();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (isTunnelConnectionFailedError(error)) {
|
||||
if (attempt < maxRetries) {
|
||||
const delay = delayMs * attempt; // 递增延迟
|
||||
logger?.warn(
|
||||
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
} else {
|
||||
logger?.error(
|
||||
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// 非代理错误,直接抛出
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
interface ChngCrawlerType {
|
||||
name: string;
|
||||
url: string;
|
||||
@@ -115,7 +165,14 @@ export const ChngCrawler = {
|
||||
|
||||
try {
|
||||
logger.log('Navigating to Bing...');
|
||||
await page.goto('https://cn.bing.com', { waitUntil: 'networkidle2' });
|
||||
await delayRetry(
|
||||
async () => {
|
||||
await page.goto('https://cn.bing.com', { waitUntil: 'networkidle2' });
|
||||
},
|
||||
3,
|
||||
5000,
|
||||
logger,
|
||||
);
|
||||
|
||||
logger.log('Searching for target site...');
|
||||
const searchBoxSelector = 'input[name="q"]';
|
||||
@@ -309,7 +366,7 @@ export const ChngCrawler = {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Crawl failed: ${errorMessage}`);
|
||||
return allResults;
|
||||
throw error;
|
||||
} finally {
|
||||
if (page) await page.close();
|
||||
}
|
||||
|
||||
@@ -46,6 +46,56 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
// 检查错误是否为代理隧道连接失败
|
||||
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 延迟重试函数
|
||||
async function delayRetry(
|
||||
operation: () => Promise<void>,
|
||||
maxRetries: number = 3,
|
||||
delayMs: number = 5000,
|
||||
logger?: Logger,
|
||||
): Promise<void> {
|
||||
let lastError: Error | unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await operation();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (isTunnelConnectionFailedError(error)) {
|
||||
if (attempt < maxRetries) {
|
||||
const delay = delayMs * attempt; // 递增延迟
|
||||
logger?.warn(
|
||||
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
} else {
|
||||
logger?.error(
|
||||
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// 非代理错误,直接抛出
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export interface CnncecpResult {
|
||||
title: string;
|
||||
publishDate: Date;
|
||||
@@ -96,7 +146,14 @@ export const CnncecpCrawler = {
|
||||
|
||||
try {
|
||||
logger.log(`Navigating to ${this.url}...`);
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
await delayRetry(
|
||||
async () => {
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
},
|
||||
3,
|
||||
5000,
|
||||
logger,
|
||||
);
|
||||
|
||||
// 模拟人类行为
|
||||
logger.log('Simulating human mouse movements...');
|
||||
@@ -171,7 +228,7 @@ export const CnncecpCrawler = {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Failed to crawl ${this.name}: ${errorMessage}`);
|
||||
return allResults;
|
||||
throw error;
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
|
||||
@@ -46,6 +46,56 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
// 检查错误是否为代理隧道连接失败
|
||||
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 延迟重试函数
|
||||
async function delayRetry(
|
||||
operation: () => Promise<void>,
|
||||
maxRetries: number = 3,
|
||||
delayMs: number = 5000,
|
||||
logger?: Logger,
|
||||
): Promise<void> {
|
||||
let lastError: Error | unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await operation();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (isTunnelConnectionFailedError(error)) {
|
||||
if (attempt < maxRetries) {
|
||||
const delay = delayMs * attempt; // 递增延迟
|
||||
logger?.warn(
|
||||
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
} else {
|
||||
logger?.error(
|
||||
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// 非代理错误,直接抛出
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export interface CnoocResult {
|
||||
title: string;
|
||||
publishDate: Date;
|
||||
@@ -96,7 +146,14 @@ export const CnoocCrawler = {
|
||||
|
||||
try {
|
||||
logger.log(`Navigating to ${this.url}...`);
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
await delayRetry(
|
||||
async () => {
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
},
|
||||
3,
|
||||
5000,
|
||||
logger,
|
||||
);
|
||||
|
||||
// 模拟人类行为
|
||||
logger.log('Simulating human mouse movements...');
|
||||
@@ -172,7 +229,7 @@ export const CnoocCrawler = {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Failed to crawl ${this.name}: ${errorMessage}`);
|
||||
return allResults;
|
||||
throw error;
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
|
||||
@@ -46,6 +46,56 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
// 检查错误是否为代理隧道连接失败
|
||||
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 延迟重试函数
|
||||
async function delayRetry(
|
||||
operation: () => Promise<void>,
|
||||
maxRetries: number = 3,
|
||||
delayMs: number = 5000,
|
||||
logger?: Logger,
|
||||
): Promise<void> {
|
||||
let lastError: Error | unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await operation();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (isTunnelConnectionFailedError(error)) {
|
||||
if (attempt < maxRetries) {
|
||||
const delay = delayMs * attempt; // 递增延迟
|
||||
logger?.warn(
|
||||
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
} else {
|
||||
logger?.error(
|
||||
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// 非代理错误,直接抛出
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export interface EpsResult {
|
||||
title: string;
|
||||
publishDate: Date;
|
||||
@@ -96,7 +146,14 @@ export const EpsCrawler = {
|
||||
|
||||
try {
|
||||
logger.log(`Navigating to ${this.url}...`);
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
await delayRetry(
|
||||
async () => {
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
},
|
||||
3,
|
||||
5000,
|
||||
logger,
|
||||
);
|
||||
|
||||
// 模拟人类行为
|
||||
logger.log('Simulating human mouse movements...');
|
||||
@@ -172,7 +229,7 @@ export const EpsCrawler = {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Failed to crawl ${this.name}: ${errorMessage}`);
|
||||
return allResults;
|
||||
throw error;
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
|
||||
@@ -46,6 +46,56 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
// 检查错误是否为代理隧道连接失败
|
||||
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 延迟重试函数
|
||||
async function delayRetry(
|
||||
operation: () => Promise<void>,
|
||||
maxRetries: number = 3,
|
||||
delayMs: number = 5000,
|
||||
logger?: Logger,
|
||||
): Promise<void> {
|
||||
let lastError: Error | unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await operation();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (isTunnelConnectionFailedError(error)) {
|
||||
if (attempt < maxRetries) {
|
||||
const delay = delayMs * attempt; // 递增延迟
|
||||
logger?.warn(
|
||||
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
} else {
|
||||
logger?.error(
|
||||
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// 非代理错误,直接抛出
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export interface EspicResult {
|
||||
title: string;
|
||||
publishDate: Date;
|
||||
@@ -106,7 +156,14 @@ export const EspicCrawler = {
|
||||
try {
|
||||
const url = this.getUrl(currentPage);
|
||||
logger.log(`Navigating to ${url}...`);
|
||||
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
await delayRetry(
|
||||
async () => {
|
||||
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
},
|
||||
3,
|
||||
5000,
|
||||
logger,
|
||||
);
|
||||
|
||||
// 等待 WAF 验证通过
|
||||
logger.log('Waiting for WAF verification...');
|
||||
@@ -212,7 +269,7 @@ export const EspicCrawler = {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Failed to crawl ${this.name}: ${errorMessage}`);
|
||||
return allResults;
|
||||
throw error;
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
|
||||
@@ -46,6 +46,56 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
// 检查错误是否为代理隧道连接失败
|
||||
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 延迟重试函数
|
||||
async function delayRetry(
|
||||
operation: () => Promise<void>,
|
||||
maxRetries: number = 3,
|
||||
delayMs: number = 5000,
|
||||
logger?: Logger,
|
||||
): Promise<void> {
|
||||
let lastError: Error | unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await operation();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (isTunnelConnectionFailedError(error)) {
|
||||
if (attempt < maxRetries) {
|
||||
const delay = delayMs * attempt; // 递增延迟
|
||||
logger?.warn(
|
||||
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
} else {
|
||||
logger?.error(
|
||||
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// 非代理错误,直接抛出
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export interface PowerbeijingResult {
|
||||
title: string;
|
||||
publishDate: Date;
|
||||
@@ -96,7 +146,14 @@ export const PowerbeijingCrawler = {
|
||||
|
||||
try {
|
||||
logger.log(`Navigating to ${this.url}...`);
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
await delayRetry(
|
||||
async () => {
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
},
|
||||
3,
|
||||
5000,
|
||||
logger,
|
||||
);
|
||||
|
||||
// 模拟人类行为
|
||||
logger.log('Simulating human mouse movements...');
|
||||
@@ -172,7 +229,7 @@ export const PowerbeijingCrawler = {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Failed to crawl ${this.name}: ${errorMessage}`);
|
||||
return allResults;
|
||||
throw error;
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
|
||||
@@ -46,6 +46,56 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
// 检查错误是否为代理隧道连接失败
|
||||
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 延迟重试函数
|
||||
async function delayRetry(
|
||||
operation: () => Promise<void>,
|
||||
maxRetries: number = 3,
|
||||
delayMs: number = 5000,
|
||||
logger?: Logger,
|
||||
): Promise<void> {
|
||||
let lastError: Error | unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await operation();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (isTunnelConnectionFailedError(error)) {
|
||||
if (attempt < maxRetries) {
|
||||
const delay = delayMs * attempt; // 递增延迟
|
||||
logger?.warn(
|
||||
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
} else {
|
||||
logger?.error(
|
||||
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// 非代理错误,直接抛出
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export interface SdiccResult {
|
||||
title: string;
|
||||
publishDate: Date;
|
||||
@@ -96,7 +146,14 @@ export const SdiccCrawler = {
|
||||
|
||||
try {
|
||||
logger.log(`Navigating to ${this.url}...`);
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
await delayRetry(
|
||||
async () => {
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
},
|
||||
3,
|
||||
5000,
|
||||
logger,
|
||||
);
|
||||
|
||||
// 模拟人类行为
|
||||
logger.log('Simulating human mouse movements...');
|
||||
@@ -182,7 +239,7 @@ export const SdiccCrawler = {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Failed to crawl ${this.name}: ${errorMessage}`);
|
||||
return allResults;
|
||||
throw error;
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
|
||||
@@ -47,6 +47,56 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
// 检查错误是否为代理隧道连接失败
|
||||
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 延迟重试函数
|
||||
async function delayRetry(
|
||||
operation: () => Promise<void>,
|
||||
maxRetries: number = 3,
|
||||
delayMs: number = 5000,
|
||||
logger?: Logger,
|
||||
): Promise<void> {
|
||||
let lastError: Error | unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await operation();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (isTunnelConnectionFailedError(error)) {
|
||||
if (attempt < maxRetries) {
|
||||
const delay = delayMs * attempt; // 递增延迟
|
||||
logger?.warn(
|
||||
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
} else {
|
||||
logger?.error(
|
||||
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// 非代理错误,直接抛出
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
interface SzecpCrawlerType {
|
||||
name: string;
|
||||
url: string;
|
||||
@@ -90,7 +140,14 @@ export const SzecpCrawler = {
|
||||
|
||||
try {
|
||||
logger.log(`Navigating to ${this.url}...`);
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
await delayRetry(
|
||||
async () => {
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
},
|
||||
3,
|
||||
5000,
|
||||
logger,
|
||||
);
|
||||
|
||||
// 模拟人类行为
|
||||
logger.log('Simulating human mouse movements...');
|
||||
@@ -191,7 +248,7 @@ export const SzecpCrawler = {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Crawl failed: ${errorMessage}`);
|
||||
return allResults;
|
||||
throw error;
|
||||
} finally {
|
||||
if (page) await page.close();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
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({
|
||||
imports: [
|
||||
@@ -22,7 +18,7 @@ import { CrawlInfoAdd } from '../crawler/entities/crawl-info-add.entity';
|
||||
username: configService.get<string>('DATABASE_USERNAME', 'root'),
|
||||
password: configService.get<string>('DATABASE_PASSWORD', 'root'),
|
||||
database: configService.get<string>('DATABASE_NAME', 'bidding'),
|
||||
entities: [BidItem, Keyword, AiRecommendation, CrawlInfoAdd],
|
||||
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
|
||||
synchronize: false,
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolvePackageJsonExports": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
|
||||
8
uni-app-version/.env
Normal file
8
uni-app-version/.env
Normal file
@@ -0,0 +1,8 @@
|
||||
# API 配置
|
||||
VITE_API_BASE_URL=http://localhost:3000
|
||||
|
||||
# 应用配置
|
||||
VITE_APP_TITLE=投标项目查看器`nVITE_APP_VERSION=1.0.0
|
||||
|
||||
# 刷新间隔(毫秒)
|
||||
VITE_AUTO_REFRESH_INTERVAL=300000
|
||||
2
uni-app-version/.env.development
Normal file
2
uni-app-version/.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
# 撘<><E69298>𤑳㴓憓<E3B493><E68693>蝵害nVITE_API_BASE_URL=http://localhost:3000
|
||||
VITE_APP_TITLE=<3D>閙<EFBFBD>憿寧𤌍<E5AFA7>亦<EFBFBD><E4BAA6>?撘<><E69298>?
|
||||
3
uni-app-version/.npmrc
Normal file
3
uni-app-version/.npmrc
Normal file
@@ -0,0 +1,3 @@
|
||||
@
|
||||
legacy-peer-deps=true
|
||||
strict-peer-dependencies=false
|
||||
17
uni-app-version/index.html
Normal file
17
uni-app-version/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<script>
|
||||
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(safe-area-inset-top)') || CSS.supports('top: constant(safe-area-inset-top)'))
|
||||
document.write('<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' + (coverSupport ? ', viewport-fit=cover' : '') + '" />')
|
||||
</script>
|
||||
<title>投标项目查看器</title>
|
||||
<!--preload-links-->
|
||||
<!--app-context-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"><!--app-html--></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
45
uni-app-version/package.json
Normal file
45
uni-app-version/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "bidding-looker-uniapp",
|
||||
"version": "1.0.0",
|
||||
"description": "投标项目查看器 - uni-app 版本",
|
||||
"main": "main.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev:h5": "uni",
|
||||
"build:h5": "uni build",
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"build:mp-weixin": "uni build -p mp-weixin",
|
||||
"dev:app": "uni -p app",
|
||||
"build:app": "uni build -p app",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"keywords": [
|
||||
"uni-app",
|
||||
"bidding",
|
||||
"looker",
|
||||
"typescript",
|
||||
"tailwindcss"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vue": "^3.4.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dcloudio/types": "^3.4.8",
|
||||
"@dcloudio/uni-app": "3.0.0-alpha-4020920240929001",
|
||||
"@dcloudio/uni-app-plus": "3.0.0-alpha-4020920240929001",
|
||||
"@dcloudio/uni-components": "3.0.0-alpha-4020920240929001",
|
||||
"@dcloudio/uni-h5": "3.0.0-alpha-4020920240929001",
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-4020920240929001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4020920240929001",
|
||||
"@types/node": "^20.14.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"sass": "^1.77.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "5.2.13",
|
||||
"vue-tsc": "^2.0.0"
|
||||
}
|
||||
}
|
||||
6
uni-app-version/postcss.config.js
Normal file
6
uni-app-version/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
36
uni-app-version/src/App.vue
Normal file
36
uni-app-version/src/App.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
export default {
|
||||
onLaunch: function() {
|
||||
console.log('App Launch')
|
||||
console.log('API Base URL:', import.meta.env.VITE_API_BASE_URL)
|
||||
},
|
||||
onShow: function() {
|
||||
console.log('App Show')
|
||||
},
|
||||
onHide: function() {
|
||||
console.log('App Hide')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
/* 引入 Tailwind CSS */
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
|
||||
/* 引入全局样式 */
|
||||
@import './uni.scss';
|
||||
|
||||
/* 全局样式 */
|
||||
page {
|
||||
background-color: #f5f5f5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
/* 重置样式 */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
144
uni-app-version/src/components/AiRecommendations.vue
Normal file
144
uni-app-version/src/components/AiRecommendations.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getAiRecommendations, type AiRecommendation } from '../utils/api'
|
||||
|
||||
const recommendations = ref<AiRecommendation[]>([])
|
||||
const loading = ref<boolean>(true)
|
||||
const error = ref<string>('')
|
||||
|
||||
const loadRecommendations = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
const items = await getAiRecommendations()
|
||||
recommendations.value = items || []
|
||||
} catch (err) {
|
||||
error.value = `加载失败: ${err.message || err}`
|
||||
console.error('加载 AI 推荐失败:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getConfidenceColor = (confidence: number): string => {
|
||||
if (confidence >= 80) return '#27ae60'
|
||||
if (confidence >= 60) return '#f39c12'
|
||||
return '#e74c3c'
|
||||
}
|
||||
|
||||
const getConfidenceLabel = (confidence: number): string => {
|
||||
if (confidence >= 80) return '高'
|
||||
if (confidence >= 60) return '中'
|
||||
return '低'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRecommendations()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
loadRecommendations
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="ai-recommendations-container">
|
||||
<view v-if="loading" class="loading">
|
||||
加载中...
|
||||
</view>
|
||||
|
||||
<view v-else-if="error" class="error">
|
||||
{{ error }}
|
||||
</view>
|
||||
|
||||
<view v-else-if="recommendations.length === 0" class="empty">
|
||||
暂无推荐项目
|
||||
</view>
|
||||
|
||||
<view v-else class="recommendation-list">
|
||||
<view
|
||||
v-for="item in recommendations"
|
||||
:key="item.id"
|
||||
class="recommendation-item"
|
||||
>
|
||||
<view class="recommendation-header">
|
||||
<view
|
||||
class="confidence-badge"
|
||||
:style="{ backgroundColor: getConfidenceColor(item.confidence) }"
|
||||
>
|
||||
{{ getConfidenceLabel(item.confidence) }} ({{ item.confidence }}%)
|
||||
</view>
|
||||
<text class="date">{{ item.createdAt }}</text>
|
||||
</view>
|
||||
<text class="recommendation-title">{{ item.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-recommendations-container {
|
||||
padding: 16rpx;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 40rpx;
|
||||
color: #666;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.recommendation-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.recommendation-item {
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8rpx;
|
||||
padding: 16rpx;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.recommendation-item:active {
|
||||
border-color: #3498db;
|
||||
box-shadow: 0 2rpx 8rpx rgba(52, 152, 219, 0.15);
|
||||
transform: translateY(-2rpx);
|
||||
}
|
||||
|
||||
.recommendation-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.confidence-badge {
|
||||
color: #fff;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 6rpx;
|
||||
font-size: 20rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 20rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.recommendation-title {
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
148
uni-app-version/src/components/CrawlInfo.vue
Normal file
148
uni-app-version/src/components/CrawlInfo.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getCrawlInfoStats, type CrawlInfoStat } from '../utils/api'
|
||||
|
||||
const crawlStats = ref<CrawlInfoStat[]>([])
|
||||
const loading = ref<boolean>(true)
|
||||
const error = ref<string>('')
|
||||
|
||||
const loadCrawlStats = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
const stats = await getCrawlInfoStats()
|
||||
crawlStats.value = stats || []
|
||||
} catch (err) {
|
||||
error.value = `加载失败: ${err.message || err}`
|
||||
console.error('加载爬虫统计信息失败:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (stat: CrawlInfoStat): string => {
|
||||
if (stat.error) {
|
||||
return '出错'
|
||||
}
|
||||
if (stat.count > 0) {
|
||||
return '正常'
|
||||
}
|
||||
return '无数据'
|
||||
}
|
||||
|
||||
const getStatusClass = (stat: CrawlInfoStat): string => {
|
||||
if (stat.error) {
|
||||
return 'status-error'
|
||||
}
|
||||
if (stat.count > 0) {
|
||||
return 'status-success'
|
||||
}
|
||||
return 'status-info'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCrawlStats()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
loadCrawlStats
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="crawl-info-container">
|
||||
<view v-if="loading" class="loading">
|
||||
加载中...
|
||||
</view>
|
||||
|
||||
<view v-else-if="error" class="error">
|
||||
{{ error }}
|
||||
</view>
|
||||
|
||||
<view v-else-if="crawlStats.length === 0" class="empty">
|
||||
暂无爬虫统计信息
|
||||
</view>
|
||||
|
||||
<view v-else class="crawl-list">
|
||||
<view
|
||||
v-for="stat in crawlStats"
|
||||
:key="stat.source"
|
||||
class="crawl-item"
|
||||
>
|
||||
<text class="source">{{ stat.source }}</text>
|
||||
<view :class="['status', getStatusClass(stat)]">
|
||||
{{ getStatusText(stat) }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.crawl-info-container {
|
||||
padding: 16rpx;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 40rpx;
|
||||
color: #666;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.crawl-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.crawl-item {
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8rpx;
|
||||
padding: 16rpx 24rpx;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.crawl-item:active {
|
||||
border-color: #3498db;
|
||||
box-shadow: 0 2rpx 8rpx rgba(52, 152, 219, 0.15);
|
||||
}
|
||||
|
||||
.source {
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 22rpx;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 6rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
background: #e2e3e5;
|
||||
color: #383d41;
|
||||
}
|
||||
</style>
|
||||
147
uni-app-version/src/components/PinnedBids.vue
Normal file
147
uni-app-version/src/components/PinnedBids.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getPinnedBids, type BidItem } from '../utils/api'
|
||||
|
||||
const bidItems = ref<BidItem[]>([])
|
||||
const loading = ref<boolean>(true)
|
||||
const error = ref<string>('')
|
||||
|
||||
const loadPinnedBids = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
const items = await getPinnedBids()
|
||||
bidItems.value = items || []
|
||||
} catch (err) {
|
||||
error.value = `加载失败: ${err.message || err}`
|
||||
console.error('加载置顶投标项目失败:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openUrl = (url: string) => {
|
||||
// #ifdef H5
|
||||
window.open(url, '_blank')
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
// 在非 H5 平台,可以使用 web-view 或复制链接
|
||||
uni.setClipboardData({
|
||||
data: url,
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: '链接已复制',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPinnedBids()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
loadPinnedBids
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="pinned-bids-container">
|
||||
<view v-if="loading" class="loading">
|
||||
加载中...
|
||||
</view>
|
||||
|
||||
<view v-else-if="error" class="error">
|
||||
{{ error }}
|
||||
</view>
|
||||
|
||||
<view v-else-if="bidItems.length === 0" class="empty">
|
||||
暂无置顶项目
|
||||
</view>
|
||||
|
||||
<view v-else class="bid-list">
|
||||
<view
|
||||
v-for="item in bidItems"
|
||||
:key="item.id"
|
||||
class="bid-item"
|
||||
@click="openUrl(item.url)"
|
||||
>
|
||||
<view class="bid-header">
|
||||
<text class="source">{{ item.source }}</text>
|
||||
<text class="date">{{ item.publishDate }}</text>
|
||||
</view>
|
||||
<text class="bid-title">{{ item.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pinned-bids-container {
|
||||
padding: 16rpx;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 40rpx;
|
||||
color: #666;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.bid-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.bid-item {
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8rpx;
|
||||
padding: 16rpx;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bid-item:active {
|
||||
border-color: #3498db;
|
||||
box-shadow: 0 2rpx 8rpx rgba(52, 152, 219, 0.15);
|
||||
transform: translateY(-2rpx);
|
||||
}
|
||||
|
||||
.bid-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.source {
|
||||
background: #f0f0f0;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 6rpx;
|
||||
font-size: 20rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 20rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.bid-title {
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
18
uni-app-version/src/env.d.ts
vendored
Normal file
18
uni-app-version/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_APP_TITLE: string
|
||||
readonly VITE_APP_VERSION: string
|
||||
readonly VITE_AUTO_REFRESH_INTERVAL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
9
uni-app-version/src/main.ts
Normal file
9
uni-app-version/src/main.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
return {
|
||||
app
|
||||
}
|
||||
}
|
||||
73
uni-app-version/src/manifest.json
Normal file
73
uni-app-version/src/manifest.json
Normal file
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"name": "bidding-looker",
|
||||
"appid": "__UNI__BIDDING_LOOKER",
|
||||
"description": "投标项目查看器",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": "100",
|
||||
"transformPx": false,
|
||||
"app-plus": {
|
||||
"usingComponents": true,
|
||||
"nvueStyleCompiler": "uni-app",
|
||||
"compilerVersion": 3,
|
||||
"splashscreen": {
|
||||
"alwaysShowBeforeRender": true,
|
||||
"waiting": true,
|
||||
"autoclose": true,
|
||||
"delay": 0
|
||||
},
|
||||
"modules": {},
|
||||
"distribute": {
|
||||
"android": {
|
||||
"permissions": [
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
|
||||
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
|
||||
]
|
||||
},
|
||||
"ios": {},
|
||||
"sdkConfigs": {}
|
||||
}
|
||||
},
|
||||
"quickapp": {},
|
||||
"mp-weixin": {
|
||||
"appid": "",
|
||||
"setting": {
|
||||
"urlCheck": false
|
||||
},
|
||||
"usingComponents": true
|
||||
},
|
||||
"mp-alipay": {
|
||||
"usingComponents": true
|
||||
},
|
||||
"mp-baidu": {
|
||||
"usingComponents": true
|
||||
},
|
||||
"mp-toutiao": {
|
||||
"usingComponents": true
|
||||
},
|
||||
"uniStatistics": {
|
||||
"enable": false
|
||||
},
|
||||
"vueVersion": "3",
|
||||
"h5": {
|
||||
"router": {
|
||||
"mode": "hash",
|
||||
"base": "/"
|
||||
},
|
||||
"devServer": {
|
||||
"port": 8080,
|
||||
"disableHostCheck": true
|
||||
}
|
||||
}
|
||||
}
|
||||
26
uni-app-version/src/pages.json
Normal file
26
uni-app-version/src/pages.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"easycom": {
|
||||
"autoscan": true,
|
||||
"custom": {
|
||||
"^u-(.*)": "uview-ui/components/u-$1/u-$1.vue"
|
||||
}
|
||||
},
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "投标项目查看器",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "投标项目查看器",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"backgroundColor": "#f5f5f5"
|
||||
},
|
||||
"tabBar": {}
|
||||
}
|
||||
194
uni-app-version/src/pages/index/index.vue
Normal file
194
uni-app-version/src/pages/index/index.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import PinnedBids from '../../components/PinnedBids.vue'
|
||||
import AiRecommendations from '../../components/AiRecommendations.vue'
|
||||
import CrawlInfo from '../../components/CrawlInfo.vue'
|
||||
|
||||
type TabId = 'pinned' | 'ai' | 'status'
|
||||
|
||||
interface Tab {
|
||||
id: TabId
|
||||
label: string
|
||||
}
|
||||
|
||||
const activeTab = ref<TabId>('pinned')
|
||||
const pinnedBidsRef = ref<InstanceType<typeof PinnedBids> | null>(null)
|
||||
const aiRecommendationsRef = ref<InstanceType<typeof AiRecommendations> | null>(null)
|
||||
const crawlInfoRef = ref<InstanceType<typeof CrawlInfo> | null>(null)
|
||||
let refreshTimer: number | null = null
|
||||
const showToast = ref<boolean>(false)
|
||||
const toastMessage = ref<string>('')
|
||||
|
||||
// 从环境变量读取自动刷新间隔
|
||||
const AUTO_REFRESH_INTERVAL = Number(import.meta.env.VITE_AUTO_REFRESH_INTERVAL) || 300000
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ id: 'pinned', label: '置顶项目' },
|
||||
{ id: 'ai', label: 'AI 推荐' },
|
||||
{ id: 'status', label: '状态' }
|
||||
]
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (activeTab.value === 'pinned' && pinnedBidsRef.value) {
|
||||
pinnedBidsRef.value.loadPinnedBids()
|
||||
showToastMessage('置顶项目已刷新')
|
||||
} else if (activeTab.value === 'ai' && aiRecommendationsRef.value) {
|
||||
aiRecommendationsRef.value.loadRecommendations()
|
||||
showToastMessage('AI 推荐已刷新')
|
||||
} else if (activeTab.value === 'status' && crawlInfoRef.value) {
|
||||
crawlInfoRef.value.loadCrawlStats()
|
||||
showToastMessage('状态已刷新')
|
||||
}
|
||||
}
|
||||
|
||||
const showToastMessage = (message: string) => {
|
||||
toastMessage.value = message
|
||||
showToast.value = true
|
||||
setTimeout(() => {
|
||||
showToast.value = false
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const startAutoRefresh = () => {
|
||||
// 使用环境变量配置的自动刷新间隔
|
||||
refreshTimer = window.setInterval(() => {
|
||||
handleRefresh()
|
||||
}, AUTO_REFRESH_INTERVAL)
|
||||
}
|
||||
|
||||
const stopAutoRefresh = () => {
|
||||
if (refreshTimer !== null) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startAutoRefresh()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
})
|
||||
|
||||
// 下拉刷新
|
||||
onPullDownRefresh(() => {
|
||||
handleRefresh()
|
||||
setTimeout(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
}, 1000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-container">
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
:class="['tab-button', { active: activeTab === tab.id }]"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</view>
|
||||
<view class="refresh-button" @click="handleRefresh">
|
||||
🔄 刷新
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="tab-content">
|
||||
<PinnedBids v-if="activeTab === 'pinned'" ref="pinnedBidsRef" />
|
||||
<AiRecommendations v-else-if="activeTab === 'ai'" ref="aiRecommendationsRef" />
|
||||
<CrawlInfo v-else-if="activeTab === 'status'" ref="crawlInfoRef" />
|
||||
</view>
|
||||
|
||||
<view v-if="showToast" class="toast">
|
||||
{{ toastMessage }}
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-container {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding: 0 16rpx;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 16rpx 24rpx;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 4rpx solid transparent;
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tab-button:active {
|
||||
color: #333;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: #3498db;
|
||||
border-bottom-color: #3498db;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 16rpx;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
margin-left: auto;
|
||||
padding: 8rpx 20rpx;
|
||||
background: #3498db;
|
||||
color: #fff;
|
||||
border-radius: 6rpx;
|
||||
font-size: 22rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.refresh-button:active {
|
||||
background: #2980b9;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
padding: 24rpx 48rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 24rpx;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
40
uni-app-version/src/uni.scss
Normal file
40
uni-app-version/src/uni.scss
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* uni-app 全局样式变量
|
||||
*/
|
||||
|
||||
/* 颜色变量 */
|
||||
$uni-color-primary: #3498db;
|
||||
$uni-color-success: #27ae60;
|
||||
$uni-color-warning: #f39c12;
|
||||
$uni-color-error: #e74c3c;
|
||||
$uni-color-info: #909399;
|
||||
|
||||
/* 文字颜色 */
|
||||
$uni-text-color: #333333;
|
||||
$uni-text-color-grey: #666666;
|
||||
$uni-text-color-placeholder: #999999;
|
||||
|
||||
/* 背景颜色 */
|
||||
$uni-bg-color: #ffffff;
|
||||
$uni-bg-color-grey: #f5f5f5;
|
||||
$uni-bg-color-hover: #f9f9f9;
|
||||
|
||||
/* 边框颜色 */
|
||||
$uni-border-color: #e0e0e0;
|
||||
|
||||
/* 字体大小 */
|
||||
$uni-font-size-xs: 20rpx;
|
||||
$uni-font-size-sm: 24rpx;
|
||||
$uni-font-size-base: 28rpx;
|
||||
$uni-font-size-lg: 32rpx;
|
||||
|
||||
/* 间距 */
|
||||
$uni-spacing-xs: 8rpx;
|
||||
$uni-spacing-sm: 16rpx;
|
||||
$uni-spacing-base: 24rpx;
|
||||
$uni-spacing-lg: 32rpx;
|
||||
|
||||
/* 圆角 */
|
||||
$uni-border-radius-sm: 6rpx;
|
||||
$uni-border-radius-base: 8rpx;
|
||||
$uni-border-radius-lg: 12rpx;
|
||||
105
uni-app-version/src/utils/api.ts
Normal file
105
uni-app-version/src/utils/api.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* API 请求封装
|
||||
*/
|
||||
|
||||
// 从环境变量读取 API 基础地址
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
|
||||
|
||||
interface RequestOptions {
|
||||
url: string
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
data?: any
|
||||
header?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一请求方法
|
||||
*/
|
||||
function request<T = any>(options: RequestOptions): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: BASE_URL + options.url,
|
||||
method: options.method || 'GET',
|
||||
data: options.data || {},
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.header
|
||||
},
|
||||
success: (res) => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(res.data as T)
|
||||
} else {
|
||||
reject(new Error(`请求失败: ${res.statusCode} ${(res.data as any)?.message || ''}`))
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('请求错误:', err)
|
||||
reject(new Error(`网络请求失败: ${err.errMsg || '未知错误'}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 数据类型定义
|
||||
export interface BidItem {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
publishDate: string
|
||||
source: string
|
||||
pin: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface AiRecommendation {
|
||||
id: string
|
||||
title: string
|
||||
confidence: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface CrawlInfoStat {
|
||||
source: string
|
||||
count: number
|
||||
latestUpdate: string
|
||||
latestPublishDate: string
|
||||
error: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取置顶投标项目
|
||||
*/
|
||||
export function getPinnedBids(): Promise<BidItem[]> {
|
||||
return request<BidItem[]>({
|
||||
url: '/api/bids/pinned',
|
||||
method: 'GET'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 AI 推荐
|
||||
*/
|
||||
export function getAiRecommendations(): Promise<AiRecommendation[]> {
|
||||
return request<AiRecommendation[]>({
|
||||
url: '/api/ai/latest-recommendations',
|
||||
method: 'GET'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取爬虫统计信息
|
||||
*/
|
||||
export function getCrawlInfoStats(): Promise<CrawlInfoStat[]> {
|
||||
return request<CrawlInfoStat[]>({
|
||||
url: '/api/bids/crawl-info-stats',
|
||||
method: 'GET'
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
request,
|
||||
getPinnedBids,
|
||||
getAiRecommendations,
|
||||
getCrawlInfoStats
|
||||
}
|
||||
25
uni-app-version/tailwind.config.js
Normal file
25
uni-app-version/tailwind.config.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
'./index.html',
|
||||
'./pages/**/*.{vue,js,ts,jsx,tsx}',
|
||||
'./components/**/*.{vue,js,ts,jsx,tsx}',
|
||||
'./App.vue'
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#3498db',
|
||||
success: '#27ae60',
|
||||
warning: '#f39c12',
|
||||
error: '#e74c3c',
|
||||
info: '#909399'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [],
|
||||
// uni-app 配置
|
||||
corePlugins: {
|
||||
preflight: false
|
||||
}
|
||||
}
|
||||
38
uni-app-version/tsconfig.json
Normal file
38
uni-app-version/tsconfig.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"types": [
|
||||
"@dcloudio/types",
|
||||
"vite/client"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.d.ts",
|
||||
"**/*.tsx",
|
||||
"**/*.vue"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"unpackage"
|
||||
]
|
||||
}
|
||||
18
uni-app-version/vite.config.ts
Normal file
18
uni-app-version/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import uniPlugin from '@dcloudio/vite-plugin-uni'
|
||||
|
||||
// Handle both ESM default export and CJS module.exports
|
||||
const uni = (uniPlugin as any).default || uniPlugin
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [uni()],
|
||||
server: {
|
||||
port: 8080,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user