Compare commits
36 Commits
82f5a81887
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2475619228 | ||
|
|
eaed16a12e | ||
|
|
4bace565e4 | ||
|
|
bdc62a2975 | ||
|
|
e3880408b7 | ||
|
|
1453e03c76 | ||
|
|
882c8b5b9f | ||
|
|
95dfcd0278 | ||
|
|
fffc17b9ad | ||
|
|
91e44018f0 | ||
|
|
cf5a0b179e | ||
|
|
9fc455cca4 | ||
|
|
8f6e5c8423 | ||
|
|
f08c513bbe | ||
|
|
b6a6398864 | ||
|
|
a55dfd78d2 | ||
|
|
810a420a46 | ||
|
|
300e930c64 | ||
|
|
9257c78e72 | ||
|
|
e8beeec2b9 | ||
|
|
9dc01eeb46 | ||
|
|
811ad927f3 | ||
|
|
3033eb622f | ||
|
|
5edebd9d55 | ||
|
|
eba5c7e5c5 | ||
|
|
36cbb6fda1 | ||
|
|
20c7c0da0c | ||
|
|
37200aa115 | ||
|
|
af78fd0682 | ||
|
|
20619bb87b | ||
|
|
70f0498c44 | ||
|
|
0f510554ed | ||
|
|
5a7cbc6daa | ||
|
|
e804e3998f | ||
|
|
eca3f4f9fd | ||
|
|
f736f30248 |
32
.env
32
.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
|
||||
|
||||
@@ -27,7 +37,17 @@ PROXY_HOST=127.0.0.1
|
||||
PROXY_PORT=3211
|
||||
|
||||
# 日志级别(可选):error, warn, info, debug, verbose
|
||||
LOG_LEVEL=info
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# OpenAI API Key (用于 AI 推荐)
|
||||
ARK_API_KEY=a63d58b6-cf56-434b-8a42-5c781ba0822a
|
||||
ARK_API_KEY=a63d58b6-cf56-434b-8a42-5c781ba0822a
|
||||
|
||||
SSH_PASSPHRASE=x
|
||||
|
||||
API_KEY=22c64b60-6e60-433c-991d-f6d658024b9e
|
||||
|
||||
# 是否启用 Basic Auth 认证(true/false)
|
||||
ENABLE_BASIC_AUTH=true
|
||||
|
||||
PORT=3300
|
||||
HOST=0.0.0.0
|
||||
@@ -24,4 +24,9 @@ PROXY_PORT=6000
|
||||
LOG_LEVEL=info
|
||||
|
||||
# OpenAI API Key (用于 AI 推荐)
|
||||
ARK_API_KEY=your_openai_api_key_here
|
||||
ARK_API_KEY=your_openai_api_key_here
|
||||
|
||||
API_KEY=your_secure_api_key_here
|
||||
|
||||
# 是否启用 Basic Auth 认证(true/false)
|
||||
ENABLE_BASIC_AUTH=true
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -12,3 +12,13 @@ build
|
||||
*-lock.json
|
||||
*.woff2
|
||||
widget/looker/frontend/src/assets/fonts/OFL.txt
|
||||
dist-electron
|
||||
unpackage
|
||||
.cursor
|
||||
qingyun
|
||||
plan
|
||||
.trae
|
||||
plans
|
||||
android
|
||||
docs
|
||||
.idea
|
||||
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),
|
||||
});
|
||||
@@ -29,7 +29,7 @@ export default tseslint.config(
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
"prettier/prettier": "off",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
# ARK API Key (用于 AI 推荐)
|
||||
VITE_ARK_API_KEY=a63d58b6-cf56-434b-8a42-5c781ba0822a
|
||||
|
||||
# 后端 API 地址
|
||||
VITE_API_BASE_URL=http://localhost:3300/
|
||||
5
frontend/.env.production
Normal file
5
frontend/.env.production
Normal file
@@ -0,0 +1,5 @@
|
||||
# ARK API Key (用于 AI 推荐)
|
||||
VITE_ARK_API_KEY=a63d58b6-cf56-434b-8a42-5c781ba0822a
|
||||
|
||||
# 后端 API 地址
|
||||
VITE_API_BASE_URL=http://139.180.190.142:3300/
|
||||
@@ -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).
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>投标</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"build:watch": "concurrently \"vue-tsc -b --watch\" \"vite build --watch\"",
|
||||
"build": "vue-tsc -b && vite build --mode production",
|
||||
"build:watch": "concurrently \"vue-tsc -b --watch\" \"vite build --watch --mode development\"",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
<template>
|
||||
<el-container class="layout-container" style="height: 100vh">
|
||||
<el-aside width="200px" style="background-color: #545c64">
|
||||
<div class="logo">投标信息一览</div>
|
||||
<el-menu active-text-color="#ffd04b" background-color="#545c64" class="el-menu-vertical-demo" default-active="1"
|
||||
text-color="#fff" @select="handleSelect">
|
||||
|
||||
<div class="layout-container" :class="{ 'is-mobile': isMobile }">
|
||||
<!-- 移动端顶部导航栏 -->
|
||||
<el-header v-if="isMobile" class="mobile-header">
|
||||
<div class="mobile-header-content">
|
||||
<el-button type="primary" link @click="toggleSidebar" class="header-btn">
|
||||
<el-icon :size="24"><Fold /></el-icon>
|
||||
</el-button>
|
||||
<span v-if="isMobile" class="mobile-title">投标信息一览</span>
|
||||
<el-button type="primary" link @click="handleLogout" v-if="currentUser" class="header-btn">
|
||||
<el-icon :size="24"><SwitchButton /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 移动端侧边栏遮罩层 -->
|
||||
<div v-if="isMobile && sidebarVisible" class="sidebar-overlay" @click="toggleSidebar"></div>
|
||||
|
||||
<!-- 侧边栏 - 桌面端固定显示,移动端可滑动 -->
|
||||
<el-aside :class="{ 'mobile-sidebar': isMobile, 'sidebar-visible': sidebarVisible }" width="200px">
|
||||
<div v-if="!isMobile" class="logo">投标信息一览</div>
|
||||
<el-menu
|
||||
active-text-color="#ffd04b"
|
||||
background-color="#545c64"
|
||||
class="el-menu-vertical-demo"
|
||||
:default-active="activeIndex"
|
||||
text-color="#fff"
|
||||
@select="handleSelect"
|
||||
:collapse="isMobile"
|
||||
>
|
||||
<el-menu-item index="1">
|
||||
<el-icon>
|
||||
<MagicStick />
|
||||
@@ -39,34 +62,63 @@
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<el-header style="text-align: right; font-size: 12px">
|
||||
<span>Admin</span>
|
||||
<!-- 桌面端顶部导航栏 -->
|
||||
<el-header v-if="!isMobile" class="desktop-header">
|
||||
<div class="header-content">
|
||||
<span v-if="currentUser" class="username">{{ currentUser }}</span>
|
||||
<el-button v-if="currentUser" type="primary" link @click="handleLogout">退出登录</el-button>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-main>
|
||||
<DashboardAI v-if="activeIndex === '1'" :bids="bids" />
|
||||
<Dashboard v-if="activeIndex === '2'" :today-bids="todayBids"
|
||||
:keywords="keywords" :loading="loading" :is-crawling="isCrawling" @refresh="fetchData"
|
||||
@update-bids="updateBidsByDateRange" />
|
||||
<Dashboard
|
||||
v-if="activeIndex === '2'"
|
||||
:today-bids="todayBids"
|
||||
:keywords="keywords"
|
||||
:loading="loading"
|
||||
:is-crawling="isCrawling"
|
||||
@refresh="fetchData"
|
||||
@update-bids="updateBidsByDateRange"
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<Bids v-if="activeIndex === '3'" :bids="bids" :source-options="sourceOptions" :loading="loading" :total="total"
|
||||
@fetch="handleFetchBids" />
|
||||
<Bids
|
||||
v-if="activeIndex === '3'"
|
||||
:bids="bids"
|
||||
:source-options="sourceOptions"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
@fetch="handleFetchBids"
|
||||
/>
|
||||
|
||||
<Keywords v-if="activeIndex === '4'" :keywords="keywords" :loading="loading" @refresh="fetchData" />
|
||||
|
||||
<CrawlInfo v-if="activeIndex === '5'" />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</div>
|
||||
|
||||
<!-- 登录对话框 -->
|
||||
<el-dialog v-model="loginDialogVisible" title="用户登录" width="90%" :style="{ maxWidth: '400px' }" :close-on-click-modal="false" :show-close="false">
|
||||
<el-form :model="loginForm" label-width="80px">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="loginForm.username" placeholder="请输入用户名" @keyup.enter="handleLogin" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="loginForm.password" type="password" placeholder="请输入密码" show-password @keyup.enter="handleLogin" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="handleLogin" :loading="loginLoading">登录</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import api, { setAuthCredentials, clearAuthCredentials, isAuthenticated } from './utils/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { DataBoard, Document, Setting, MagicStick, Connection } from '@element-plus/icons-vue'
|
||||
import { DataBoard, Document, Setting, MagicStick, Connection, Fold, SwitchButton } from '@element-plus/icons-vue'
|
||||
import Dashboard from './components/Dashboard.vue'
|
||||
import DashboardAI from './components/Dashboard-AI.vue'
|
||||
import Bids from './components/Bids.vue'
|
||||
@@ -82,14 +134,43 @@ const isCrawling = ref(false)
|
||||
const total = ref(0)
|
||||
const sourceOptions = ref<string[]>([])
|
||||
|
||||
// 移动端状态
|
||||
const isMobile = ref(false)
|
||||
const sidebarVisible = ref(false)
|
||||
|
||||
// 登录相关状态
|
||||
const loginDialogVisible = ref(false)
|
||||
const loginLoading = ref(false)
|
||||
const loginForm = ref({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
const currentUser = ref<string | null>(null)
|
||||
|
||||
// 检测屏幕宽度
|
||||
const checkScreenSize = () => {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
if (!isMobile.value) {
|
||||
sidebarVisible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSidebar = () => {
|
||||
sidebarVisible.value = !sidebarVisible.value
|
||||
}
|
||||
|
||||
const handleSelect = (key: string) => {
|
||||
activeIndex.value = key
|
||||
// 移动端选择后关闭侧边栏
|
||||
if (isMobile.value) {
|
||||
sidebarVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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 +190,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 +226,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) {
|
||||
@@ -156,12 +237,104 @@ const updateBidsByDateRange = async (startDate: string, endDate?: string, keywor
|
||||
}
|
||||
}
|
||||
|
||||
// 处理登录
|
||||
const handleLogin = async () => {
|
||||
if (!loginForm.value.username || !loginForm.value.password) {
|
||||
ElMessage.warning('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
|
||||
loginLoading.value = true
|
||||
try {
|
||||
// 保存凭证到 localStorage
|
||||
setAuthCredentials(loginForm.value.username, loginForm.value.password)
|
||||
|
||||
// 测试凭证是否有效
|
||||
await api.get('/api/bids', { params: { page: 1, limit: 1 } })
|
||||
|
||||
// 登录成功
|
||||
currentUser.value = loginForm.value.username
|
||||
loginDialogVisible.value = false
|
||||
ElMessage.success('登录成功')
|
||||
|
||||
// 清空表单
|
||||
loginForm.value.username = ''
|
||||
loginForm.value.password = ''
|
||||
|
||||
// 加载数据
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
console.error('登录失败:', error)
|
||||
// 清除无效凭证
|
||||
clearAuthCredentials()
|
||||
if (error.response?.status === 401) {
|
||||
ElMessage.error('用户名或密码错误')
|
||||
} else {
|
||||
ElMessage.error('登录失败,请稍后重试')
|
||||
}
|
||||
} finally {
|
||||
loginLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理登出
|
||||
const handleLogout = () => {
|
||||
clearAuthCredentials()
|
||||
currentUser.value = null
|
||||
ElMessage.success('已退出登录')
|
||||
}
|
||||
|
||||
// 处理认证要求事件
|
||||
const handleAuthRequired = () => {
|
||||
loginDialogVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
// 检查是否已登录
|
||||
if (isAuthenticated()) {
|
||||
// 从凭证中提取用户名
|
||||
const credentials = localStorage.getItem('authCredentials')
|
||||
if (credentials) {
|
||||
try {
|
||||
const decoded = atob(credentials)
|
||||
const [username] = decoded.split(':')
|
||||
currentUser.value = username || null
|
||||
fetchData()
|
||||
} catch (e) {
|
||||
console.error('解析凭证失败:', e)
|
||||
clearAuthCredentials()
|
||||
loginDialogVisible.value = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
loginDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 监听认证要求事件
|
||||
window.addEventListener('auth-required', handleAuthRequired)
|
||||
|
||||
// 监听屏幕大小变化
|
||||
checkScreenSize()
|
||||
window.addEventListener('resize', checkScreenSize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('auth-required', handleAuthRequired)
|
||||
window.removeEventListener('resize', checkScreenSize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.layout-container .el-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.layout-container .el-header {
|
||||
background-color: #fff;
|
||||
color: var(--el-text-color-primary);
|
||||
@@ -171,6 +344,13 @@ onMounted(() => {
|
||||
|
||||
.layout-container .el-aside {
|
||||
color: var(--el-text-color-primary);
|
||||
transition: transform 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layout-container .el-menu {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.logo {
|
||||
@@ -182,4 +362,109 @@ onMounted(() => {
|
||||
font-size: 18px;
|
||||
background-color: #434a50;
|
||||
}
|
||||
|
||||
/* 桌面端样式 */
|
||||
.desktop-header .header-content {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.username {
|
||||
margin-right: 15px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 移动端样式 */
|
||||
.is-mobile .el-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.is-mobile .el-main {
|
||||
padding-top: 60px;
|
||||
}
|
||||
|
||||
.mobile-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: #434a50;
|
||||
color: white;
|
||||
padding: 0;
|
||||
height: 50px !important;
|
||||
line-height: 50px;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.mobile-header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.mobile-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.mobile-sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
z-index: 1000;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.mobile-sidebar.sidebar-visible {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.is-mobile .el-main {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Element Plus 菜单在移动端的样式调整 */
|
||||
.is-mobile .el-menu {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.is-mobile .el-menu-item {
|
||||
padding: 0 20px !important;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.is-mobile .el-button {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.is-mobile .el-icon {
|
||||
padding: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h2 style="margin: 0;">All Bids</h2>
|
||||
<el-select v-model="selectedSource" placeholder="Filter by Source" clearable style="width: 200px" @change="handleSourceChange">
|
||||
<div class="bids-container">
|
||||
<div class="bids-header">
|
||||
<h2 class="bids-title">All Bids</h2>
|
||||
<el-select v-model="selectedSource" placeholder="按来源筛选" clearable class="source-select" @change="handleSourceChange">
|
||||
<el-option
|
||||
v-for="source in sourceOptions"
|
||||
:key="source"
|
||||
@@ -11,8 +11,8 @@
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table :data="bids" v-loading="loading" style="width: 100%">
|
||||
<el-table-column label="Pin" width="60" align="center">
|
||||
<el-table :data="bids" v-loading="loading" style="width: 100%" class="bids-table">
|
||||
<el-table-column label="Pin" width="45" align="center">
|
||||
<template #default="scope">
|
||||
<el-icon
|
||||
:style="{
|
||||
@@ -26,13 +26,22 @@
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="Title">
|
||||
<el-table-column label="Title" min-width="150">
|
||||
<template #default="scope">
|
||||
<a :href="scope.row.url" target="_blank">{{ scope.row.title }}</a>
|
||||
<a
|
||||
v-if="scope.row.url"
|
||||
:href="scope.row.url"
|
||||
target="_blank"
|
||||
class="project-link"
|
||||
@click.stop
|
||||
>
|
||||
{{ scope.row.title }}
|
||||
</a>
|
||||
<span v-else>{{ scope.row.title }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="source" label="Source" width="200" />
|
||||
<el-table-column prop="publishDate" label="Date" width="150">
|
||||
<el-table-column prop="source" label="Source" min-width="100" />
|
||||
<el-table-column prop="publishDate" label="Date" width="95">
|
||||
<template #default="scope">{{ formatDate(scope.row.publishDate) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -44,16 +53,17 @@
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
style="margin-top: 20px; justify-content: flex-end;"
|
||||
class="pagination"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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'
|
||||
import { formatDate } from '../utils/date.util'
|
||||
|
||||
interface Props {
|
||||
bids: any[]
|
||||
@@ -72,11 +82,6 @@ const selectedSource = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
}
|
||||
|
||||
const handleSourceChange = () => {
|
||||
currentPage.value = 1
|
||||
emit('fetch', currentPage.value, pageSize.value, selectedSource.value || undefined)
|
||||
@@ -97,7 +102,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) {
|
||||
@@ -107,6 +112,52 @@ const togglePin = async (item: any) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bids-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bids-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bids-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.source-select {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.bids-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-link {
|
||||
color: var(--el-text-color-primary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.project-link:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
@@ -115,4 +166,32 @@ a {
|
||||
a:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
/* 移动端响应式样式 */
|
||||
@media (max-width: 768px) {
|
||||
.bids-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.bids-title {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.source-select {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.bids-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.bids-table :deep(.el-table__cell) {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="crawl-info">
|
||||
<el-card>
|
||||
<el-card class="crawl-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>爬虫统计信息</span>
|
||||
<span class="card-title">爬虫统计信息</span>
|
||||
<el-button type="primary" size="small" @click="fetchCrawlStats" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
@@ -11,33 +11,38 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="crawlStats" stripe style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="source" label="爬虫来源" width="200" />
|
||||
<el-table-column prop="count" label="本次获取数量" width="120" sortable />
|
||||
<el-table-column label="最近更新时间" width="180">
|
||||
<el-table :data="crawlStats" stripe style="width: 100%" v-loading="loading" class="crawl-table" :cell-class-name="tableCellClassName">
|
||||
<el-table-column prop="source" label="爬虫来源" min-width="120" />
|
||||
<el-table-column prop="count" label="本次获取数量" width="110" sortable />
|
||||
<el-table-column label="最近更新时间" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.latestUpdate) }}
|
||||
{{ formatDateTime(row.latestUpdate) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最新工程时间" width="180">
|
||||
<el-table-column label="最新工程时间" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.latestPublishDate) }}
|
||||
{{ formatDateTime(row.latestPublishDate) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.error && row.error.trim() ? 'danger' : (row.count > 0 ? 'success' : 'info')">
|
||||
<el-tag
|
||||
v-if="row.count === -1"
|
||||
type="warning"
|
||||
size="small"
|
||||
>
|
||||
正在更新...
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else
|
||||
:type="row.error && row.error.trim() ? 'danger' : (row.count > 0 ? 'success' : 'info')"
|
||||
size="small"
|
||||
>
|
||||
{{ row.error && row.error.trim() ? '出错' : (row.count > 0 ? '正常' : '无数据') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="错误信息" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.error && row.error.trim()" style="color: #f56c6c">{{ row.error }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<el-table-column label="操作" width="60">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="primary"
|
||||
@@ -47,14 +52,13 @@
|
||||
:disabled="crawlingSources.has(row.source)"
|
||||
>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
更新
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="summary" v-if="crawlStats.length > 0">
|
||||
<el-descriptions :column="3" border>
|
||||
<el-descriptions :column="2" border class="descriptions">
|
||||
<el-descriptions-item label="爬虫来源总数">
|
||||
{{ crawlStats.length }}
|
||||
</el-descriptions-item>
|
||||
@@ -74,10 +78,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import api from '../utils/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { formatDateTime } from '../utils/date.util'
|
||||
|
||||
interface CrawlStat {
|
||||
source: string
|
||||
@@ -90,6 +95,7 @@ interface CrawlStat {
|
||||
const crawlStats = ref<CrawlStat[]>([])
|
||||
const loading = ref(false)
|
||||
const crawlingSources = ref<Set<string>>(new Set())
|
||||
let refreshTimer: number | null = null
|
||||
|
||||
const totalCount = computed(() => {
|
||||
return crawlStats.value.reduce((sum, item) => sum + item.count, 0)
|
||||
@@ -103,22 +109,10 @@ const errorSources = computed(() => {
|
||||
return crawlStats.value.filter(item => item.error && item.error.trim()).length
|
||||
})
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -128,11 +122,20 @@ const fetchCrawlStats = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 表格单元格类名,用于响应式处理
|
||||
const tableCellClassName = ({ columnIndex }: { columnIndex: number }) => {
|
||||
// 移动端隐藏错误信息列
|
||||
if (columnIndex === 5) {
|
||||
return 'hidden-on-mobile'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
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} 条数据`)
|
||||
@@ -140,7 +143,6 @@ const crawlSingleSource = async (sourceName: string) => {
|
||||
ElMessage.error(`${sourceName} 更新失败: ${res.data.error || '未知错误'}`)
|
||||
}
|
||||
|
||||
// 刷新统计数据
|
||||
await fetchCrawlStats()
|
||||
} catch (error) {
|
||||
console.error('Failed to crawl single source:', error)
|
||||
@@ -150,14 +152,40 @@ const crawlSingleSource = async (sourceName: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const startAutoRefresh = () => {
|
||||
if (refreshTimer !== null) {
|
||||
clearInterval(refreshTimer)
|
||||
}
|
||||
// 取消自动刷新
|
||||
// refreshTimer = window.setInterval(() => {
|
||||
// fetchCrawlStats()
|
||||
// }, REFRESH_INTERVAL)
|
||||
}
|
||||
|
||||
const stopAutoRefresh = () => {
|
||||
if (refreshTimer !== null) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCrawlStats()
|
||||
startAutoRefresh()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopAutoRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.crawl-info {
|
||||
padding: 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.crawl-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@@ -166,7 +194,67 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.crawl-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.descriptions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
/* 移动端响应式样式 */
|
||||
@media (max-width: 768px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.crawl-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.crawl-table :deep(.el-table__cell) {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.el-table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.el-descriptions {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* 移动端隐藏错误信息列 */
|
||||
.crawl-table :deep(.hidden-on-mobile) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h2 style="margin: 0;">Dashboard AI</h2>
|
||||
<div class="dashboard-ai-container">
|
||||
<div class="dashboard-header">
|
||||
<div class="dashboard-title-wrapper">
|
||||
<h2 class="dashboard-title">Dashboard AI</h2>
|
||||
|
||||
</div>
|
||||
<el-button type="primary" :loading="loading" @click="fetchAIRecommendations">
|
||||
<el-icon style="margin-right: 5px"><MagicStick /></el-icon>
|
||||
获取 AI 推荐
|
||||
</el-button>
|
||||
</div>
|
||||
<PinnedProject ref="pinnedProjectRef" @pin-changed="handlePinChanged" />
|
||||
<el-row :gutter="20">
|
||||
<el-row :gutter="20" class="ai-section">
|
||||
<el-col :span="24">
|
||||
<el-card class="box-card" shadow="hover">
|
||||
<!-- <template #header> -->
|
||||
<div class="card-header">
|
||||
<span>AI 推荐项目</span>
|
||||
<el-tag type="success">{{ aiRecommendations.length }} 个推荐</el-tag>
|
||||
</div>
|
||||
<!-- </template> -->
|
||||
<div class="card-header">
|
||||
<span>AI 推荐项目</span>
|
||||
<span v-if="lastRecommendationTime" class="last-recommendation-time">
|
||||
生成时间: {{ lastRecommendationTime }}
|
||||
</span>
|
||||
<span v-else class="last-recommendation-time text-muted">
|
||||
暂无推荐时间
|
||||
</span>
|
||||
<el-tag type="success">{{ aiRecommendations.length }} 个推荐</el-tag>
|
||||
</div>
|
||||
<div v-if="loading" style="text-align: center; padding: 40px;">
|
||||
<el-icon class="is-loading" :size="30"><Loading /></el-icon>
|
||||
<p style="margin-top: 10px; color: #909399;">AI 正在分析中...</p>
|
||||
@@ -27,8 +34,8 @@
|
||||
<p style="font-size: 12px; margin-top: 5px;">点击上方按钮获取 AI 推荐</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-table :data="aiRecommendations" style="width: 100%" size="small">
|
||||
<el-table-column label="Pin" width="60" align="center">
|
||||
<el-table :data="aiRecommendations" style="width: 100%" size="small" class="ai-table">
|
||||
<el-table-column label="Pin" width="45" align="center">
|
||||
<template #default="scope">
|
||||
<el-icon
|
||||
:style="{
|
||||
@@ -42,20 +49,29 @@
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="项目名称">
|
||||
<el-table-column label="项目名称" min-width="150">
|
||||
<template #default="scope">
|
||||
<a :href="scope.row.url" target="_blank">{{ scope.row.title }}</a>
|
||||
<a
|
||||
v-if="scope.row.url"
|
||||
:href="scope.row.url"
|
||||
target="_blank"
|
||||
class="project-link"
|
||||
@click.stop
|
||||
>
|
||||
{{ scope.row.title }}
|
||||
</a>
|
||||
<span v-else>{{ scope.row.title }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="source" label="来源" width="200" />
|
||||
<el-table-column prop="publishDate" label="发布日期" width="180">
|
||||
<el-table-column prop="source" label="来源" min-width="80" />
|
||||
<el-table-column prop="publishDate" label="发布日期" width="95">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.publishDate) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="confidence" label="推荐度" width="120">
|
||||
<el-table-column prop="confidence" label="推荐度" width="70">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getConfidenceType(scope.row.confidence)">
|
||||
<el-tag :type="getConfidenceType(scope.row.confidence)" size="small">
|
||||
{{ scope.row.confidence }}%
|
||||
</el-tag>
|
||||
</template>
|
||||
@@ -65,26 +81,28 @@
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<h3 style="margin: 0;">选择日期范围</h3>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<div class="filter-section">
|
||||
<h3 class="filter-title">选择日期范围</h3>
|
||||
<div class="filter-controls">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="To"
|
||||
start-placeholder="Start Date"
|
||||
end-placeholder="End Date"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
clearable
|
||||
style="width: 240px;"
|
||||
class="date-picker"
|
||||
/>
|
||||
<el-button type="primary" @click="setLast3Days">3天</el-button>
|
||||
<el-button type="primary" @click="setLast7Days">7天</el-button>
|
||||
<el-button type="success" :loading="bidsLoading" @click="fetchBidsByDateRange">
|
||||
<el-icon style="margin-right: 5px"><List /></el-icon>
|
||||
列出时间范围内所有工程
|
||||
</el-button>
|
||||
<div class="button-group">
|
||||
<el-button type="primary" @click="setLast3Days">3天</el-button>
|
||||
<el-button type="primary" @click="setLast7Days">7天</el-button>
|
||||
<el-button type="success" :loading="bidsLoading" @click="fetchBidsByDateRange">
|
||||
<el-icon style="margin-right: 5px"><List /></el-icon>
|
||||
列出时间范围内所有工程
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-row v-if="showAllBids" :gutter="20" style="margin-top: 20px;">
|
||||
@@ -105,14 +123,23 @@
|
||||
<p style="margin-top: 10px;">该时间范围内暂无工程</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-table :data="bidsByDateRange" style="width: 100%" size="small">
|
||||
<el-table-column prop="title" label="项目名称">
|
||||
<el-table :data="bidsByDateRange" style="width: 100%" size="small" class="bids-table">
|
||||
<el-table-column label="项目名称" min-width="150">
|
||||
<template #default="scope">
|
||||
<a :href="scope.row.url" target="_blank">{{ scope.row.title }}</a>
|
||||
<a
|
||||
v-if="scope.row.url"
|
||||
:href="scope.row.url"
|
||||
target="_blank"
|
||||
class="project-link"
|
||||
@click.stop
|
||||
>
|
||||
{{ scope.row.title }}
|
||||
</a>
|
||||
<span v-else>{{ scope.row.title }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="source" label="来源" width="200" />
|
||||
<el-table-column prop="publishDate" label="发布日期" width="180">
|
||||
<el-table-column prop="source" label="来源" width="220" />
|
||||
<el-table-column prop="publishDate" label="发布日期" width="95">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.publishDate) }}
|
||||
</template>
|
||||
@@ -127,7 +154,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'
|
||||
@@ -154,6 +181,7 @@ const dateRange = ref<[string, string] | null>(null)
|
||||
const showAllBids = ref(false)
|
||||
const bidsLoading = ref(false)
|
||||
const bidsByDateRange = ref<any[]>([])
|
||||
const lastRecommendationTime = ref<string | null>(null)
|
||||
|
||||
// 从 localStorage 加载保存的日期范围
|
||||
const loadSavedDateRange = () => {
|
||||
@@ -175,13 +203,16 @@ watch(dateRange, (newDateRange) => {
|
||||
// 从数据库加载最新的 AI 推荐
|
||||
const loadLatestRecommendations = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/ai/latest-recommendations')
|
||||
const recommendations = response.data
|
||||
|
||||
const response = await api.get('/api/ai/latest-recommendations')
|
||||
const { recommendations, generatedAt } = response.data
|
||||
|
||||
// 更新生成时间
|
||||
lastRecommendationTime.value = generatedAt
|
||||
|
||||
// 获取所有置顶的项目
|
||||
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 状态
|
||||
aiRecommendations.value = recommendations.map((rec: any) => ({
|
||||
...rec,
|
||||
@@ -240,7 +271,7 @@ const fetchAIRecommendations = async () => {
|
||||
}))
|
||||
|
||||
// 调用后端 API
|
||||
const response = await axios.post('/api/ai/recommendations', {
|
||||
const response = await api.post('/api/ai/recommendations', {
|
||||
bids: bidsData
|
||||
})
|
||||
|
||||
@@ -267,10 +298,13 @@ const fetchAIRecommendations = async () => {
|
||||
aiRecommendations.value = recommendations
|
||||
|
||||
// 保存推荐结果到数据库
|
||||
await axios.post('/api/ai/save-recommendations', {
|
||||
await api.post('/api/ai/save-recommendations', {
|
||||
recommendations
|
||||
})
|
||||
|
||||
// 更新时间戳
|
||||
lastRecommendationTime.value = new Date().toLocaleString('zh-CN', { hour12: false })
|
||||
|
||||
ElMessage.success('AI 推荐获取成功')
|
||||
} catch (error: any) {
|
||||
ElMessage.error('获取 AI 推荐失败')
|
||||
@@ -301,7 +335,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 +378,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 组件的数据
|
||||
@@ -358,18 +392,98 @@ const togglePin = async (item: AIRecommendation) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-ai-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-title-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dashboard-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.last-recommendation-time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.last-recommendation-time.text-muted {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.ai-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
}
|
||||
|
||||
.box-card{
|
||||
.box-card {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
margin-bottom: 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.filter-title {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.date-picker {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ai-table,
|
||||
.bids-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-link {
|
||||
color: var(--el-text-color-primary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.project-link:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
@@ -378,4 +492,50 @@ a {
|
||||
a:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
/* 移动端响应式样式 */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.dashboard-title-wrapper {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dashboard-title {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.date-picker {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ai-table,
|
||||
.bids-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.ai-table :deep(.el-table__cell),
|
||||
.bids-table :deep(.el-table__cell) {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h2 style="margin: 0;">Dashboard</h2>
|
||||
<div class="dashboard-container">
|
||||
<div class="dashboard-header">
|
||||
<h2 class="dashboard-title">Dashboard</h2>
|
||||
<el-button type="primary" :loading="crawling" :disabled="isCrawling" @click="handleCrawl">
|
||||
<el-icon style="margin-right: 5px"><Refresh /></el-icon>
|
||||
立刻抓取
|
||||
@@ -9,34 +9,36 @@
|
||||
</div>
|
||||
<PinnedProject ref="pinnedProjectRef" @pin-changed="handlePinChanged" />
|
||||
<el-divider />
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<h3 style="margin: 0;">Today's Bids</h3>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<div class="filter-section">
|
||||
<h3 class="filter-title">Today's Bids</h3>
|
||||
<div class="filter-controls">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="To"
|
||||
start-placeholder="Start Date"
|
||||
end-placeholder="End Date"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
clearable
|
||||
style="width: 240px;"
|
||||
class="date-picker"
|
||||
/>
|
||||
<el-button type="primary" @click="setLast3Days">3天</el-button>
|
||||
<el-button type="primary" @click="setLast7Days">7天</el-button>
|
||||
<el-button type="success" :loading="updating" @click="updateBidsByDateRange">
|
||||
<el-icon style="margin-right: 5px"><Refresh /></el-icon>
|
||||
更新
|
||||
</el-button>
|
||||
<div class="button-group">
|
||||
<el-button type="primary" @click="setLast3Days">3天</el-button>
|
||||
<el-button type="primary" @click="setLast7Days">7天</el-button>
|
||||
<el-button type="success" :loading="updating" @click="updateBidsByDateRange">
|
||||
<el-icon style="margin-right: 5px"><Refresh /></el-icon>
|
||||
更新
|
||||
</el-button>
|
||||
</div>
|
||||
<el-select
|
||||
v-model="selectedKeywords"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="Filter by Keywords"
|
||||
placeholder="按关键字筛选"
|
||||
clearable
|
||||
style="width: 300px;"
|
||||
class="keyword-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="keyword in keywords"
|
||||
@@ -47,8 +49,8 @@
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="filteredTodayBids" v-loading="loading" style="width: 100%">
|
||||
<el-table-column label="Pin" width="60" align="center">
|
||||
<el-table :data="filteredTodayBids" v-loading="loading" style="width: 100%" class="bids-table">
|
||||
<el-table-column label="Pin" width="50" align="center">
|
||||
<template #default="scope">
|
||||
<el-icon
|
||||
:style="{
|
||||
@@ -62,13 +64,22 @@
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="Title">
|
||||
<el-table-column label="Title" min-width="150">
|
||||
<template #default="scope">
|
||||
<a :href="scope.row.url" target="_blank">{{ scope.row.title }}</a>
|
||||
<a
|
||||
v-if="scope.row.url"
|
||||
:href="scope.row.url"
|
||||
target="_blank"
|
||||
class="project-link"
|
||||
@click.stop
|
||||
>
|
||||
{{ scope.row.title }}
|
||||
</a>
|
||||
<span v-else>{{ scope.row.title }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="source" label="Source" width="220" />
|
||||
<el-table-column prop="publishDate" label="Date" width="150">
|
||||
<el-table-column prop="source" label="Source" min-width="90" />
|
||||
<el-table-column prop="publishDate" label="Date" width="100">
|
||||
<template #default="scope">{{ formatDate(scope.row.publishDate) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -77,10 +88,11 @@
|
||||
|
||||
<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'
|
||||
import { formatDate } from '../utils/date.util'
|
||||
|
||||
interface Props {
|
||||
todayBids: any[]
|
||||
@@ -121,7 +133,7 @@ watch(dateRange, (newDateRange) => {
|
||||
localStorage.setItem('dashboard_dateRange', JSON.stringify(newDateRange))
|
||||
}, { deep: true })
|
||||
|
||||
// 从 localStorage 加载保存的关键字
|
||||
// 从 localStorage 保存的关键字
|
||||
const loadSavedKeywords = () => {
|
||||
const saved = localStorage.getItem('selectedKeywords')
|
||||
if (saved) {
|
||||
@@ -160,11 +172,6 @@ watch(dateRange, () => {
|
||||
}
|
||||
})
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
}
|
||||
|
||||
// 过滤 Today's Bids,只显示包含所选关键字的项目,并且在日期范围内
|
||||
const filteredTodayBids = computed(() => {
|
||||
let result = props.todayBids
|
||||
@@ -275,7 +282,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 +308,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 组件的数据
|
||||
@@ -324,6 +331,71 @@ if (!dateRange.value) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-title {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.date-picker {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.keyword-select {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.bids-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-link {
|
||||
color: var(--el-text-color-primary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.project-link:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -338,4 +410,41 @@ a {
|
||||
a:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
/* 移动端响应式样式 */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.dashboard-title {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.date-picker {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.keyword-select {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.bids-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.bids-table :deep(.el-table__cell) {
|
||||
padding: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h2>Keyword Management</h2>
|
||||
<el-button type="primary" @click="dialogVisible = true">Add Keyword</el-button>
|
||||
<div class="keywords-container">
|
||||
<div class="keywords-header">
|
||||
<h2 class="keywords-title">Keyword Management</h2>
|
||||
<el-button type="primary" @click="dialogVisible = true">添加关键字</el-button>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" style="min-height: 200px;">
|
||||
<div v-loading="loading" style="min-height: 200px;" class="keywords-list">
|
||||
<el-tag
|
||||
v-for="keyword in keywords"
|
||||
:key="keyword.id"
|
||||
closable
|
||||
:type="getTagType(keyword.weight)"
|
||||
@close="handleDeleteKeyword(keyword.id)"
|
||||
style="margin: 5px;"
|
||||
class="keyword-tag"
|
||||
>
|
||||
{{ keyword.word }}
|
||||
</el-tag>
|
||||
<el-empty v-if="keywords.length === 0" description="No keywords" />
|
||||
<el-empty v-if="keywords.length === 0" description="暂无关键字" />
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="Add Keyword" width="30%">
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="Keyword">
|
||||
<el-input v-model="form.word" />
|
||||
<el-dialog v-model="dialogVisible" title="添加关键字" width="90%" :style="{ maxWidth: '400px' }">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="关键字">
|
||||
<el-input v-model="form.word" placeholder="请输入关键字" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Weight">
|
||||
<el-form-item label="权重">
|
||||
<el-input-number v-model="form.weight" :min="1" :max="5" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">Cancel</el-button>
|
||||
<el-button type="primary" @click="handleAddKeyword">Confirm</el-button>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleAddKeyword">确认</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -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 {
|
||||
@@ -72,36 +72,86 @@ const getTagType = (weight: number) => {
|
||||
|
||||
const handleAddKeyword = async () => {
|
||||
if (!form.word) {
|
||||
ElMessage.warning('Please enter a keyword')
|
||||
ElMessage.warning('请输入关键字')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await axios.post('/api/keywords', form)
|
||||
ElMessage.success('Keyword added')
|
||||
await api.post('/api/keywords', form)
|
||||
ElMessage.success('关键字添加成功')
|
||||
dialogVisible.value = false
|
||||
form.word = ''
|
||||
form.weight = 1
|
||||
emit('refresh')
|
||||
} catch (error) {
|
||||
ElMessage.error('Failed to add keyword')
|
||||
ElMessage.error('添加关键字失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteKeyword = async (id: string) => {
|
||||
try {
|
||||
await axios.delete(`/api/keywords/${id}`)
|
||||
ElMessage.success('Keyword deleted')
|
||||
await api.delete(`/api/keywords/${id}`)
|
||||
ElMessage.success('关键字删除成功')
|
||||
emit('refresh')
|
||||
} catch (error) {
|
||||
ElMessage.error('Failed to delete keyword')
|
||||
ElMessage.error('删除关键字失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
.keywords-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.keywords-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.keywords-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.keywords-list {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.keyword-tag {
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
/* 移动端响应式样式 */
|
||||
@media (max-width: 768px) {
|
||||
.keywords-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.keywords-title {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.keyword-tag {
|
||||
margin: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.keywords-list {
|
||||
min-height: 150px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<el-card class="box-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Pinned</span>
|
||||
<span class="card-title">Pinned</span>
|
||||
<el-tag type="danger">{{ pinnedBids.length }} 个置顶</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
@@ -15,8 +15,8 @@
|
||||
<p style="margin-top: 10px;">暂无置顶项目</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-table :data="pinnedBids" style="width: 100%" size="small">
|
||||
<el-table-column label="Pin" width="60" align="center">
|
||||
<el-table :data="pinnedBids" style="width: 100%" size="small" class="pinned-table">
|
||||
<el-table-column label="Pin" width="45" align="center">
|
||||
<template #default="scope">
|
||||
<el-icon
|
||||
:style="{
|
||||
@@ -30,15 +30,24 @@
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="项目名称">
|
||||
<el-table-column label="项目名称" min-width="150">
|
||||
<template #default="scope">
|
||||
<a :href="scope.row.url" target="_blank">{{ scope.row.title }}</a>
|
||||
<a
|
||||
v-if="scope.row.url"
|
||||
:href="scope.row.url"
|
||||
target="_blank"
|
||||
class="project-link"
|
||||
@click.stop
|
||||
>
|
||||
{{ scope.row.title }}
|
||||
</a>
|
||||
<span v-else>{{ scope.row.title }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="source" label="来源" width="200" />
|
||||
<el-table-column prop="publishDate" label="发布日期" width="180">
|
||||
<el-table-column prop="source" label="来源" min-width="100" />
|
||||
<el-table-column prop="publishDate" label="发布日期" width="95">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.publishDate) }}
|
||||
{{ formatSimpleDate(scope.row.publishDate) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -48,9 +57,10 @@
|
||||
|
||||
<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'
|
||||
import { formatSimpleDate } from '../utils/date.util'
|
||||
|
||||
const emit = defineEmits<{
|
||||
pinChanged: [title: string]
|
||||
@@ -63,7 +73,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 +85,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)
|
||||
@@ -88,16 +98,6 @@ const togglePin = async (item: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期,只显示年月日
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
// 初始化时加载置顶项目
|
||||
onMounted(() => {
|
||||
loadPinnedBids()
|
||||
@@ -116,11 +116,31 @@ defineExpose({
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.box-card {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.pinned-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-link {
|
||||
color: var(--el-text-color-primary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.project-link:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
@@ -129,4 +149,30 @@ a {
|
||||
a:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
/* 移动端响应式样式 */
|
||||
@media (max-width: 768px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.pinned-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.pinned-table :deep(.el-table__cell) {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.box-card {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* 基础样式 */
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
@@ -15,6 +16,19 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
@@ -24,19 +38,6 @@ a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
@@ -56,17 +57,6 @@ button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
|
||||
80
frontend/src/utils/api.ts
Normal file
80
frontend/src/utils/api.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import axios, { type InternalAxiosRequestConfig, type AxiosError } from 'axios';
|
||||
|
||||
/**
|
||||
* 认证相关工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 设置 Basic Auth 凭证到 localStorage
|
||||
*/
|
||||
export const setAuthCredentials = (username: string, password: string) => {
|
||||
const credentials = btoa(`${username}:${password}`);
|
||||
localStorage.setItem('authCredentials', credentials);
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 localStorage 获取 Basic Auth 凭证
|
||||
*/
|
||||
export const getAuthCredentials = (): string | null => {
|
||||
return localStorage.getItem('authCredentials');
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除认证凭证
|
||||
*/
|
||||
export const clearAuthCredentials = () => {
|
||||
localStorage.removeItem('authCredentials');
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否已登录
|
||||
*/
|
||||
export const isAuthenticated = (): boolean => {
|
||||
return !!localStorage.getItem('authCredentials');
|
||||
};
|
||||
|
||||
/**
|
||||
* API配置
|
||||
* 配置axios实例,设置baseURL和请求拦截器
|
||||
*/
|
||||
const api = axios.create({
|
||||
baseURL:
|
||||
(import.meta.env.VITE_API_BASE_URL as string) || 'http://localhost:3000', // 设置后端服务地址
|
||||
timeout: 120000, // 请求超时时间(120秒)
|
||||
});
|
||||
|
||||
// 请求拦截器
|
||||
api.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// 添加 Basic Auth 头
|
||||
const credentials = getAuthCredentials();
|
||||
if (credentials && config.headers) {
|
||||
config.headers['Authorization'] = `Basic ${credentials}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
// 处理 401 未授权错误
|
||||
if (error.response?.status === 401) {
|
||||
// 清除无效的凭证
|
||||
clearAuthCredentials();
|
||||
// 触发自定义事件,通知应用需要重新登录
|
||||
window.dispatchEvent(new CustomEvent('auth-required'));
|
||||
}
|
||||
console.error('API请求错误:', error);
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default api;
|
||||
69
frontend/src/utils/date.util.ts
Normal file
69
frontend/src/utils/date.util.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 日期格式化工具函数
|
||||
* 统一处理东八区(Asia/Shanghai)时间显示
|
||||
*/
|
||||
|
||||
/**
|
||||
* 格式化日期为 YYYY-MM-DD 格式
|
||||
* @param dateStr 日期字符串或Date对象
|
||||
* @returns 格式化后的日期字符串
|
||||
*/
|
||||
export function formatDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
if (isNaN(date.getTime())) return '-'
|
||||
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
timeZone: 'Asia/Shanghai'
|
||||
}).replace(/\//g, '-')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期时间为 YYYY-MM-DD HH:mm 格式
|
||||
* @param dateStr 日期字符串或Date对象
|
||||
* @returns 格式化后的日期时间字符串
|
||||
*/
|
||||
export function formatDateTime(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '-'
|
||||
|
||||
// 如果时间字符串已经包含时区信息(如 +08:00),说明已经是正确的北京时间
|
||||
// 直接从字符串中提取日期和时间部分,避免时区转换问题
|
||||
const timezoneMatch = dateStr.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}):\d{2}(?:\.\d{3})?[+-]\d{2}:\d{2}$/)
|
||||
|
||||
if (timezoneMatch) {
|
||||
// 时间字符串已包含时区,直接提取日期和时间
|
||||
return `${timezoneMatch[1]} ${timezoneMatch[2]}`
|
||||
}
|
||||
|
||||
// 没有时区信息或格式不匹配,使用 Date 对象解析并转换
|
||||
const date = new Date(dateStr)
|
||||
if (isNaN(date.getTime())) return '-'
|
||||
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Asia/Shanghai'
|
||||
}).replace(/\//g, '-')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期为简洁的 YYYY-MM-DD 格式(用于置顶项目等)
|
||||
* @param dateStr 日期字符串或Date对象
|
||||
* @returns 格式化后的日期字符串
|
||||
*/
|
||||
export function formatSimpleDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
if (isNaN(date.getTime())) return ''
|
||||
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
|
||||
1
ionic-app/.env.development
Normal file
1
ionic-app/.env.development
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:3300
|
||||
1
ionic-app/.env.production
Normal file
1
ionic-app/.env.production
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://139.180.190.142:3300/
|
||||
129
ionic-app/README.md
Normal file
129
ionic-app/README.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# 投标项目查看器 - Ionic 版本
|
||||
|
||||
基于 Ionic + Vue 3 + TypeScript + Tailwind CSS 的移动端应用,适配 Android 平台。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **AI 推荐** - 显示 AI 推荐的投标项目,带推荐度标签
|
||||
- **爬虫信息** - 显示各爬虫来源的统计信息和状态
|
||||
- **置顶项目** - 显示用户置顶的投标项目
|
||||
- **下拉刷新** - 支持下拉刷新数据
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **Ionic 8** - 移动端 UI 框架
|
||||
- **Vue 3** - 前端框架
|
||||
- **TypeScript** - 类型安全
|
||||
- **Tailwind CSS** - 样式框架
|
||||
- **Capacitor** - 原生功能集成
|
||||
- **Axios** - HTTP 客户端
|
||||
|
||||
## 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 开发
|
||||
|
||||
启动开发服务器:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
应用将在 http://localhost:8100 运行。
|
||||
|
||||
## 构建
|
||||
|
||||
构建生产版本:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Android 构建
|
||||
|
||||
### 添加 Android 平台
|
||||
|
||||
```bash
|
||||
npm install -g @capacitor/cli
|
||||
npx cap init
|
||||
npx cap add android
|
||||
```
|
||||
|
||||
### 同步资源
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npx cap sync
|
||||
```
|
||||
|
||||
### 在 Android Studio 中打开
|
||||
|
||||
```bash
|
||||
npx cap open android
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
创建 `.env.development` 文件:
|
||||
|
||||
```env
|
||||
VITE_API_BASE_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
创建 `.env.production` 文件:
|
||||
|
||||
```env
|
||||
VITE_API_BASE_URL=https://your-production-api.com
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
ionic-app/
|
||||
├── src/
|
||||
│ ├── components/ # 组件
|
||||
│ │ ├── AiRecommendations.vue
|
||||
│ │ ├── CrawlInfo.vue
|
||||
│ │ └── PinnedBids.vue
|
||||
│ ├── pages/ # 页面
|
||||
│ │ └── HomePage.vue
|
||||
│ ├── router/ # 路由
|
||||
│ │ └── index.ts
|
||||
│ ├── theme/ # 主题
|
||||
│ │ └── variables.css
|
||||
│ ├── types/ # 类型定义
|
||||
│ │ └── index.ts
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ └── api.ts
|
||||
│ ├── App.vue # 根组件
|
||||
│ ├── main.ts # 入口文件
|
||||
│ └── vite-env.d.ts # Vite 类型声明
|
||||
├── public/ # 静态资源
|
||||
├── .env.development # 开发环境变量
|
||||
├── .env.production # 生产环境变量
|
||||
├── capacitor.config.ts # Capacitor 配置
|
||||
├── ionic.config.json # Ionic 配置
|
||||
├── package.json
|
||||
├── tailwind.config.js # Tailwind 配置
|
||||
├── tsconfig.json # TypeScript 配置
|
||||
└── vite.config.ts # Vite 配置
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/ai/latest-recommendations` | GET | 获取最新 AI 推荐 |
|
||||
| `/api/bids/crawl-info-stats` | GET | 获取爬虫统计信息 |
|
||||
| `/api/bids/pinned` | GET | 获取置顶项目 |
|
||||
| `/api/bids/{title}/pin` | PATCH | 切换置顶状态 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 确保后端 API 服务正在运行
|
||||
2. 开发环境默认 API 地址为 `http://localhost:3001`
|
||||
3. 首次运行需要安装所有依赖
|
||||
4. Android 构建需要安装 Android Studio 和 Android SDK
|
||||
101
ionic-app/android/.gitignore
vendored
Normal file
101
ionic-app/android/.gitignore
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||
|
||||
# Built application files
|
||||
*.apk
|
||||
*.aar
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
*.class
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||
# release/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/gradle.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
# Android Studio 3 in .gitignore file.
|
||||
.idea/caches
|
||||
.idea/modules.xml
|
||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
#*.keystore
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
.cxx/
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
# google-services.json
|
||||
|
||||
# Freeline
|
||||
freeline.py
|
||||
freeline/
|
||||
freeline_project_description.json
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
fastlane/readme.md
|
||||
|
||||
# Version control
|
||||
vcs.xml
|
||||
|
||||
# lint
|
||||
lint/intermediates/
|
||||
lint/generated/
|
||||
lint/outputs/
|
||||
lint/tmp/
|
||||
# lint/reports/
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-android-plugins
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
2
ionic-app/android/app/.gitignore
vendored
Normal file
2
ionic-app/android/app/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
54
ionic-app/android/app/build.gradle
Normal file
54
ionic-app/android/app/build.gradle
Normal file
@@ -0,0 +1,54 @@
|
||||
apply plugin: 'com.android.application'
|
||||
|
||||
android {
|
||||
namespace "com.bidding.app"
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "com.bidding.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
flatDir{
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
}
|
||||
19
ionic-app/android/app/capacitor.build.gradle
Normal file
19
ionic-app/android/app/capacitor.build.gradle
Normal file
@@ -0,0 +1,19 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (hasProperty('postBuildExtras')) {
|
||||
postBuildExtras()
|
||||
}
|
||||
29
ionic-app/android/build.gradle
Normal file
29
ionic-app/android/build.gradle
Normal file
@@ -0,0 +1,29 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.2.1'
|
||||
classpath 'com.google.gms:google-services:4.4.0'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "variables.gradle"
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
6
ionic-app/android/capacitor.settings.gradle
Normal file
6
ionic-app/android/capacitor.settings.gradle
Normal file
@@ -0,0 +1,6 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
22
ionic-app/android/gradle.properties
Normal file
22
ionic-app/android/gradle.properties
Normal file
@@ -0,0 +1,22 @@
|
||||
# Project-wide Gradle settings.
|
||||
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
248
ionic-app/android/gradlew
vendored
Normal file
248
ionic-app/android/gradlew
vendored
Normal file
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command;
|
||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||
# shell script including quotes and variable substitutions, so put them in
|
||||
# double quotes to make sure that they get re-expanded; and
|
||||
# * put everything else in single quotes, so that it's not re-expanded.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
92
ionic-app/android/gradlew.bat
vendored
Normal file
92
ionic-app/android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
5
ionic-app/android/settings.gradle
Normal file
5
ionic-app/android/settings.gradle
Normal file
@@ -0,0 +1,5 @@
|
||||
include ':app'
|
||||
include ':capacitor-cordova-android-plugins'
|
||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||
|
||||
apply from: 'capacitor.settings.gradle'
|
||||
16
ionic-app/android/variables.gradle
Normal file
16
ionic-app/android/variables.gradle
Normal file
@@ -0,0 +1,16 @@
|
||||
ext {
|
||||
minSdkVersion = 22
|
||||
compileSdkVersion = 34
|
||||
targetSdkVersion = 34
|
||||
androidxActivityVersion = '1.8.0'
|
||||
androidxAppCompatVersion = '1.6.1'
|
||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||
androidxCoreVersion = '1.12.0'
|
||||
androidxFragmentVersion = '1.6.2'
|
||||
coreSplashScreenVersion = '1.0.1'
|
||||
androidxWebkitVersion = '1.9.0'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.1.5'
|
||||
androidxEspressoCoreVersion = '3.5.1'
|
||||
cordovaAndroidVersion = '10.1.1'
|
||||
}
|
||||
25
ionic-app/capacitor.config.json
Normal file
25
ionic-app/capacitor.config.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"appId": "com.bidding.app",
|
||||
"appName": "BiddingApp",
|
||||
"webDir": "dist",
|
||||
"server": {
|
||||
"androidScheme": "https"
|
||||
},
|
||||
"plugins": {
|
||||
"SplashScreen": {
|
||||
"launchShowDuration": 0,
|
||||
"launchAutoHide": true,
|
||||
"backgroundColor": "#3880ff",
|
||||
"androidSplashResourceName": "splash",
|
||||
"androidScaleType": "CENTER_CROP",
|
||||
"showSpinner": true,
|
||||
"androidSpinnerStyle": "large",
|
||||
"iosSpinnerStyle": "small",
|
||||
"spinnerColor": "#3880ff",
|
||||
"splashFullScreen": true,
|
||||
"splashImmersive": true,
|
||||
"layoutName": "launch_screen",
|
||||
"useDialog": true
|
||||
}
|
||||
}
|
||||
}
|
||||
29
ionic-app/index.html
Normal file
29
ionic-app/index.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>投标项目查看器</title>
|
||||
|
||||
<base href="/" />
|
||||
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="msapplication-tap-highlight" content="no" />
|
||||
|
||||
<link rel="icon" type="image/png" href="/favicon.ico" />
|
||||
|
||||
<!-- add to homescreen for ios -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="api-footer" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 99999; background: var(--ion-toolbar-background, #f8f9fa); padding: 10px; text-align: center; font-size: 12px; color: var(--ion-text-color, #333); border-top: 1px solid #ddd;"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
7
ionic-app/ionic.config.json
Normal file
7
ionic-app/ionic.config.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "bidding-looker-ionic",
|
||||
"integrations": {
|
||||
"capacitor": {}
|
||||
},
|
||||
"type": "vue"
|
||||
}
|
||||
36
ionic-app/package.json
Normal file
36
ionic-app/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "bidding-looker-ionic",
|
||||
"version": "1.0.0",
|
||||
"description": "投标项目查看器 - Ionic 版本",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"ionic:build": "npm run build",
|
||||
"ionic:serve": "vite --port 8100 --host",
|
||||
"cap:sync": "capacitor sync",
|
||||
"cap:open:android": "capacitor open android"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^6.1.2",
|
||||
"@capacitor/app": "^6.0.1",
|
||||
"@capacitor/core": "^6.0.0",
|
||||
"@ionic/vue": "^8.3.2",
|
||||
"@ionic/vue-router": "^8.3.2",
|
||||
"axios": "^1.7.9",
|
||||
"ionicons": "^7.4.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^6.0.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
}
|
||||
6
ionic-app/postcss.config.js
Normal file
6
ionic-app/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
31
ionic-app/src/App.vue
Normal file
31
ionic-app/src/App.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { IonApp, IonRouterOutlet } from '@ionic/vue'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
const apiHost = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001'
|
||||
|
||||
onMounted(() => {
|
||||
const footer = document.getElementById('api-footer')
|
||||
if (footer) {
|
||||
footer.textContent = `连接: ${apiHost}`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IonApp>
|
||||
<IonRouterOutlet />
|
||||
</IonApp>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
219
ionic-app/src/components/AiRecommendations.vue
Normal file
219
ionic-app/src/components/AiRecommendations.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { IonCard, IonCardHeader, IonCardSubtitle, IonCardTitle, IonCardContent, IonChip, IonLabel, IonSpinner, IonRefresher, IonRefresherContent, RefresherEventDetail } from '@ionic/vue'
|
||||
import { getAiRecommendations, togglePin } from '@/utils/api'
|
||||
import type { AiRecommendation } from '@/types'
|
||||
|
||||
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: any) {
|
||||
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 '低'
|
||||
}
|
||||
|
||||
const handleRefresh = async (event: CustomEvent<RefresherEventDetail>) => {
|
||||
await loadRecommendations()
|
||||
const target = event.target as any
|
||||
if (target && target.complete) {
|
||||
target.complete()
|
||||
}
|
||||
}
|
||||
|
||||
const handleTogglePin = async (item: AiRecommendation) => {
|
||||
try {
|
||||
const newPinStatus = !item.pin
|
||||
await togglePin(item.title, newPinStatus)
|
||||
item.pin = newPinStatus
|
||||
} catch (err) {
|
||||
console.error('切换置顶状态失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateStr?: string): string => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRecommendations()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
loadRecommendations
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IonRefresher slot="fixed" @ionRefresh="handleRefresh">
|
||||
<IonRefresherContent></IonRefresherContent>
|
||||
</IonRefresher>
|
||||
|
||||
<div v-if="loading" class="loading-container">
|
||||
<IonSpinner name="crescent" />
|
||||
<p class="loading-text">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-container">
|
||||
<p class="error-text">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="recommendations.length === 0" class="empty-container">
|
||||
<p class="empty-text">暂无推荐项目</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="recommendation-list">
|
||||
<IonCard v-for="item in recommendations" :key="item.id" class="recommendation-card">
|
||||
<IonCardHeader>
|
||||
<div class="card-header">
|
||||
<IonChip :style="{ backgroundColor: getConfidenceColor(item.confidence) }" class="confidence-chip">
|
||||
<IonLabel class="confidence-label">
|
||||
{{ getConfidenceLabel(item.confidence) }} ({{ item.confidence }}%)
|
||||
</IonLabel>
|
||||
</IonChip>
|
||||
<IonCardSubtitle class="date-text">{{ formatDate(item.publishDate) }}</IonCardSubtitle>
|
||||
</div>
|
||||
<IonCardTitle class="title-text">{{ item.title }}</IonCardTitle>
|
||||
</IonCardHeader>
|
||||
<IonCardContent>
|
||||
<div class="card-footer">
|
||||
<span class="source-text">{{ item.source }}</span>
|
||||
<button
|
||||
@click="handleTogglePin(item)"
|
||||
:class="['pin-button', { pinned: item.pin }]"
|
||||
>
|
||||
{{ item.pin ? '已置顶' : '置顶' }}
|
||||
</button>
|
||||
</div>
|
||||
</IonCardContent>
|
||||
</IonCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loading-container,
|
||||
.error-container,
|
||||
.empty-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-text,
|
||||
.error-text,
|
||||
.empty-text {
|
||||
margin-top: 16px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.recommendation-list {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.recommendation-card {
|
||||
margin: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.confidence-chip {
|
||||
color: #fff;
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.confidence-label {
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.date-text {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.source-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.pin-button {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pin-button.pinned {
|
||||
background: #e74c3c;
|
||||
color: #fff;
|
||||
border-color: #e74c3c;
|
||||
}
|
||||
|
||||
.pin-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
345
ionic-app/src/components/CrawlInfo.vue
Normal file
345
ionic-app/src/components/CrawlInfo.vue
Normal file
@@ -0,0 +1,345 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { IonCard, IonCardHeader, IonCardTitle, IonCardContent, IonBadge, IonSpinner, IonRefresher, IonRefresherContent, IonButton, RefresherEventDetail } from '@ionic/vue'
|
||||
import { getCrawlInfoStats, crawlSingleSource } from '@/utils/api'
|
||||
import type { CrawlInfoStat } from '@/types'
|
||||
|
||||
const crawlStats = ref<CrawlInfoStat[]>([])
|
||||
const loading = ref<boolean>(true)
|
||||
const error = ref<string>('')
|
||||
const updatingSources = ref<Set<string>>(new Set())
|
||||
|
||||
const loadCrawlStats = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
const stats = await getCrawlInfoStats()
|
||||
crawlStats.value = stats || []
|
||||
} catch (err: any) {
|
||||
error.value = `加载失败: ${err.message || err}`
|
||||
console.error('加载爬虫统计信息失败:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateSource = async (sourceName: string) => {
|
||||
if (updatingSources.value.has(sourceName)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
updatingSources.value.add(sourceName)
|
||||
await crawlSingleSource(sourceName)
|
||||
// 更新成功后重新加载统计数据
|
||||
await loadCrawlStats()
|
||||
} catch (err: any) {
|
||||
console.error(`更新 ${sourceName} 失败:`, err)
|
||||
alert(`更新失败: ${err.message || err}`)
|
||||
} finally {
|
||||
updatingSources.value.delete(sourceName)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (stat: CrawlInfoStat): string => {
|
||||
if (stat.error) {
|
||||
return '出错'
|
||||
}
|
||||
if (stat.count > 0) {
|
||||
return '正常'
|
||||
}
|
||||
return '无数据'
|
||||
}
|
||||
|
||||
const getStatusColor = (stat: CrawlInfoStat): string => {
|
||||
if (stat.error) {
|
||||
return '#f8d7da'
|
||||
}
|
||||
if (stat.count > 0) {
|
||||
return '#d4edda'
|
||||
}
|
||||
return '#e2e3e5'
|
||||
}
|
||||
|
||||
const getStatusTextColor = (stat: CrawlInfoStat): string => {
|
||||
if (stat.error) {
|
||||
return '#721c24'
|
||||
}
|
||||
if (stat.count > 0) {
|
||||
return '#155724'
|
||||
}
|
||||
return '#383d41'
|
||||
}
|
||||
|
||||
const handleRefresh = async (event: CustomEvent<RefresherEventDetail>) => {
|
||||
await loadCrawlStats()
|
||||
const target = event.target as any
|
||||
if (target && target.complete) {
|
||||
target.complete()
|
||||
}
|
||||
}
|
||||
|
||||
const formatDateTime = (dateStr: string | null): string => {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
const totalCount = computed(() => {
|
||||
return crawlStats.value.reduce((sum: number, item: CrawlInfoStat) => sum + item.count, 0)
|
||||
})
|
||||
|
||||
const activeSources = computed(() => {
|
||||
return crawlStats.value.filter((item: CrawlInfoStat) => item.count > 0).length
|
||||
})
|
||||
|
||||
const errorSources = computed(() => {
|
||||
return crawlStats.value.filter((item: CrawlInfoStat) => item.error && item.error.trim()).length
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadCrawlStats()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
loadCrawlStats
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IonRefresher slot="fixed" @ionRefresh="handleRefresh">
|
||||
<IonRefresherContent></IonRefresherContent>
|
||||
</IonRefresher>
|
||||
|
||||
<div v-if="loading" class="loading-container">
|
||||
<IonSpinner name="crescent" />
|
||||
<p class="loading-text">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-container">
|
||||
<p class="error-text">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="crawlStats.length === 0" class="empty-container">
|
||||
<p class="empty-text">暂无爬虫统计信息</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<IonCard class="summary-card">
|
||||
<IonCardHeader>
|
||||
<IonCardTitle>统计摘要</IonCardTitle>
|
||||
</IonCardHeader>
|
||||
<IonCardContent>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">爬虫来源总数</span>
|
||||
<span class="summary-value">{{ crawlStats.length }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">本次获取总数</span>
|
||||
<span class="summary-value">{{ totalCount }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">有数据来源</span>
|
||||
<span class="summary-value">{{ activeSources }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">出错来源</span>
|
||||
<span class="summary-value error">{{ errorSources }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</IonCardContent>
|
||||
</IonCard>
|
||||
|
||||
<div class="crawl-list">
|
||||
<IonCard v-for="stat in crawlStats" :key="stat.source" class="crawl-card">
|
||||
<IonCardContent class="crawl-card-content">
|
||||
<div class="crawl-header">
|
||||
<span class="source-name">{{ stat.source }}</span>
|
||||
<div class="header-actions">
|
||||
<IonBadge
|
||||
:style="{
|
||||
backgroundColor: getStatusColor(stat),
|
||||
color: getStatusTextColor(stat)
|
||||
}"
|
||||
class="status-badge"
|
||||
>
|
||||
{{ getStatusText(stat) }}
|
||||
</IonBadge>
|
||||
<IonButton
|
||||
size="small"
|
||||
fill="outline"
|
||||
:disabled="updatingSources.has(stat.source)"
|
||||
@click="handleUpdateSource(stat.source)"
|
||||
class="update-button"
|
||||
>
|
||||
<span v-if="updatingSources.has(stat.source)">更新中...</span>
|
||||
<span v-else>更新</span>
|
||||
</IonButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="crawl-details">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">本次获取数量:</span>
|
||||
<span class="detail-value">{{ stat.count }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">最近更新时间:</span>
|
||||
<span class="detail-value">{{ formatDateTime(stat.latestUpdate) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">最新工程时间:</span>
|
||||
<span class="detail-value">{{ formatDateTime(stat.latestPublishDate) }}</span>
|
||||
</div>
|
||||
<div v-if="stat.error && stat.error.trim()" class="detail-item error">
|
||||
<span class="detail-label">错误信息:</span>
|
||||
<span class="detail-value">{{ stat.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</IonCardContent>
|
||||
</IonCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loading-container,
|
||||
.error-container,
|
||||
.empty-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-text,
|
||||
.error-text,
|
||||
.empty-text {
|
||||
margin-top: 16px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.summary-value.error {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.crawl-list {
|
||||
padding: 0 16px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.crawl-card {
|
||||
margin: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.crawl-card-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.crawl-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.source-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.update-button {
|
||||
--padding-start: 8px;
|
||||
--padding-end: 8px;
|
||||
--padding-top: 4px;
|
||||
--padding-bottom: 4px;
|
||||
font-size: 12px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.crawl-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-item.error {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
199
ionic-app/src/components/PinnedBids.vue
Normal file
199
ionic-app/src/components/PinnedBids.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { IonCard, IonCardHeader, IonCardTitle, IonCardContent, IonSpinner, IonRefresher, IonRefresherContent, RefresherEventDetail } from '@ionic/vue'
|
||||
import { getPinnedBids, togglePin } from '@/utils/api'
|
||||
import type { BidItem } from '@/types'
|
||||
|
||||
const pinnedBids = ref<BidItem[]>([])
|
||||
const loading = ref<boolean>(true)
|
||||
const error = ref<string>('')
|
||||
|
||||
const loadPinnedBids = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
const items = await getPinnedBids()
|
||||
pinnedBids.value = items || []
|
||||
} catch (err: any) {
|
||||
error.value = `加载失败: ${err.message || err}`
|
||||
console.error('加载置顶项目失败:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = async (event: CustomEvent<RefresherEventDetail>) => {
|
||||
await loadPinnedBids()
|
||||
const target = event.target as any
|
||||
if (target && target.complete) {
|
||||
target.complete()
|
||||
}
|
||||
}
|
||||
|
||||
const handleTogglePin = async (item: BidItem) => {
|
||||
try {
|
||||
await togglePin(item.title, false)
|
||||
pinnedBids.value = pinnedBids.value.filter((bid: BidItem) => bid.title !== item.title)
|
||||
} catch (err) {
|
||||
console.error('取消置顶失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string): string => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPinnedBids()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
loadPinnedBids
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IonRefresher slot="fixed" @ionRefresh="handleRefresh">
|
||||
<IonRefresherContent></IonRefresherContent>
|
||||
</IonRefresher>
|
||||
|
||||
<div v-if="loading" class="loading-container">
|
||||
<IonSpinner name="crescent" />
|
||||
<p class="loading-text">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-container">
|
||||
<p class="error-text">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="pinnedBids.length === 0" class="empty-container">
|
||||
<p class="empty-text">暂无置顶项目</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="pinned-list">
|
||||
<IonCard v-for="item in pinnedBids" :key="item.id" class="pinned-card">
|
||||
<IonCardHeader>
|
||||
<IonCardTitle class="title-text">{{ item.title }}</IonCardTitle>
|
||||
</IonCardHeader>
|
||||
<IonCardContent>
|
||||
<div class="card-details">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">来源:</span>
|
||||
<span class="detail-value">{{ item.source }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">发布日期:</span>
|
||||
<span class="detail-value">{{ formatDate(item.publishDate) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<a :href="item.url" target="_blank" class="view-link">查看详情</a>
|
||||
<button @click="handleTogglePin(item)" class="unpin-button">取消置顶</button>
|
||||
</div>
|
||||
</IonCardContent>
|
||||
</IonCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loading-container,
|
||||
.error-container,
|
||||
.empty-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-text,
|
||||
.error-text,
|
||||
.empty-text {
|
||||
margin-top: 16px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.pinned-list {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pinned-card {
|
||||
margin: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.card-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.view-link {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.unpin-button {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid #e74c3c;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
color: #e74c3c;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.unpin-button:active {
|
||||
transform: scale(0.95);
|
||||
background: #e74c3c;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
28
ionic-app/src/main.ts
Normal file
28
ionic-app/src/main.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createApp } from 'vue'
|
||||
import { IonicVue } from '@ionic/vue'
|
||||
|
||||
/* Core CSS required for Ionic components to work properly */
|
||||
import '@ionic/vue/css/core.css'
|
||||
|
||||
/* Basic CSS for apps built with Ionic */
|
||||
import '@ionic/vue/css/normalize.css'
|
||||
import '@ionic/vue/css/structure.css'
|
||||
import '@ionic/vue/css/typography.css'
|
||||
import '@ionic/vue/css/padding.css'
|
||||
import '@ionic/vue/css/float-elements.css'
|
||||
import '@ionic/vue/css/text-alignment.css'
|
||||
import '@ionic/vue/css/text-transformation.css'
|
||||
import '@ionic/vue/css/flex-utils.css'
|
||||
import '@ionic/vue/css/display.css'
|
||||
|
||||
/* Theme variables */
|
||||
import './theme/variables.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(IonicVue)
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
81
ionic-app/src/pages/HomePage.vue
Normal file
81
ionic-app/src/pages/HomePage.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonSegment, IonSegmentButton, IonIcon, IonLabel, IonButtons, IonButton } from '@ionic/vue'
|
||||
import { sparkles, statsChart, pin, logOutOutline } from 'ionicons/icons'
|
||||
import AiRecommendations from '@/components/AiRecommendations.vue'
|
||||
import CrawlInfo from '@/components/CrawlInfo.vue'
|
||||
import PinnedBids from '@/components/PinnedBids.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const activeTab = ref('pinned')
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const aiRecommendationsRef = ref()
|
||||
const crawlInfoRef = ref()
|
||||
const pinnedBidsRef = ref()
|
||||
|
||||
const handleTabChange = (tab: string) => {
|
||||
activeTab.value = tab
|
||||
// 刷新对应标签页的数据
|
||||
if (tab === 'recommendations' && aiRecommendationsRef.value) {
|
||||
aiRecommendationsRef.value.loadRecommendations()
|
||||
} else if (tab === 'crawl' && crawlInfoRef.value) {
|
||||
crawlInfoRef.value.loadCrawlStats()
|
||||
} else if (tab === 'pinned' && pinnedBidsRef.value) {
|
||||
pinnedBidsRef.value.loadPinnedBids()
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout()
|
||||
// 路由守卫会自动跳转到登录页
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IonPage>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>投标项目查看器</IonTitle>
|
||||
<IonButtons slot="end">
|
||||
<IonButton @click="handleLogout">
|
||||
<IonIcon :icon="logOutOutline" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent>
|
||||
<IonSegment :value="activeTab" @ionChange="(e: any) => handleTabChange(e.detail.value)" class="segment-container">
|
||||
<IonSegmentButton value="pinned">
|
||||
<IonIcon :icon="pin" />
|
||||
<IonLabel>置顶项目</IonLabel>
|
||||
</IonSegmentButton>
|
||||
<IonSegmentButton value="recommendations">
|
||||
<IonIcon :icon="sparkles" />
|
||||
<IonLabel>AI 推荐</IonLabel>
|
||||
</IonSegmentButton>
|
||||
<IonSegmentButton value="crawl">
|
||||
<IonIcon :icon="statsChart" />
|
||||
<IonLabel>爬虫信息</IonLabel>
|
||||
</IonSegmentButton>
|
||||
|
||||
</IonSegment>
|
||||
|
||||
<div class="tab-content">
|
||||
<AiRecommendations v-if="activeTab === 'recommendations'" ref="aiRecommendationsRef" />
|
||||
<CrawlInfo v-if="activeTab === 'crawl'" ref="crawlInfoRef" />
|
||||
<PinnedBids v-if="activeTab === 'pinned'" ref="pinnedBidsRef" />
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.segment-container {
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
</style>
|
||||
121
ionic-app/src/pages/LoginPage.vue
Normal file
121
ionic-app/src/pages/LoginPage.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonItem, IonLabel, IonInput, IonButton, IonCard, IonCardHeader, IonCardTitle, IonCardContent, IonSpinner } from '@ionic/vue'
|
||||
import { useIonRouter } from '@ionic/vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useIonRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!username.value || !password.value) {
|
||||
error.value = '请输入用户名和密码'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await authStore.login(username.value, password.value)
|
||||
router.push('/home')
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '登录失败,请检查用户名和密码'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IonPage>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>登录</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent class="login-content">
|
||||
<IonCard class="login-card">
|
||||
<IonCardHeader>
|
||||
<IonCardTitle>投标项目查看器</IonCardTitle>
|
||||
</IonCardHeader>
|
||||
<IonCardContent>
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<IonItem class="input-item">
|
||||
<IonLabel position="floating">用户名</IonLabel>
|
||||
<IonInput
|
||||
v-model="username"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
<IonItem class="input-item">
|
||||
<IonLabel position="floating">密码</IonLabel>
|
||||
<IonInput
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
@click="handleLogin"
|
||||
:disabled="loading"
|
||||
class="login-button"
|
||||
>
|
||||
<IonSpinner v-if="loading" name="crescent" slot="start" />
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</IonButton>
|
||||
</IonCardContent>
|
||||
</IonCard>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-content {
|
||||
--background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
margin: 20px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
margin-top: 24px;
|
||||
--background: #667eea;
|
||||
--color: #fff;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
42
ionic-app/src/router/index.ts
Normal file
42
ionic-app/src/router/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { createRouter, createWebHistory } from '@ionic/vue-router'
|
||||
import HomePage from '@/pages/HomePage.vue'
|
||||
import LoginPage from '@/pages/LoginPage.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/login'
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
component: LoginPage
|
||||
},
|
||||
{
|
||||
path: '/home',
|
||||
component: HomePage,
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.state.value.isAuthenticated) {
|
||||
// 需要认证但未登录,跳转到登录页
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && authStore.state.value.isAuthenticated) {
|
||||
// 已登录用户访问登录页,跳转到首页
|
||||
next('/home')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
79
ionic-app/src/stores/auth.ts
Normal file
79
ionic-app/src/stores/auth.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ref } from 'vue'
|
||||
import api from '@/utils/api'
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean
|
||||
username: string | null
|
||||
token: string | null
|
||||
}
|
||||
|
||||
const state = ref<AuthState>({
|
||||
isAuthenticated: false,
|
||||
username: null,
|
||||
token: null
|
||||
})
|
||||
|
||||
export const useAuthStore = () => {
|
||||
const login = async (username: string, password: string) => {
|
||||
// 创建 Basic Auth token
|
||||
const credentials = btoa(`${username}:${password}`)
|
||||
const token = `Basic ${credentials}`
|
||||
|
||||
// 保存 token 到 localStorage
|
||||
localStorage.setItem('auth_token', token)
|
||||
localStorage.setItem('auth_username', username)
|
||||
|
||||
// 更新 API 实例的默认 headers
|
||||
api.defaults.headers.common['Authorization'] = token
|
||||
|
||||
// 更新状态
|
||||
state.value = {
|
||||
isAuthenticated: true,
|
||||
username,
|
||||
token
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
// 清除 localStorage
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('auth_username')
|
||||
|
||||
// 清除 API headers
|
||||
delete api.defaults.headers.common['Authorization']
|
||||
|
||||
// 更新状态
|
||||
state.value = {
|
||||
isAuthenticated: false,
|
||||
username: null,
|
||||
token: null
|
||||
}
|
||||
}
|
||||
|
||||
const checkAuth = () => {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
const username = localStorage.getItem('auth_username')
|
||||
|
||||
if (token && username) {
|
||||
api.defaults.headers.common['Authorization'] = token
|
||||
state.value = {
|
||||
isAuthenticated: true,
|
||||
username,
|
||||
token
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 初始化时检查认证状态
|
||||
checkAuth()
|
||||
|
||||
return {
|
||||
state,
|
||||
login,
|
||||
logout,
|
||||
checkAuth
|
||||
}
|
||||
}
|
||||
229
ionic-app/src/theme/variables.css
Normal file
229
ionic-app/src/theme/variables.css
Normal file
@@ -0,0 +1,229 @@
|
||||
/* Ionic Variables and Theming. For more info, please see:
|
||||
http://ionicframework.com/docs/theming/theming-base-variables */
|
||||
|
||||
/** Ionic CSS Variables **/
|
||||
:root {
|
||||
/** primary **/
|
||||
--ion-color-primary: #3880ff;
|
||||
--ion-color-primary-rgb: 56, 128, 255;
|
||||
--ion-color-primary-contrast: #ffffff;
|
||||
--ion-color-primary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-primary-shade: #3171e0;
|
||||
--ion-color-primary-tint: #4c8dff;
|
||||
|
||||
/** secondary **/
|
||||
--ion-color-secondary: #3dc2ff;
|
||||
--ion-color-secondary-rgb: 61, 194, 255;
|
||||
--ion-color-secondary-contrast: #ffffff;
|
||||
--ion-color-secondary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-secondary-shade: #36abe0;
|
||||
--ion-color-secondary-tint: #50c8ff;
|
||||
|
||||
/** tertiary **/
|
||||
--ion-color-tertiary: #5260ff;
|
||||
--ion-color-tertiary-rgb: 82, 96, 255;
|
||||
--ion-color-tertiary-contrast: #ffffff;
|
||||
--ion-color-tertiary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-tertiary-shade: #4854e0;
|
||||
--ion-color-tertiary-tint: #6370ff;
|
||||
|
||||
/** success **/
|
||||
--ion-color-success: #2dd36f;
|
||||
--ion-color-success-rgb: 45, 211, 111;
|
||||
--ion-color-success-contrast: #ffffff;
|
||||
--ion-color-success-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-success-shade: #28ba62;
|
||||
--ion-color-success-tint: #42d77d;
|
||||
|
||||
/** warning **/
|
||||
--ion-color-warning: #ffc409;
|
||||
--ion-color-warning-rgb: 255, 196, 9;
|
||||
--ion-color-warning-contrast: #000000;
|
||||
--ion-color-warning-contrast-rgb: 0, 0, 0;
|
||||
--ion-color-warning-shade: #e0ac08;
|
||||
--ion-color-warning-tint: #ffca22;
|
||||
|
||||
/** danger **/
|
||||
--ion-color-danger: #eb445a;
|
||||
--ion-color-danger-rgb: 235, 68, 90;
|
||||
--ion-color-danger-contrast: #ffffff;
|
||||
--ion-color-danger-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-danger-shade: #cf3c4f;
|
||||
--ion-color-danger-tint: #ed576b;
|
||||
|
||||
/** dark **/
|
||||
--ion-color-dark: #222428;
|
||||
--ion-color-dark-rgb: 34, 36, 40;
|
||||
--ion-color-dark-contrast: #ffffff;
|
||||
--ion-color-dark-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-dark-shade: #1e2023;
|
||||
--ion-color-dark-tint: #383a3e;
|
||||
|
||||
/** medium **/
|
||||
--ion-color-medium: #92949c;
|
||||
--ion-color-medium-rgb: 146, 148, 156;
|
||||
--ion-color-medium-contrast: #ffffff;
|
||||
--ion-color-medium-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-medium-shade: #808289;
|
||||
--ion-color-medium-tint: #9d9fa6;
|
||||
|
||||
/** light **/
|
||||
--ion-color-light: #f4f5f8;
|
||||
--ion-color-light-rgb: 244, 245, 248;
|
||||
--ion-color-light-contrast: #000000;
|
||||
--ion-color-light-contrast-rgb: 0, 0, 0;
|
||||
--ion-color-light-shade: #d7d8da;
|
||||
--ion-color-light-tint: #f5f6f9;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/*
|
||||
* Dark Colors
|
||||
* -------------------------------------------
|
||||
*/
|
||||
|
||||
body.dark {
|
||||
--ion-color-primary: #428cff;
|
||||
--ion-color-primary-rgb: 66, 140, 255;
|
||||
--ion-color-primary-contrast: #ffffff;
|
||||
--ion-color-primary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-primary-shade: #3a7be0;
|
||||
--ion-color-primary-tint: #5598ff;
|
||||
|
||||
--ion-color-secondary: #50c8ff;
|
||||
--ion-color-secondary-rgb: 80, 200, 255;
|
||||
--ion-color-secondary-contrast: #ffffff;
|
||||
--ion-color-secondary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-secondary-shade: #46b0e0;
|
||||
--ion-color-secondary-tint: #62ceff;
|
||||
|
||||
--ion-color-tertiary: #6a64ff;
|
||||
--ion-color-tertiary-rgb: 106, 100, 255;
|
||||
--ion-color-tertiary-contrast: #ffffff;
|
||||
--ion-color-tertiary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-tertiary-shade: #5d58e0;
|
||||
--ion-color-tertiary-tint: #7974ff;
|
||||
|
||||
--ion-color-success: #2fdf75;
|
||||
--ion-color-success-rgb: 47, 223, 117;
|
||||
--ion-color-success-contrast: #000000;
|
||||
--ion-color-success-contrast-rgb: 0, 0, 0;
|
||||
--ion-color-success-shade: #29c467;
|
||||
--ion-color-success-tint: #44e283;
|
||||
|
||||
--ion-color-warning: #ffd534;
|
||||
--ion-color-warning-rgb: 255, 213, 52;
|
||||
--ion-color-warning-contrast: #000000;
|
||||
--ion-color-warning-contrast-rgb: 0, 0, 0;
|
||||
--ion-color-warning-shade: #e0bb2e;
|
||||
--ion-color-warning-tint: #ffd948;
|
||||
|
||||
--ion-color-danger: #ff4961;
|
||||
--ion-color-danger-rgb: 255, 73, 97;
|
||||
--ion-color-danger-contrast: #ffffff;
|
||||
--ion-color-danger-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-danger-shade: #e04055;
|
||||
--ion-color-danger-tint: #ff5b71;
|
||||
|
||||
--ion-color-dark: #f4f5f8;
|
||||
--ion-color-dark-rgb: 244, 245, 248;
|
||||
--ion-color-dark-contrast: #000000;
|
||||
--ion-color-dark-contrast-rgb: 0, 0, 0;
|
||||
--ion-color-dark-shade: #d7d8da;
|
||||
--ion-color-dark-tint: #f5f6f9;
|
||||
|
||||
--ion-color-medium: #989aa2;
|
||||
--ion-color-medium-rgb: 152, 154, 162;
|
||||
--ion-color-medium-contrast: #000000;
|
||||
--ion-color-medium-contrast-rgb: 0, 0, 0;
|
||||
--ion-color-medium-shade: #86888f;
|
||||
--ion-color-medium-tint: #a2a4ab;
|
||||
|
||||
--ion-color-light: #222428;
|
||||
--ion-color-light-rgb: 34, 36, 40;
|
||||
--ion-color-light-contrast: #ffffff;
|
||||
--ion-color-light-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-light-shade: #1e2023;
|
||||
--ion-color-light-tint: #383a3e;
|
||||
}
|
||||
|
||||
/*
|
||||
* iOS Dark Theme
|
||||
* -------------------------------------------
|
||||
*/
|
||||
|
||||
.ios body.dark {
|
||||
--ion-background-color: #03060b;
|
||||
--ion-background-color-rgb: 3, 6, 11;
|
||||
|
||||
--ion-text-color: #ffffff;
|
||||
--ion-text-color-rgb: 255, 255, 255;
|
||||
|
||||
--ion-color-step-50: #0d0d0d;
|
||||
--ion-color-step-100: #1a1a1a;
|
||||
--ion-color-step-150: #262626;
|
||||
--ion-color-step-200: #333333;
|
||||
--ion-color-step-250: #404040;
|
||||
--ion-color-step-300: #4d4d4d;
|
||||
--ion-color-step-350: #595959;
|
||||
--ion-color-step-400: #666666;
|
||||
--ion-color-step-450: #737373;
|
||||
--ion-color-step-500: #808080;
|
||||
--ion-color-step-550: #8c8c8c;
|
||||
--ion-color-step-600: #999999;
|
||||
--ion-color-step-650: #a6a6a6;
|
||||
--ion-color-step-700: #b3b3b3;
|
||||
--ion-color-step-750: #bfbfbf;
|
||||
--ion-color-step-800: #cccccc;
|
||||
--ion-color-step-850: #d9d9d9;
|
||||
--ion-color-step-900: #e6e6e6;
|
||||
--ion-color-step-950: #f2f2f2;
|
||||
|
||||
--ion-item-background: #000000;
|
||||
|
||||
--ion-card-background: #1c1c1d;
|
||||
}
|
||||
|
||||
/*
|
||||
* Material Design Dark Theme
|
||||
* -------------------------------------------
|
||||
*/
|
||||
|
||||
.md body.dark {
|
||||
--ion-background-color: #121212;
|
||||
--ion-background-color-rgb: 18, 18, 18;
|
||||
|
||||
--ion-text-color: #ffffff;
|
||||
--ion-text-color-rgb: 255, 255, 255;
|
||||
|
||||
--ion-border-color: #222222;
|
||||
|
||||
--ion-color-step-50: #1e1e1e;
|
||||
--ion-color-step-100: #2a2a2a;
|
||||
--ion-color-step-150: #363636;
|
||||
--ion-color-step-200: #414141;
|
||||
--ion-color-step-250: #4d4d4d;
|
||||
--ion-color-step-300: #595959;
|
||||
--ion-color-step-350: #656565;
|
||||
--ion-color-step-400: #717171;
|
||||
--ion-color-step-450: #7d7d7d;
|
||||
--ion-color-step-500: #898989;
|
||||
--ion-color-step-550: #949494;
|
||||
--ion-color-step-600: #a0a0a0;
|
||||
--ion-color-step-650: #acacac;
|
||||
--ion-color-step-700: #b8b8b8;
|
||||
--ion-color-step-750: #c4c4c4;
|
||||
--ion-color-step-800: #d0d0d0;
|
||||
--ion-color-step-850: #dbdbdb;
|
||||
--ion-color-step-900: #e7e7e7;
|
||||
--ion-color-step-950: #f3f3f3;
|
||||
|
||||
--ion-item-background: #1e1e1e;
|
||||
|
||||
--ion-toolbar-background: #1f1f1f;
|
||||
|
||||
--ion-tab-bar-background: #1f1f1f;
|
||||
|
||||
--ion-card-background: #1e1e1e;
|
||||
}
|
||||
}
|
||||
40
ionic-app/src/types/index.ts
Normal file
40
ionic-app/src/types/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// 投标项目类型
|
||||
export interface BidItem {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
publishDate: string
|
||||
source: string
|
||||
pin: boolean
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
// AI 推荐类型
|
||||
export interface AiRecommendation {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
source: string
|
||||
confidence: number
|
||||
publishDate?: string
|
||||
pin?: boolean
|
||||
}
|
||||
|
||||
// 爬虫统计信息类型
|
||||
export interface CrawlInfoStat {
|
||||
source: string
|
||||
count: number
|
||||
latestUpdate: string | null
|
||||
latestPublishDate: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
// API 响应类型
|
||||
export interface ApiResponse<T> {
|
||||
data: T
|
||||
message?: string
|
||||
}
|
||||
|
||||
// 日期范围类型
|
||||
export type DateRange = [string, string] | null
|
||||
97
ionic-app/src/utils/api.ts
Normal file
97
ionic-app/src/utils/api.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import axios from 'axios'
|
||||
import type { BidItem, AiRecommendation, CrawlInfoStat, DateRange } from '@/types'
|
||||
|
||||
// 从环境变量读取 API 基础地址
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001'
|
||||
|
||||
// 创建 axios 实例
|
||||
const api = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
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 function getPinnedBids(): Promise<BidItem[]> {
|
||||
return api.get('/api/bids/pinned').then(res => res.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最新 AI 推荐
|
||||
*/
|
||||
export function getAiRecommendations(): Promise<AiRecommendation[]> {
|
||||
return api.get('/api/ai/latest-recommendations').then(res => res.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取爬虫统计信息
|
||||
*/
|
||||
export function getCrawlInfoStats(): Promise<CrawlInfoStat[]> {
|
||||
return api.get('/api/bids/crawl-info-stats').then(res => res.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 爬取单个数据源
|
||||
*/
|
||||
export function crawlSingleSource(sourceName: string): Promise<any> {
|
||||
return api.post(`/api/crawler/crawl/${encodeURIComponent(sourceName)}`).then(res => res.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换置顶状态
|
||||
*/
|
||||
export function togglePin(title: string, pin: boolean): Promise<void> {
|
||||
return api.patch(`/api/bids/${encodeURIComponent(title)}/pin`, { pin }).then(res => res.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 按日期范围获取工程
|
||||
*/
|
||||
export function getBidsByDateRange(startDate: string, endDate?: string): Promise<BidItem[]> {
|
||||
const params: any = { startDate }
|
||||
if (endDate) {
|
||||
params.endDate = endDate
|
||||
}
|
||||
return api.get('/api/bids/by-date-range', { params }).then(res => res.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 AI 推荐(发送 bids 数据)
|
||||
*/
|
||||
export function fetchAiRecommendations(bids: { title: string }[]): Promise<AiRecommendation[]> {
|
||||
return api.post('/api/ai/recommendations', { bids }).then(res => res.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 AI 推荐结果
|
||||
*/
|
||||
export function saveAiRecommendations(recommendations: AiRecommendation[]): Promise<void> {
|
||||
return api.post('/api/ai/save-recommendations', { recommendations }).then(res => res.data)
|
||||
}
|
||||
|
||||
export default api
|
||||
9
ionic-app/src/vite-env.d.ts
vendored
Normal file
9
ionic-app/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
11
ionic-app/tailwind.config.js
Normal file
11
ionic-app/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
25
ionic-app/tsconfig.json
Normal file
25
ionic-app/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
ionic-app/tsconfig.node.json
Normal file
11
ionic-app/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
19
ionic-app/vite.config.ts
Normal file
19
ionic-app/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 8100,
|
||||
host: true
|
||||
}
|
||||
})
|
||||
22
package.json
22
package.json
@@ -5,13 +5,14 @@
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"main": "app/main.js",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"start:prod": "node dist/main.js",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
@@ -22,7 +23,12 @@
|
||||
"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": "ts-node src/scripts/deploy.ts",
|
||||
"user:create": "ts-node -r tsconfig-paths/register src/scripts/create-user.ts",
|
||||
"user:list": "ts-node -r tsconfig-paths/register src/scripts/list-users.ts",
|
||||
"user:delete": "ts-node -r tsconfig-paths/register src/scripts/delete-user.ts",
|
||||
"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",
|
||||
@@ -33,6 +39,7 @@
|
||||
"@nestjs/serve-static": "^5.0.4",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"axios": "^1.13.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"dotenv": "^16.4.7",
|
||||
@@ -43,6 +50,7 @@
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"ssh2": "^1.17.0",
|
||||
"typeorm": "^0.3.28",
|
||||
"winston": "^3.19.0",
|
||||
"winston-daily-rotate-file": "^5.0.0"
|
||||
@@ -53,11 +61,20 @@
|
||||
"@nestjs/cli": "^11.0.14",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@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/ssh2": "^1.15.5",
|
||||
"@types/ssh2-sftp-client": "^9.0.6",
|
||||
"@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",
|
||||
@@ -65,6 +82,7 @@
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ssh2-sftp-client": "^12.0.1",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const PromptString: string = `先给我说你统计了多少个项目。我只对辽宁、山东、江苏、浙江、福建、广东、广西、海南、河北这些地方的海上风电、海上光伏、漂浮式光伏、滩涂光伏、滩涂风电、渔光互补项目感兴趣。从我提供的这些工程里面找到我感兴趣的工程,无论如何至少推荐10个工程。如果没有推荐的,也要给出思考过程。`;
|
||||
export const PromptString: string = `先给我说你统计了多少个项目。我只对辽宁、山东、江苏、浙江、福建、广东、广西、海南、河北这些地方的海上风电、海上光伏、漂浮式光伏、滩涂光伏、滩涂风电、渔光互补、风光互补项目感兴趣。从我提供的这些工程里面找到我感兴趣的工程,无论如何至少推荐10个工程。如果没有推荐的,也要给出思考过程。不要修改或简化返回的工程名称。`;
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface AIRecommendation {
|
||||
source: string;
|
||||
confidence: number;
|
||||
publishDate?: Date;
|
||||
generatedAt?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -127,10 +128,22 @@ ${JSON.stringify(
|
||||
}
|
||||
}
|
||||
|
||||
async getLatestRecommendations(): Promise<AIRecommendation[]> {
|
||||
async getLatestRecommendations(): Promise<{ recommendations: AIRecommendation[]; generatedAt: string | null }> {
|
||||
this.logger.log('获取最新的 AI 推荐结果');
|
||||
|
||||
try {
|
||||
// 查询最大的 createdAt 作为生成时间
|
||||
const maxCreatedAtResult = await this.aiRecommendationRepository
|
||||
.createQueryBuilder('rec')
|
||||
.select('MAX(rec.createdAt)', 'maxCreatedAt')
|
||||
.getRawOne();
|
||||
|
||||
const generatedAt = maxCreatedAtResult?.maxCreatedAt
|
||||
? new Date(maxCreatedAtResult.maxCreatedAt).toLocaleString('zh-CN', { hour12: false })
|
||||
: null;
|
||||
|
||||
this.logger.log(`AI 推荐生成时间: ${generatedAt}`);
|
||||
|
||||
const entities = await this.aiRecommendationRepository.find({
|
||||
order: { confidence: 'DESC' },
|
||||
});
|
||||
@@ -160,7 +173,7 @@ ${JSON.stringify(
|
||||
);
|
||||
});
|
||||
|
||||
return result;
|
||||
return { recommendations: result, generatedAt };
|
||||
} catch (error) {
|
||||
this.logger.error('获取最新 AI 推荐失败:', error);
|
||||
throw error;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
@@ -9,7 +9,10 @@ import { KeywordsModule } from './keywords/keywords.module';
|
||||
import { CrawlerModule } from './crawler/crawler.module';
|
||||
import { TasksModule } from './schedule/schedule.module';
|
||||
import { LoggerModule } from './common/logger/logger.module';
|
||||
import { LoggingMiddleware } from './common/logger/logging.middleware';
|
||||
import { AiModule } from './ai/ai.module';
|
||||
import { AuthModule } from './common/auth/auth.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -20,6 +23,8 @@ import { AiModule } from './ai/ai.module';
|
||||
exclude: ['/api'],
|
||||
}),
|
||||
LoggerModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
DatabaseModule,
|
||||
BidsModule,
|
||||
KeywordsModule,
|
||||
@@ -28,4 +33,8 @@ import { AiModule } from './ai/ai.module';
|
||||
AiModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(LoggingMiddleware).forRoutes('*');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, LessThan } from 'typeorm';
|
||||
import { BidItem } from '../entities/bid-item.entity';
|
||||
import { CrawlInfoAdd } from '../../crawler/entities/crawl-info-add.entity';
|
||||
import {
|
||||
getDaysAgo,
|
||||
setStartOfDay,
|
||||
setEndOfDay,
|
||||
utcToBeijing,
|
||||
utcToBeijingISOString,
|
||||
} from '../../common/utils/timezone.util';
|
||||
|
||||
interface FindAllQuery {
|
||||
page?: number;
|
||||
@@ -15,10 +22,10 @@ interface SourceResult {
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface CrawlInfoAddStats {
|
||||
export interface CrawlInfoAddStats {
|
||||
source: string;
|
||||
count: number;
|
||||
latestUpdate: Date | string;
|
||||
latestUpdate: Date | string | null;
|
||||
latestPublishDate: Date | string | null;
|
||||
error: string | null;
|
||||
}
|
||||
@@ -73,8 +80,7 @@ export class BidsService {
|
||||
}
|
||||
|
||||
async cleanOldData() {
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
const thirtyDaysAgo = getDaysAgo(30);
|
||||
return this.bidRepository.delete({
|
||||
createdAt: LessThan(thirtyDaysAgo),
|
||||
});
|
||||
@@ -91,9 +97,7 @@ export class BidsService {
|
||||
}
|
||||
|
||||
async getRecentBids() {
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
thirtyDaysAgo.setHours(0, 0, 0, 0);
|
||||
const thirtyDaysAgo = setStartOfDay(getDaysAgo(30));
|
||||
|
||||
return this.bidRepository
|
||||
.createQueryBuilder('bid')
|
||||
@@ -118,14 +122,12 @@ export class BidsService {
|
||||
const qb = this.bidRepository.createQueryBuilder('bid');
|
||||
|
||||
if (startDate) {
|
||||
const start = new Date(startDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const start = setStartOfDay(new Date(startDate));
|
||||
qb.andWhere('bid.publishDate >= :startDate', { startDate: start });
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
const end = new Date(endDate);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
const end = setEndOfDay(new Date(endDate));
|
||||
qb.andWhere('bid.publishDate <= :endDate', { endDate: end });
|
||||
}
|
||||
|
||||
@@ -177,16 +179,27 @@ export class BidsService {
|
||||
const results =
|
||||
await this.crawlInfoRepository.query<CrawlInfoAddRawResult[]>(query);
|
||||
|
||||
return results.map((item) => ({
|
||||
source: String(item.source),
|
||||
count: Number(item.count),
|
||||
latestUpdate: item.latestUpdate,
|
||||
latestPublishDate: item.latestPublishDate,
|
||||
// 确保 error 字段正确处理:null 或空字符串都转换为 null,非空字符串保留
|
||||
error:
|
||||
item.error && String(item.error).trim() !== ''
|
||||
? String(item.error)
|
||||
: null,
|
||||
}));
|
||||
return results.map((item) => {
|
||||
// 将UTC时间转换为北京时间的ISO字符串格式
|
||||
// 这样前端接收到的时间字符串已经是正确的北京时间,不需要再次转换
|
||||
const latestUpdateBeijing = item.latestUpdate
|
||||
? utcToBeijingISOString(new Date(item.latestUpdate))
|
||||
: null;
|
||||
const latestPublishDateBeijing = item.latestPublishDate
|
||||
? utcToBeijingISOString(new Date(item.latestPublishDate))
|
||||
: null;
|
||||
|
||||
return {
|
||||
source: String(item.source),
|
||||
count: Number(item.count),
|
||||
latestUpdate: latestUpdateBeijing,
|
||||
latestPublishDate: latestPublishDateBeijing,
|
||||
// 确保 error 字段正确处理:null 或空字符串都转换为 null,非空字符串保留
|
||||
error:
|
||||
item.error && String(item.error).trim() !== ''
|
||||
? String(item.error)
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
329
src/common/auth/auth.guard.spec.ts
Normal file
329
src/common/auth/auth.guard.spec.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { AuthGuard } from './auth.guard';
|
||||
|
||||
describe('AuthGuard', () => {
|
||||
let guard: AuthGuard;
|
||||
let configService: ConfigService;
|
||||
let mockExecutionContext: ExecutionContext;
|
||||
let mockRequest: Partial<Request>;
|
||||
|
||||
const createMockExecutionContext = (request: Partial<Request>): ExecutionContext => {
|
||||
return {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request as Request,
|
||||
getResponse: () => ({}),
|
||||
getNext: () => ({}),
|
||||
}),
|
||||
} as ExecutionContext;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthGuard,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
guard = module.get<AuthGuard>(AuthGuard);
|
||||
configService = module.get<ConfigService>(ConfigService);
|
||||
});
|
||||
|
||||
describe('本地IP访问', () => {
|
||||
it('应该允许 127.0.0.1 访问', () => {
|
||||
mockRequest = {
|
||||
ip: '127.0.0.1',
|
||||
socket: { remoteAddress: '127.0.0.1' },
|
||||
headers: {},
|
||||
};
|
||||
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||
|
||||
const result = guard.canActivate(mockExecutionContext);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('应该允许 ::1 (IPv6本地地址) 访问', () => {
|
||||
mockRequest = {
|
||||
ip: '::1',
|
||||
socket: { remoteAddress: '::1' },
|
||||
headers: {},
|
||||
};
|
||||
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||
|
||||
const result = guard.canActivate(mockExecutionContext);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('应该允许 localhost 访问', () => {
|
||||
mockRequest = {
|
||||
ip: 'localhost',
|
||||
socket: { remoteAddress: 'localhost' },
|
||||
headers: {},
|
||||
};
|
||||
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||
|
||||
const result = guard.canActivate(mockExecutionContext);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('应该允许通过 X-Forwarded-For 传递的本地IP访问', () => {
|
||||
mockRequest = {
|
||||
ip: '192.168.1.1',
|
||||
socket: { remoteAddress: '192.168.1.1' },
|
||||
headers: {
|
||||
'x-forwarded-for': '127.0.0.1',
|
||||
},
|
||||
};
|
||||
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||
|
||||
const result = guard.canActivate(mockExecutionContext);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('公网IP访问 - 已配置API_KEY', () => {
|
||||
const validApiKey = 'test-api-key-12345';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(configService, 'get').mockReturnValue(validApiKey);
|
||||
});
|
||||
|
||||
it('应该允许提供正确API Key的公网访问', () => {
|
||||
mockRequest = {
|
||||
ip: '8.8.8.8',
|
||||
socket: { remoteAddress: '8.8.8.8' },
|
||||
headers: {
|
||||
'x-api-key': validApiKey,
|
||||
},
|
||||
};
|
||||
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||
|
||||
const result = guard.canActivate(mockExecutionContext);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('应该拒绝未提供API Key的公网访问', () => {
|
||||
mockRequest = {
|
||||
ip: '8.8.8.8',
|
||||
socket: { remoteAddress: '8.8.8.8' },
|
||||
headers: {},
|
||||
};
|
||||
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||
|
||||
expect(() => guard.canActivate(mockExecutionContext)).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(() => guard.canActivate(mockExecutionContext)).toThrow(
|
||||
'Invalid or missing API Key',
|
||||
);
|
||||
});
|
||||
|
||||
it('应该拒绝提供错误API Key的公网访问', () => {
|
||||
mockRequest = {
|
||||
ip: '8.8.8.8',
|
||||
socket: { remoteAddress: '8.8.8.8' },
|
||||
headers: {
|
||||
'x-api-key': 'wrong-api-key',
|
||||
},
|
||||
};
|
||||
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||
|
||||
expect(() => guard.canActivate(mockExecutionContext)).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(() => guard.canActivate(mockExecutionContext)).toThrow(
|
||||
'Invalid or missing API Key',
|
||||
);
|
||||
});
|
||||
|
||||
it('应该拒绝提供空字符串API Key的公网访问', () => {
|
||||
mockRequest = {
|
||||
ip: '8.8.8.8',
|
||||
socket: { remoteAddress: '8.8.8.8' },
|
||||
headers: {
|
||||
'x-api-key': '',
|
||||
},
|
||||
};
|
||||
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||
|
||||
expect(() => guard.canActivate(mockExecutionContext)).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该正确处理 X-Forwarded-For 中的公网IP', () => {
|
||||
mockRequest = {
|
||||
ip: '127.0.0.1',
|
||||
socket: { remoteAddress: '127.0.0.1' },
|
||||
headers: {
|
||||
'x-forwarded-for': '8.8.8.8',
|
||||
'x-api-key': validApiKey,
|
||||
},
|
||||
};
|
||||
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||
|
||||
const result = guard.canActivate(mockExecutionContext);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('应该正确处理多个IP的 X-Forwarded-For 头(取第一个)', () => {
|
||||
mockRequest = {
|
||||
ip: '127.0.0.1',
|
||||
socket: { remoteAddress: '127.0.0.1' },
|
||||
headers: {
|
||||
'x-forwarded-for': '8.8.8.8, 192.168.1.1',
|
||||
'x-api-key': validApiKey,
|
||||
},
|
||||
};
|
||||
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||
|
||||
const result = guard.canActivate(mockExecutionContext);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('公网IP访问 - 未配置API_KEY', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(configService, 'get').mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it('应该允许所有公网访问(开发环境)', () => {
|
||||
mockRequest = {
|
||||
ip: '8.8.8.8',
|
||||
socket: { remoteAddress: '8.8.8.8' },
|
||||
headers: {},
|
||||
};
|
||||
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||
|
||||
const result = guard.canActivate(mockExecutionContext);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('应该允许未提供API Key的公网访问', () => {
|
||||
mockRequest = {
|
||||
ip: '8.8.8.8',
|
||||
socket: { remoteAddress: '8.8.8.8' },
|
||||
headers: {},
|
||||
};
|
||||
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||
|
||||
const result = guard.canActivate(mockExecutionContext);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('内网IP访问', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(configService, 'get').mockReturnValue('test-api-key');
|
||||
});
|
||||
|
||||
it('应该要求内网IP提供API Key', () => {
|
||||
mockRequest = {
|
||||
ip: '192.168.1.100',
|
||||
socket: { remoteAddress: '192.168.1.100' },
|
||||
headers: {},
|
||||
};
|
||||
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||
|
||||
expect(() => guard.canActivate(mockExecutionContext)).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该允许提供正确API Key的内网访问', () => {
|
||||
mockRequest = {
|
||||
ip: '192.168.1.100',
|
||||
socket: { remoteAddress: '192.168.1.100' },
|
||||
headers: {
|
||||
'x-api-key': 'test-api-key',
|
||||
},
|
||||
};
|
||||
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||
|
||||
const result = guard.canActivate(mockExecutionContext);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('应该要求 10.x.x.x 网段提供API Key', () => {
|
||||
mockRequest = {
|
||||
ip: '10.0.0.1',
|
||||
socket: { remoteAddress: '10.0.0.1' },
|
||||
headers: {},
|
||||
};
|
||||
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||
|
||||
expect(() => guard.canActivate(mockExecutionContext)).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该要求 172.16-31.x.x 网段提供API Key', () => {
|
||||
mockRequest = {
|
||||
ip: '172.16.0.1',
|
||||
socket: { remoteAddress: '172.16.0.1' },
|
||||
headers: {},
|
||||
};
|
||||
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||
|
||||
expect(() => guard.canActivate(mockExecutionContext)).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界情况', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(configService, 'get').mockReturnValue('test-api-key');
|
||||
});
|
||||
|
||||
it('应该处理 unknown IP 地址', () => {
|
||||
mockRequest = {
|
||||
ip: 'unknown',
|
||||
socket: { remoteAddress: 'unknown' },
|
||||
headers: {},
|
||||
};
|
||||
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||
|
||||
expect(() => guard.canActivate(mockExecutionContext)).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该处理缺少 IP 信息的请求', () => {
|
||||
mockRequest = {
|
||||
ip: undefined,
|
||||
socket: { remoteAddress: undefined },
|
||||
headers: {},
|
||||
};
|
||||
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||
|
||||
expect(() => guard.canActivate(mockExecutionContext)).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该处理 API Key 大小写敏感', () => {
|
||||
jest.spyOn(configService, 'get').mockReturnValue('Test-API-Key');
|
||||
mockRequest = {
|
||||
ip: '8.8.8.8',
|
||||
socket: { remoteAddress: '8.8.8.8' },
|
||||
headers: {
|
||||
'x-api-key': 'test-api-key', // 小写,应该被拒绝
|
||||
},
|
||||
};
|
||||
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||
|
||||
expect(() => guard.canActivate(mockExecutionContext)).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
72
src/common/auth/auth.guard.ts
Normal file
72
src/common/auth/auth.guard.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request } from 'express';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(AuthGuard.name);
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private usersService: UsersService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
// 检查是否启用 Basic Auth
|
||||
const enableBasicAuth =
|
||||
this.configService.get<string>('ENABLE_BASIC_AUTH') === 'true';
|
||||
|
||||
this.logger.log(`Basic Auth enabled: ${enableBasicAuth}`);
|
||||
|
||||
if (!enableBasicAuth) {
|
||||
// 如果未启用 Basic Auth,允许所有访问
|
||||
return true;
|
||||
}
|
||||
|
||||
// 解析 Authorization header
|
||||
const authHeader = request.headers['authorization'] as string;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
||||
this.logger.warn('Missing or invalid Authorization header');
|
||||
throw new UnauthorizedException('Missing or invalid Authorization header');
|
||||
}
|
||||
|
||||
// 解码 Basic Auth
|
||||
const base64Credentials = authHeader.split(' ')[1];
|
||||
const credentials = Buffer.from(base64Credentials, 'base64').toString(
|
||||
'utf-8',
|
||||
);
|
||||
const [username, password] = credentials.split(':');
|
||||
|
||||
if (!username || !password) {
|
||||
this.logger.warn('Invalid credentials format');
|
||||
throw new UnauthorizedException('Invalid credentials format');
|
||||
}
|
||||
|
||||
this.logger.log(`Attempting login for user: ${username}`);
|
||||
|
||||
// 验证用户
|
||||
const user = await this.usersService.validateUser(username, password);
|
||||
|
||||
if (!user) {
|
||||
this.logger.warn(`Login failed for user: ${username} - Invalid username or password`);
|
||||
throw new UnauthorizedException('Invalid username or password');
|
||||
}
|
||||
|
||||
this.logger.log(`User ${username} logged in successfully`);
|
||||
|
||||
// 将用户信息附加到请求对象
|
||||
(request as any).user = user;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
15
src/common/auth/auth.module.ts
Normal file
15
src/common/auth/auth.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AuthGuard } from './auth.guard';
|
||||
import { UsersModule } from '../../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: AuthGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
29
src/common/logger/logging.middleware.ts
Normal file
29
src/common/logger/logging.middleware.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { CustomLogger } from './logger.service';
|
||||
|
||||
@Injectable()
|
||||
export class LoggingMiddleware implements NestMiddleware {
|
||||
constructor(private readonly logger: CustomLogger) {
|
||||
this.logger.setContext('HTTP');
|
||||
}
|
||||
|
||||
use(req: Request, res: Response, next: NextFunction) {
|
||||
const { method, originalUrl, ip } = req;
|
||||
const userAgent = req.get('user-agent') || '';
|
||||
const startTime = Date.now();
|
||||
|
||||
// 收到请求时立即输出
|
||||
this.logger.debug(`--> ${method} ${originalUrl} - ${ip} - ${userAgent}`);
|
||||
|
||||
res.on('finish', () => {
|
||||
const { statusCode } = res;
|
||||
const duration = Date.now() - startTime;
|
||||
this.logger.debug(
|
||||
`<-- ${method} ${originalUrl} ${statusCode} - ${duration}ms`,
|
||||
);
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,6 @@ const appLogTransport = new DailyRotateFile({
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '20m',
|
||||
maxFiles: '30d',
|
||||
format: logFormat,
|
||||
});
|
||||
|
||||
// 错误日志传输(按天轮转)
|
||||
@@ -68,16 +67,19 @@ 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,
|
||||
});
|
||||
|
||||
143
src/common/utils/timezone.util.ts
Normal file
143
src/common/utils/timezone.util.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 时区工具函数
|
||||
* 统一处理东八区(Asia/Shanghai)时间相关的操作
|
||||
*/
|
||||
|
||||
const TIMEZONE_OFFSET = 8 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 将北京时间(+8)转换为UTC
|
||||
* 用于将爬取的北京时间字符串解析后的Date对象转为UTC存储
|
||||
* @param date 北京时间的Date对象
|
||||
* @returns UTC时间的Date对象
|
||||
*/
|
||||
export function beijingToUtc(date: Date): Date {
|
||||
return new Date(date.getTime() - TIMEZONE_OFFSET);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将UTC时间转换为北京时间(+8)
|
||||
* 用于将数据库中的UTC时间转为北京时间显示
|
||||
* @param date UTC时间的Date对象
|
||||
* @returns 北京时间的Date对象
|
||||
*/
|
||||
export function utcToBeijing(date: Date): Date {
|
||||
return new Date(date.getTime() + TIMEZONE_OFFSET);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前时间的东八区Date对象
|
||||
* @returns Date 当前时间的东八区表示
|
||||
*/
|
||||
export function getCurrentDateInTimezone(): Date {
|
||||
const now = new Date();
|
||||
const utc = now.getTime() + now.getTimezoneOffset() * 60 * 1000;
|
||||
return new Date(utc + TIMEZONE_OFFSET);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将任意Date对象转换为东八区时间
|
||||
* @param date 原始Date对象
|
||||
* @returns Date 转换后的东八区时间
|
||||
*/
|
||||
export function convertToTimezone(date: Date): Date {
|
||||
const utc = date.getTime() + date.getTimezoneOffset() * 60 * 1000;
|
||||
return new Date(utc + TIMEZONE_OFFSET);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期为 YYYY-MM-DD 格式
|
||||
* @param date Date对象
|
||||
* @returns 格式化后的日期字符串
|
||||
*/
|
||||
export function formatDate(date: Date): string {
|
||||
const timezoneDate = convertToTimezone(date);
|
||||
const year = timezoneDate.getFullYear();
|
||||
const month = String(timezoneDate.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(timezoneDate.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期时间为 YYYY-MM-DD HH:mm:ss 格式
|
||||
* @param date Date对象
|
||||
* @returns 格式化后的日期时间字符串
|
||||
*/
|
||||
export function formatDateTime(date: Date): string {
|
||||
const timezoneDate = convertToTimezone(date);
|
||||
const year = timezoneDate.getFullYear();
|
||||
const month = String(timezoneDate.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(timezoneDate.getDate()).padStart(2, '0');
|
||||
const hours = String(timezoneDate.getHours()).padStart(2, '0');
|
||||
const minutes = String(timezoneDate.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(timezoneDate.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置时间为当天的开始时间 (00:00:00.000)
|
||||
* @param date Date对象
|
||||
* @returns 设置后的Date对象
|
||||
*/
|
||||
export function setStartOfDay(date: Date): Date {
|
||||
const timezoneDate = convertToTimezone(date);
|
||||
timezoneDate.setHours(0, 0, 0, 0);
|
||||
return timezoneDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置时间为当天的结束时间 (23:59:59.999)
|
||||
* @param date Date对象
|
||||
* @returns 设置后的Date对象
|
||||
*/
|
||||
export function setEndOfDay(date: Date): Date {
|
||||
const timezoneDate = convertToTimezone(date);
|
||||
timezoneDate.setHours(23, 59, 59, 999);
|
||||
return timezoneDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定天数前的日期
|
||||
* @param days 天数
|
||||
* @returns 指定天数前的Date对象
|
||||
*/
|
||||
export function getDaysAgo(days: number): Date {
|
||||
const date = getCurrentDateInTimezone();
|
||||
date.setDate(date.getDate() - days);
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析日期字符串为东八区Date对象
|
||||
* @param dateStr 日期字符串 (支持 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss 格式)
|
||||
* @returns 解析后的Date对象
|
||||
*/
|
||||
export function parseDateString(dateStr: string): Date {
|
||||
const date = new Date(dateStr);
|
||||
return convertToTimezone(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将UTC时间转换为北京时间的ISO字符串格式
|
||||
* 用于API返回,确保前端接收到的时间字符串已经是北京时间
|
||||
* @param date UTC时间的Date对象
|
||||
* @returns 北京时间的ISO字符串 (格式: YYYY-MM-DDTHH:mm:ss+08:00)
|
||||
*/
|
||||
export function utcToBeijingISOString(date: Date): string {
|
||||
// 获取UTC时间戳(毫秒)
|
||||
const utcTimestamp = date.getTime();
|
||||
// 计算北京时间戳(UTC + 8小时)
|
||||
const beijingTimestamp = utcTimestamp + TIMEZONE_OFFSET;
|
||||
// 创建UTC Date对象来格式化(避免本地时区影响)
|
||||
const beijingDate = new Date(beijingTimestamp);
|
||||
|
||||
// 使用UTC方法获取时间组件,确保不受本地时区影响
|
||||
const year = beijingDate.getUTCFullYear();
|
||||
const month = String(beijingDate.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(beijingDate.getUTCDate()).padStart(2, '0');
|
||||
const hours = String(beijingDate.getUTCHours()).padStart(2, '0');
|
||||
const minutes = String(beijingDate.getUTCMinutes()).padStart(2, '0');
|
||||
const seconds = String(beijingDate.getUTCSeconds()).padStart(2, '0');
|
||||
const milliseconds = String(beijingDate.getUTCMilliseconds()).padStart(3, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${milliseconds}+08:00`;
|
||||
}
|
||||
@@ -49,7 +49,17 @@ export class CrawlerController {
|
||||
this.crawlingSources.add(sourceName);
|
||||
|
||||
try {
|
||||
// 设置状态为正在更新(count = -1)
|
||||
await this.crawlerService.updateCrawlStatus(sourceName, -1);
|
||||
|
||||
const result = await this.crawlerService.crawlSingleSource(sourceName);
|
||||
|
||||
// 更新状态为实际数量
|
||||
await this.crawlerService.updateCrawlStatus(
|
||||
sourceName,
|
||||
result.success ? result.count : 0,
|
||||
);
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
this.crawlingSources.delete(sourceName);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as puppeteer from 'puppeteer';
|
||||
import { BidsService } from '../../bids/services/bid.service';
|
||||
import { beijingToUtc } from '../../common/utils/timezone.util';
|
||||
import { CrawlInfoAdd } from '../entities/crawl-info-add.entity';
|
||||
import { ChdtpCrawler } from './chdtp_target';
|
||||
import { ChngCrawler } from './chng_target';
|
||||
@@ -24,10 +25,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 +60,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 +124,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}`,
|
||||
);
|
||||
@@ -137,19 +147,21 @@ export class BidCrawlerService {
|
||||
: null;
|
||||
|
||||
for (const item of results) {
|
||||
// 将北京时间转换为UTC存储
|
||||
const publishDateUtc = beijingToUtc(new Date(item.publishDate));
|
||||
await this.bidsService.createOrUpdate({
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
publishDate: item.publishDate,
|
||||
publishDate: publishDateUtc,
|
||||
source: crawler.name,
|
||||
});
|
||||
}
|
||||
|
||||
// 保存爬虫统计信息到数据库
|
||||
// 保存爬虫统计信息到数据库(将北京时间转为UTC)
|
||||
await this.saveCrawlInfo(
|
||||
crawler.name,
|
||||
results.length,
|
||||
latestPublishDate,
|
||||
latestPublishDate ? beijingToUtc(latestPublishDate) : null,
|
||||
);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
@@ -184,7 +196,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}`,
|
||||
);
|
||||
@@ -210,11 +222,11 @@ export class BidCrawlerService {
|
||||
});
|
||||
}
|
||||
|
||||
// 更新爬虫统计信息到数据库
|
||||
// 更新爬虫统计信息到数据库(将北京时间转为UTC)
|
||||
await this.saveCrawlInfo(
|
||||
crawler.name,
|
||||
results.length,
|
||||
latestPublishDate,
|
||||
latestPublishDate ? beijingToUtc(latestPublishDate) : null,
|
||||
);
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
@@ -337,7 +349,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}`,
|
||||
);
|
||||
@@ -352,19 +364,21 @@ export class BidCrawlerService {
|
||||
: null;
|
||||
|
||||
for (const item of results) {
|
||||
// 将北京时间转换为UTC存储
|
||||
const publishDateUtc = beijingToUtc(new Date(item.publishDate));
|
||||
await this.bidsService.createOrUpdate({
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
publishDate: item.publishDate,
|
||||
publishDate: publishDateUtc,
|
||||
source: targetCrawler.name,
|
||||
});
|
||||
}
|
||||
|
||||
// 保存爬虫统计信息到数据库
|
||||
// 保存爬虫统计信息到数据库(将北京时间转为UTC)
|
||||
await this.saveCrawlInfo(
|
||||
targetCrawler.name,
|
||||
results.length,
|
||||
latestPublishDate,
|
||||
latestPublishDate ? beijingToUtc(latestPublishDate) : null,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -416,4 +430,38 @@ export class BidCrawlerService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新爬虫状态,count = -1 表示正在更新
|
||||
async updateCrawlStatus(source: string, count: number) {
|
||||
try {
|
||||
// 使用原生查询实现 upsert 逻辑
|
||||
await this.crawlInfoRepository.manager.transaction(
|
||||
async (manager) => {
|
||||
// 检查记录是否存在
|
||||
const existing = await manager.findOne(CrawlInfoAdd, {
|
||||
where: { source },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// 更新现有记录
|
||||
await manager.update(CrawlInfoAdd, { source }, { count });
|
||||
} else {
|
||||
// 插入新记录
|
||||
await manager.save(CrawlInfoAdd, {
|
||||
source,
|
||||
count,
|
||||
latestPublishDate: null,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
this.logger.log(`Updated crawl status for ${source}: ${count}`);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(
|
||||
`Failed to update crawl status for ${source}: ${errorMessage}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,17 @@ 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 +340,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,17 @@ 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 +245,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,17 @@ 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 +232,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();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,50 @@
|
||||
import * as puppeteer from 'puppeteer';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
async function simulateHumanMouseMovement(page: puppeteer.Page) {
|
||||
const viewport = page.viewport();
|
||||
if (!viewport) return;
|
||||
|
||||
const movements = 5 + Math.floor(Math.random() * 5);
|
||||
|
||||
for (let i = 0; i < movements; i++) {
|
||||
const x = Math.floor(Math.random() * viewport.width);
|
||||
const y = Math.floor(Math.random() * viewport.height);
|
||||
|
||||
await page.mouse.move(x, y, {
|
||||
steps: 10 + Math.floor(Math.random() * 20),
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 100 + Math.random() * 400));
|
||||
}
|
||||
}
|
||||
|
||||
async function simulateHumanScrolling(page: puppeteer.Page) {
|
||||
const scrollCount = 3 + Math.floor(Math.random() * 5);
|
||||
|
||||
for (let i = 0; i < scrollCount; i++) {
|
||||
const scrollDistance = 100 + Math.floor(Math.random() * 400);
|
||||
|
||||
await page.evaluate((distance) => {
|
||||
window.scrollBy({
|
||||
top: distance,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, scrollDistance);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 500 + Math.random() * 1000));
|
||||
}
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
export interface ChdtpResult {
|
||||
title: string;
|
||||
publishDate: Date;
|
||||
url: string; // Necessary for system uniqueness
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface ChdtpCrawlerType {
|
||||
@@ -14,6 +54,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 +132,23 @@ 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,
|
||||
);
|
||||
|
||||
logger.log('Simulating human mouse movements...');
|
||||
await simulateHumanMouseMovement(page);
|
||||
|
||||
logger.log('Simulating human scrolling...');
|
||||
await simulateHumanScrolling(page);
|
||||
|
||||
while (currentPage <= maxPages) {
|
||||
const content = await page.content();
|
||||
@@ -58,6 +164,12 @@ export const ChdtpCrawler = {
|
||||
`Extracted ${pageResults.length} items from page ${currentPage}`,
|
||||
);
|
||||
|
||||
logger.log('Simulating human mouse movements before pagination...');
|
||||
await simulateHumanMouseMovement(page);
|
||||
|
||||
logger.log('Simulating human scrolling before pagination...');
|
||||
await simulateHumanScrolling(page);
|
||||
|
||||
// Find the "Next Page" button
|
||||
// Using partial match for src to be robust against path variations
|
||||
const nextButtonSelector = 'input[type="image"][src*="page-next.png"]';
|
||||
@@ -68,9 +180,6 @@ export const ChdtpCrawler = {
|
||||
break;
|
||||
}
|
||||
|
||||
// Optional: Check if the button is disabled (though image inputs usually aren't "disabled" in the same way)
|
||||
// For this specific site, we'll try to click.
|
||||
|
||||
logger.log(`Navigating to page ${currentPage + 1}...`);
|
||||
|
||||
try {
|
||||
@@ -92,6 +201,12 @@ export const ChdtpCrawler = {
|
||||
|
||||
currentPage++;
|
||||
|
||||
logger.log('Simulating human mouse movements after pagination...');
|
||||
await simulateHumanMouseMovement(page);
|
||||
|
||||
logger.log('Simulating human scrolling after pagination...');
|
||||
await simulateHumanScrolling(page);
|
||||
|
||||
// Random delay between pages
|
||||
const delay = Math.floor(Math.random() * (3000 - 1000 + 1)) + 1000;
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
@@ -102,7 +217,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,17 @@ 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 +231,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,17 @@ 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 +232,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,17 @@ 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 +232,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,17 @@ 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 +232,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,17 @@ 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 +242,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,17 @@ 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 +251,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,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
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';
|
||||
import { AiRecommendation } from '../ai/entities/ai-recommendation.entity';
|
||||
import { Keyword } from '../keywords/keyword.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -22,10 +23,19 @@ 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: [
|
||||
User,
|
||||
BidItem,
|
||||
CrawlInfoAdd,
|
||||
AiRecommendation,
|
||||
Keyword,
|
||||
__dirname + '/../**/*.entity{.ts,.js}',
|
||||
],
|
||||
synchronize: false,
|
||||
timezone: 'Z',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
exports: [TypeOrmModule],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dotenv/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { CustomLogger } from './common/logger/logger.service';
|
||||
@@ -20,6 +21,12 @@ async function bootstrap() {
|
||||
// 启用 CORS
|
||||
app.enableCors();
|
||||
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
// 信任代理(用于获取真实客户端 IP)
|
||||
const httpAdapter = app.getHttpAdapter();
|
||||
httpAdapter.getInstance().set('trust proxy', true);
|
||||
|
||||
const host = process.env.HOST ?? '0.0.0.0';
|
||||
const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
|
||||
await app.listen(port, host);
|
||||
}
|
||||
void bootstrap();
|
||||
|
||||
100
src/scripts/create-user.ts
Normal file
100
src/scripts/create-user.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import 'dotenv/config';
|
||||
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
// 数据库配置
|
||||
const dbConfig: DataSourceOptions = {
|
||||
type: (process.env.DATABASE_TYPE as any) || 'mysql',
|
||||
host: process.env.DATABASE_HOST || 'localhost',
|
||||
port: parseInt(process.env.DATABASE_PORT || '3306'),
|
||||
username: process.env.DATABASE_USERNAME || 'root',
|
||||
password: process.env.DATABASE_PASSWORD || 'root',
|
||||
database: process.env.DATABASE_NAME || 'bidding',
|
||||
entities: [User],
|
||||
synchronize: false,
|
||||
};
|
||||
|
||||
// 日志工具
|
||||
const logger = {
|
||||
log: (message: string, ...args: any[]) => {
|
||||
console.log(`[INFO] ${new Date().toISOString()} - ${message}`, ...args);
|
||||
},
|
||||
error: (message: string, ...args: any[]) => {
|
||||
console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, ...args);
|
||||
},
|
||||
};
|
||||
|
||||
// 主函数
|
||||
async function createUser() {
|
||||
let dataSource: DataSource | null = null;
|
||||
|
||||
try {
|
||||
// 从命令行参数获取用户名和密码
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length < 2) {
|
||||
console.log('用法: npm run user:create <用户名> <密码>');
|
||||
console.log('示例: npm run user:create admin password123');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const username = args[0];
|
||||
const password = args[1];
|
||||
|
||||
if (!username || username.trim().length === 0) {
|
||||
logger.error('用户名不能为空');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!password || password.length === 0) {
|
||||
logger.error('密码不能为空');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.log('开始创建用户...');
|
||||
|
||||
// 创建数据库连接
|
||||
dataSource = new DataSource(dbConfig);
|
||||
await dataSource.initialize();
|
||||
logger.log('数据库连接成功');
|
||||
|
||||
// 检查用户名是否已存在
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const existingUser = await userRepository.findOne({
|
||||
where: { username },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
logger.error(`用户名 ${username} 已存在`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// 创建用户
|
||||
const user = userRepository.create({
|
||||
username,
|
||||
password: hashedPassword,
|
||||
});
|
||||
|
||||
await userRepository.save(user);
|
||||
|
||||
logger.log(`用户 ${username} 创建成功!`);
|
||||
logger.log(`用户 ID: ${user.id}`);
|
||||
logger.log(`创建时间: ${user.createdAt}`);
|
||||
|
||||
await dataSource.destroy();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error('创建用户失败:', error);
|
||||
if (dataSource && dataSource.isInitialized) {
|
||||
await dataSource.destroy();
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行创建用户
|
||||
createUser();
|
||||
78
src/scripts/delete-user.ts
Normal file
78
src/scripts/delete-user.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'dotenv/config';
|
||||
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
|
||||
// 数据库配置
|
||||
const dbConfig: DataSourceOptions = {
|
||||
type: (process.env.DATABASE_TYPE as any) || 'mysql',
|
||||
host: process.env.DATABASE_HOST || 'localhost',
|
||||
port: parseInt(process.env.DATABASE_PORT || '3306'),
|
||||
username: process.env.DATABASE_USERNAME || 'root',
|
||||
password: process.env.DATABASE_PASSWORD || 'root',
|
||||
database: process.env.DATABASE_NAME || 'bidding',
|
||||
entities: [User],
|
||||
synchronize: false,
|
||||
};
|
||||
|
||||
// 日志工具
|
||||
const logger = {
|
||||
log: (message: string, ...args: any[]) => {
|
||||
console.log(`[INFO] ${new Date().toISOString()} - ${message}`, ...args);
|
||||
},
|
||||
error: (message: string, ...args: any[]) => {
|
||||
console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, ...args);
|
||||
},
|
||||
};
|
||||
|
||||
// 主函数
|
||||
async function deleteUser() {
|
||||
let dataSource: DataSource | null = null;
|
||||
|
||||
try {
|
||||
// 获取命令行参数
|
||||
const usernameToDelete = process.argv[2];
|
||||
|
||||
if (!usernameToDelete) {
|
||||
logger.error('请提供要删除的用户名');
|
||||
console.log('用法: npm run user:delete <username>');
|
||||
console.log('示例: npm run user:delete testuser');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.log(`开始删除用户: ${usernameToDelete}...`);
|
||||
|
||||
// 创建数据库连接
|
||||
dataSource = new DataSource(dbConfig);
|
||||
await dataSource.initialize();
|
||||
logger.log('数据库连接成功');
|
||||
|
||||
// 获取用户
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const user = await userRepository.findOne({
|
||||
where: { username: usernameToDelete },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
logger.error(`用户 "${usernameToDelete}" 不存在`);
|
||||
await dataSource.destroy();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
await userRepository.delete(user.id);
|
||||
|
||||
logger.log(`用户 "${usernameToDelete}" 删除成功!`);
|
||||
|
||||
await dataSource.destroy();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error('删除用户失败:', error);
|
||||
if (dataSource && dataSource.isInitialized) {
|
||||
await dataSource.destroy();
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行删除用户
|
||||
deleteUser();
|
||||
@@ -1,73 +0,0 @@
|
||||
# Deploy script - Upload files to remote server using scp
|
||||
|
||||
# Configuration
|
||||
$remoteHost = "127.0.0.1"
|
||||
$remotePort = "1122"
|
||||
$remoteUser = "cubie"
|
||||
$keyPath = "d:\163"
|
||||
$serverDest = "/home/cubie/down/document/bidding/publish/server"
|
||||
$frontendDest = "/home/cubie/down/document/bidding/publish/frontend"
|
||||
$srcDest = "/home/cubie/down/document/bidding/"
|
||||
|
||||
# Check if key file exists
|
||||
if (-not (Test-Path $keyPath)) {
|
||||
Write-Error "Private key file not found: $keyPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check if dist directory exists
|
||||
if (-not (Test-Path "dist")) {
|
||||
Write-Error "dist directory not found, please run npm run build first"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check if frontend directory exists
|
||||
if (-not (Test-Path "frontend")) {
|
||||
Write-Error "frontend directory not found"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check if src directory exists
|
||||
if (-not (Test-Path "src")) {
|
||||
Write-Error "src directory not found"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Starting deployment..." -ForegroundColor Green
|
||||
Write-Host "Remote server: ${remoteHost}:${remotePort}" -ForegroundColor Cyan
|
||||
Write-Host "Private key: $keyPath" -ForegroundColor Cyan
|
||||
|
||||
# Upload dist directory contents to server directory
|
||||
Write-Host "`nUploading dist directory to ${serverDest}..." -ForegroundColor Yellow
|
||||
scp -i $keyPath -P $remotePort -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -r dist/* "${remoteUser}@${remoteHost}:${serverDest}"
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Failed to upload dist directory"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "dist directory uploaded successfully" -ForegroundColor Green
|
||||
|
||||
# Upload entire frontend directory to publish directory
|
||||
Write-Host "`nUploading frontend directory to ${frontendDest}..." -ForegroundColor Yellow
|
||||
scp -i $keyPath -P $remotePort -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -r frontend/dist "${remoteUser}@${remoteHost}:${frontendDest}"
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Failed to upload frontend directory"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "frontend directory uploaded successfully" -ForegroundColor Green
|
||||
|
||||
# Upload entire src directory to bidding directory
|
||||
Write-Host "`nUploading src directory to ${srcDest}..." -ForegroundColor Yellow
|
||||
scp -i $keyPath -P $remotePort -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -r src "${remoteUser}@${remoteHost}:${srcDest}"
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Failed to upload src directory"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "src directory uploaded successfully" -ForegroundColor Green
|
||||
|
||||
Write-Host "`nDeployment completed!" -ForegroundColor Green
|
||||
164
src/scripts/deploy.ts
Normal file
164
src/scripts/deploy.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Deploy script - Upload files to remote server using SSH2
|
||||
* 使用 ssh2-sftp-client 避免每次输入密钥密码
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import SftpClient from 'ssh2-sftp-client';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
// 加载 .env 文件
|
||||
dotenv.config();
|
||||
|
||||
// Configuration
|
||||
// const config = {
|
||||
// host: '127.0.0.1',
|
||||
// port: 1122,
|
||||
// username: 'cubie',
|
||||
// privateKey: fs.readFileSync('d:\\163'),
|
||||
// passphrase: process.env.SSH_PASSPHRASE || '',
|
||||
// };
|
||||
|
||||
|
||||
const config = {
|
||||
host: '139.180.190.142',
|
||||
port: 2211,
|
||||
username: 'root',
|
||||
privateKey: fs.readFileSync('d:\\163'),
|
||||
passphrase: process.env.SSH_PASSPHRASE || '',
|
||||
};
|
||||
|
||||
// const destinations = {
|
||||
// server: '/home/cubie/down/document/bidding/publish/server',
|
||||
// frontend: '/home/cubie/down/document/bidding/publish/frontend',
|
||||
// src: '/home/cubie/down/document/bidding/',
|
||||
// };
|
||||
|
||||
|
||||
const destinations = {
|
||||
server: '/root/bidding/publish/server',
|
||||
frontend: '/root/bidding/publish/frontend/dist',
|
||||
src: '/root/bidding/',
|
||||
};
|
||||
|
||||
async function uploadDirectory(
|
||||
sftp: SftpClient,
|
||||
localPath: string,
|
||||
remotePath: string,
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
console.log(`\n[上传] ${localPath} -> ${remotePath}`);
|
||||
|
||||
// 检查本地目录
|
||||
if (!fs.existsSync(localPath)) {
|
||||
throw new Error(`本地目录不存在: ${localPath}`);
|
||||
}
|
||||
|
||||
// 统计文件数量
|
||||
const countFiles = (dir: string): number => {
|
||||
let count = 0;
|
||||
const items = fs.readdirSync(dir);
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dir, item);
|
||||
if (fs.statSync(fullPath).isDirectory()) {
|
||||
count += countFiles(fullPath);
|
||||
} else {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
const fileCount = countFiles(localPath);
|
||||
console.log(` 文件数量: ${fileCount}`);
|
||||
|
||||
await sftp.uploadDir(localPath, remotePath);
|
||||
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
console.log(` 完成! 耗时: ${duration}s`);
|
||||
}
|
||||
|
||||
async function deploy(): Promise<void> {
|
||||
// 检查必要目录
|
||||
const requiredDirs = ['dist', 'frontend', 'src'];
|
||||
for (const dir of requiredDirs) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
console.error(`${dir} 目录不存在`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查私钥文件
|
||||
if (!fs.existsSync('d:\\163')) {
|
||||
console.error('私钥文件不存在: d:\\163');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!config.passphrase) {
|
||||
console.error('请在 .env 文件中设置 SSH_PASSPHRASE');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sftp = new SftpClient();
|
||||
|
||||
// 添加详细日志
|
||||
sftp.on('upload', (info) => {
|
||||
console.log(` 已上传: ${info.source}`);
|
||||
});
|
||||
|
||||
try {
|
||||
console.log('开始部署...');
|
||||
console.log(`远程服务器: ${config.host}:${config.port}`);
|
||||
console.log(`用户名: ${config.username}`);
|
||||
console.log(`私钥文件: d:\\163`);
|
||||
console.log('正在连接...');
|
||||
|
||||
await sftp.connect({
|
||||
...config,
|
||||
keepaliveInterval: 5000, // 每5秒发送keepalive
|
||||
keepaliveCountMax: 10,
|
||||
readyTimeout: 60000, // 60秒连接超时
|
||||
});
|
||||
|
||||
console.log('连接成功!');
|
||||
|
||||
// 上传 dist 目录内容到 server 目录
|
||||
await uploadDirectory(sftp, 'dist', destinations.server);
|
||||
|
||||
// 上传 frontend/dist 到 frontend 目录
|
||||
await uploadDirectory(
|
||||
sftp,
|
||||
path.join('frontend', 'dist'),
|
||||
destinations.frontend,
|
||||
);
|
||||
|
||||
// 上传 src 目录
|
||||
await uploadDirectory(sftp, 'src', destinations.src + 'src');
|
||||
|
||||
// 上传 package.json
|
||||
console.log('\n[上传] package.json -> ' + destinations.src + 'package.json');
|
||||
await sftp.put('package.json', destinations.src + 'package.json');
|
||||
console.log(' 已上传: package.json');
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log('部署完成!');
|
||||
console.log('========================================');
|
||||
} catch (err) {
|
||||
console.error('\n========================================');
|
||||
console.error('部署失败!');
|
||||
console.error('========================================');
|
||||
console.error('错误信息:', err instanceof Error ? err.message : err);
|
||||
if (err instanceof Error && err.stack) {
|
||||
console.error('\n堆栈信息:');
|
||||
console.error(err.stack);
|
||||
}
|
||||
process.exit(1);
|
||||
} finally {
|
||||
console.log('\n断开连接...');
|
||||
await sftp.end();
|
||||
console.log('连接已关闭');
|
||||
}
|
||||
}
|
||||
|
||||
deploy();
|
||||
55
src/scripts/init-users-table.ts
Normal file
55
src/scripts/init-users-table.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'dotenv/config';
|
||||
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
|
||||
// 数据库配置
|
||||
const dbConfig: DataSourceOptions = {
|
||||
type: (process.env.DATABASE_TYPE as any) || 'mysql',
|
||||
host: process.env.DATABASE_HOST || 'localhost',
|
||||
port: parseInt(process.env.DATABASE_PORT || '3306'),
|
||||
username: process.env.DATABASE_USERNAME || 'root',
|
||||
password: process.env.DATABASE_PASSWORD || 'root',
|
||||
database: process.env.DATABASE_NAME || 'bidding',
|
||||
entities: [User],
|
||||
synchronize: true, // 启用自动同步以创建表
|
||||
};
|
||||
|
||||
// 日志工具
|
||||
const logger = {
|
||||
log: (message: string, ...args: any[]) => {
|
||||
console.log(`[INFO] ${new Date().toISOString()} - ${message}`, ...args);
|
||||
},
|
||||
error: (message: string, ...args: any[]) => {
|
||||
console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, ...args);
|
||||
},
|
||||
};
|
||||
|
||||
// 主函数
|
||||
async function initUsersTable() {
|
||||
let dataSource: DataSource | null = null;
|
||||
|
||||
try {
|
||||
logger.log('开始初始化 users 表...');
|
||||
|
||||
// 创建数据库连接
|
||||
dataSource = new DataSource(dbConfig);
|
||||
await dataSource.initialize();
|
||||
logger.log('数据库连接成功');
|
||||
|
||||
// 同步表结构
|
||||
await dataSource.synchronize();
|
||||
logger.log('users 表创建成功!');
|
||||
|
||||
await dataSource.destroy();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error('初始化 users 表失败:', error);
|
||||
if (dataSource && dataSource.isInitialized) {
|
||||
await dataSource.destroy();
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行初始化
|
||||
initUsersTable();
|
||||
72
src/scripts/list-users.ts
Normal file
72
src/scripts/list-users.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'dotenv/config';
|
||||
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
|
||||
// 数据库配置
|
||||
const dbConfig: DataSourceOptions = {
|
||||
type: (process.env.DATABASE_TYPE as any) || 'mysql',
|
||||
host: process.env.DATABASE_HOST || 'localhost',
|
||||
port: parseInt(process.env.DATABASE_PORT || '3306'),
|
||||
username: process.env.DATABASE_USERNAME || 'root',
|
||||
password: process.env.DATABASE_PASSWORD || 'root',
|
||||
database: process.env.DATABASE_NAME || 'bidding',
|
||||
entities: [User],
|
||||
synchronize: false,
|
||||
};
|
||||
|
||||
// 日志工具
|
||||
const logger = {
|
||||
log: (message: string, ...args: any[]) => {
|
||||
console.log(`[INFO] ${new Date().toISOString()} - ${message}`, ...args);
|
||||
},
|
||||
error: (message: string, ...args: any[]) => {
|
||||
console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, ...args);
|
||||
},
|
||||
};
|
||||
|
||||
// 主函数
|
||||
async function listUsers() {
|
||||
let dataSource: DataSource | null = null;
|
||||
|
||||
try {
|
||||
logger.log('开始获取用户列表...');
|
||||
|
||||
// 创建数据库连接
|
||||
dataSource = new DataSource(dbConfig);
|
||||
await dataSource.initialize();
|
||||
logger.log('数据库连接成功');
|
||||
|
||||
// 获取所有用户
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const users = await userRepository.find();
|
||||
|
||||
if (users.length === 0) {
|
||||
logger.log('当前没有用户');
|
||||
} else {
|
||||
logger.log(`共有 ${users.length} 个用户:`);
|
||||
console.log('');
|
||||
console.log('ID | 用户名 | 创建时间 | 更新时间');
|
||||
console.log('------------------------------------|-------------|------------------------|------------------------');
|
||||
users.forEach((user) => {
|
||||
const id = user.id;
|
||||
const username = user.username.padEnd(11);
|
||||
const createdAt = user.createdAt.toISOString().replace('T', ' ').substring(0, 19);
|
||||
const updatedAt = user.updatedAt.toISOString().replace('T', ' ').substring(0, 19);
|
||||
console.log(`${id} | ${username} | ${createdAt} | ${updatedAt}`);
|
||||
});
|
||||
console.log('');
|
||||
}
|
||||
|
||||
await dataSource.destroy();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error('获取用户列表失败:', error);
|
||||
if (dataSource && dataSource.isInitialized) {
|
||||
await dataSource.destroy();
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行列出用户
|
||||
listUsers();
|
||||
@@ -19,8 +19,8 @@ async function updateSource() {
|
||||
getRepositoryToken(BidItem),
|
||||
);
|
||||
|
||||
const oldSource = '北京电力交易平台';
|
||||
const newSource = '北京京能电子商务平台';
|
||||
const oldSource = '电能e招采平台';
|
||||
const newSource = '电能e招采平台(国电投)';
|
||||
|
||||
logger.log(`开始更新 source 字段: "${oldSource}" -> "${newSource}"`);
|
||||
|
||||
|
||||
25
src/users/entities/user.entity.ts
Normal file
25
src/users/entities/user.entity.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
username: string;
|
||||
|
||||
@Column()
|
||||
password: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
11
src/users/users.module.ts
Normal file
11
src/users/users.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { User } from './entities/user.entity';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
112
src/users/users.service.ts
Normal file
112
src/users/users.service.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { User } from './entities/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建新用户
|
||||
* @param username 用户名
|
||||
* @param password 明文密码
|
||||
* @returns 创建的用户
|
||||
*/
|
||||
async createUser(username: string, password: string): Promise<User> {
|
||||
// 检查用户名是否已存在
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: { username },
|
||||
});
|
||||
if (existingUser) {
|
||||
throw new Error(`用户名 ${username} 已存在`);
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// 创建用户
|
||||
const user = this.userRepository.create({
|
||||
username,
|
||||
password: hashedPassword,
|
||||
});
|
||||
|
||||
return await this.userRepository.save(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户名和密码
|
||||
* @param username 用户名
|
||||
* @param password 明文密码
|
||||
* @returns 验证成功返回用户对象,失败返回 null
|
||||
*/
|
||||
async validateUser(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<User | null> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { username },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||
if (!isPasswordValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有用户(不包含密码)
|
||||
* @returns 用户列表
|
||||
*/
|
||||
async findAll(): Promise<Omit<User, 'password'>[]> {
|
||||
const users = await this.userRepository.find();
|
||||
return users.map((user) => {
|
||||
const { password, ...userWithoutPassword } = user;
|
||||
return userWithoutPassword;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户名查找用户
|
||||
* @param username 用户名
|
||||
* @returns 用户对象(不包含密码)
|
||||
*/
|
||||
async findByUsername(
|
||||
username: string,
|
||||
): Promise<Omit<User, 'password'> | null> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { username },
|
||||
});
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
const { password, ...userWithoutPassword } = user;
|
||||
return userWithoutPassword;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户
|
||||
* @param id 用户 ID
|
||||
*/
|
||||
async deleteUser(id: string): Promise<void> {
|
||||
await this.userRepository.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户名删除用户
|
||||
* @param username 用户名
|
||||
*/
|
||||
async deleteUserByUsername(username: string): Promise<void> {
|
||||
await this.userRepository.delete({ username });
|
||||
}
|
||||
}
|
||||
142
test/auth.e2e-spec.ts
Normal file
142
test/auth.e2e-spec.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
describe('AuthGuard (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
const validApiKey = 'test-e2e-api-key-12345';
|
||||
|
||||
beforeEach(async () => {
|
||||
// 设置测试环境变量
|
||||
process.env.API_KEY = validApiKey;
|
||||
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
// 清理环境变量
|
||||
delete process.env.API_KEY;
|
||||
});
|
||||
|
||||
describe('本地IP访问', () => {
|
||||
it('应该允许本地IP访问(无需API Key)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/api/keywords')
|
||||
.set('X-Forwarded-For', '127.0.0.1')
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('应该允许 IPv6 本地地址访问', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/api/keywords')
|
||||
.set('X-Forwarded-For', '::1')
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('公网IP访问 - 已配置API_KEY', () => {
|
||||
it('应该允许提供正确API Key的公网访问', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/api/keywords')
|
||||
.set('X-Forwarded-For', '8.8.8.8')
|
||||
.set('X-API-Key', validApiKey)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('应该拒绝未提供API Key的公网访问', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/api/keywords')
|
||||
.set('X-Forwarded-For', '8.8.8.8')
|
||||
.expect(401)
|
||||
.expect((res) => {
|
||||
expect(res.body.message).toBe('Invalid or missing API Key');
|
||||
});
|
||||
});
|
||||
|
||||
it('应该拒绝提供错误API Key的公网访问', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/api/keywords')
|
||||
.set('X-Forwarded-For', '8.8.8.8')
|
||||
.set('X-API-Key', 'wrong-api-key')
|
||||
.expect(401)
|
||||
.expect((res) => {
|
||||
expect(res.body.message).toBe('Invalid or missing API Key');
|
||||
});
|
||||
});
|
||||
|
||||
it('应该拒绝提供空字符串API Key的公网访问', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/api/keywords')
|
||||
.set('X-Forwarded-For', '8.8.8.8')
|
||||
.set('X-API-Key', '')
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('公网IP访问 - 未配置API_KEY', () => {
|
||||
beforeEach(async () => {
|
||||
await app.close();
|
||||
// 清除 API_KEY
|
||||
delete process.env.API_KEY;
|
||||
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('应该允许所有公网访问(开发环境)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/api/keywords')
|
||||
.set('X-Forwarded-For', '8.8.8.8')
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('内网IP访问', () => {
|
||||
it('应该要求内网IP提供API Key', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/api/keywords')
|
||||
.set('X-Forwarded-For', '192.168.1.100')
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('应该允许提供正确API Key的内网访问', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/api/keywords')
|
||||
.set('X-Forwarded-For', '192.168.1.100')
|
||||
.set('X-API-Key', validApiKey)
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('多个API端点', () => {
|
||||
it('应该对所有API端点应用鉴权', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/api/bids')
|
||||
.set('X-Forwarded-For', '8.8.8.8')
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('应该允许带正确API Key访问所有端点', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/api/bids')
|
||||
.set('X-Forwarded-For', '8.8.8.8')
|
||||
.set('X-API-Key', validApiKey)
|
||||
.expect((res) => {
|
||||
// 可能返回 200 或 404,但不应该是 401
|
||||
expect(res.status).not.toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolvePackageJsonExports": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user