Compare commits
78 Commits
66f535ed0c
...
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 | ||
|
|
82f5a81887 | ||
|
|
10565af001 | ||
|
|
740c11527f | ||
|
|
3f6d10061d | ||
|
|
bcd7af4e69 | ||
|
|
571eea0f66 | ||
|
|
6a9c52fe10 | ||
|
|
8e4429558c | ||
|
|
2fcfb452ec | ||
|
|
e410053ddd | ||
|
|
f32c04b8df | ||
|
|
3b3cef582e | ||
|
|
d1e64596aa | ||
|
|
f050d38140 | ||
|
|
feb18c01bb | ||
|
|
50bc930663 | ||
|
|
4f4355c1cd | ||
|
|
6825885005 | ||
|
|
894976e680 | ||
|
|
333748a6b9 | ||
|
|
72e5230584 | ||
|
|
b3d784f1e3 | ||
|
|
b261ff074c | ||
|
|
7f36e014e6 | ||
|
|
5024d2c502 | ||
|
|
5f186bfb2a | ||
|
|
996289c671 | ||
|
|
bfac194c14 | ||
|
|
533d7b60fb | ||
|
|
af58d770b6 | ||
|
|
2b21ddb990 | ||
|
|
3d269ce9d1 | ||
|
|
61520e9ebf | ||
|
|
3647b9a2e5 | ||
|
|
f2630ed01c | ||
|
|
b1435523e8 | ||
|
|
f1ec37143c | ||
|
|
090e4121ce | ||
|
|
4f37b0fb61 | ||
|
|
8b2f328981 | ||
|
|
1b28a3462a | ||
|
|
3e6456e120 |
54
.env
54
.env
@@ -1,11 +1,53 @@
|
|||||||
DATABASE_TYPE=mariadb
|
# DATABASE_TYPE=mariadb
|
||||||
DATABASE_HOST=127.0.0.1
|
# DATABASE_HOST=127.0.0.1
|
||||||
DATABASE_PORT=23306
|
# DATABASE_PORT=23306
|
||||||
DATABASE_USERNAME=root
|
# DATABASE_USERNAME=root
|
||||||
DATABASE_PASSWORD=410491
|
# DATABASE_PASSWORD=410491
|
||||||
|
# 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=mysql-35aea0ff-ijustforregister-858d.h.aivencloud.com
|
||||||
|
DATABASE_PORT=14129
|
||||||
|
DATABASE_USERNAME=avnadmin
|
||||||
|
DATABASE_PASSWORD=AVNS_PJLxfsWSKa4_FAq_PBt
|
||||||
DATABASE_NAME=bidding
|
DATABASE_NAME=bidding
|
||||||
DATABASE_SYNCHRONIZE=true
|
DATABASE_SYNCHRONIZE=false
|
||||||
|
|
||||||
|
# Slave 数据库配置(用于数据同步)
|
||||||
|
SLAVE_DATABASE_TYPE=mysql
|
||||||
|
SLAVE_DATABASE_HOST=bj-cynosdbmysql-grp-r3a4c658.sql.tencentcdb.com
|
||||||
|
SLAVE_DATABASE_PORT=21741
|
||||||
|
SLAVE_DATABASE_USERNAME=root
|
||||||
|
SLAVE_DATABASE_PASSWORD=}?cRa1f[,}`J
|
||||||
|
SLAVE_DATABASE_NAME=bidding
|
||||||
|
|
||||||
# 代理配置(可选)
|
# 代理配置(可选)
|
||||||
PROXY_HOST=127.0.0.1
|
PROXY_HOST=127.0.0.1
|
||||||
PROXY_PORT=3211
|
PROXY_PORT=3211
|
||||||
|
|
||||||
|
# 日志级别(可选):error, warn, info, debug, verbose
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# OpenAI API Key (用于 AI 推荐)
|
||||||
|
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
|
||||||
19
.env.example
19
.env.example
@@ -6,8 +6,27 @@ DATABASE_PASSWORD=root
|
|||||||
DATABASE_NAME=bidding
|
DATABASE_NAME=bidding
|
||||||
DATABASE_SYNCHRONIZE=true
|
DATABASE_SYNCHRONIZE=true
|
||||||
|
|
||||||
|
# Slave 数据库配置(用于数据同步)
|
||||||
|
SLAVE_DATABASE_TYPE=mariadb
|
||||||
|
SLAVE_DATABASE_HOST=localhost
|
||||||
|
SLAVE_DATABASE_PORT=3306
|
||||||
|
SLAVE_DATABASE_USERNAME=root
|
||||||
|
SLAVE_DATABASE_PASSWORD=root
|
||||||
|
SLAVE_DATABASE_NAME=bidding_slave
|
||||||
|
|
||||||
# 代理配置(可选)
|
# 代理配置(可选)
|
||||||
PROXY_HOST=127.0.0.1
|
PROXY_HOST=127.0.0.1
|
||||||
PROXY_PORT=6000
|
PROXY_PORT=6000
|
||||||
# PROXY_USERNAME=
|
# PROXY_USERNAME=
|
||||||
# PROXY_PASSWORD=
|
# PROXY_PASSWORD=
|
||||||
|
|
||||||
|
# 日志级别(可选):error, warn, info, debug, verbose
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# OpenAI API Key (用于 AI 推荐)
|
||||||
|
ARK_API_KEY=your_openai_api_key_here
|
||||||
|
|
||||||
|
API_KEY=your_secure_api_key_here
|
||||||
|
|
||||||
|
# 是否启用 Basic Auth 认证(true/false)
|
||||||
|
ENABLE_BASIC_AUTH=true
|
||||||
23
.gitignore
vendored
23
.gitignore
vendored
@@ -1 +1,24 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
dist
|
||||||
|
.vscode
|
||||||
|
public
|
||||||
|
*.xls*
|
||||||
|
pw-browsers
|
||||||
|
logs
|
||||||
|
build
|
||||||
|
*.exe
|
||||||
|
*.png
|
||||||
|
*.log
|
||||||
|
*-lock.json
|
||||||
|
*.woff2
|
||||||
|
widget/looker/frontend/src/assets/fonts/OFL.txt
|
||||||
|
dist-electron
|
||||||
|
unpackage
|
||||||
|
.cursor
|
||||||
|
qingyun
|
||||||
|
plan
|
||||||
|
.trae
|
||||||
|
plans
|
||||||
|
android
|
||||||
|
docs
|
||||||
|
.idea
|
||||||
379
README.md
379
README.md
@@ -1,122 +1,327 @@
|
|||||||
<p align="center">
|
# 投标信息智能监控系统
|
||||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
一个基于 TypeScript 的 Web 应用,用于自动爬取商务投标平台的最新信息,将符合条件的投标项目突出显示,为用户提供精准的投标信息监控服务。
|
||||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
|
||||||
|
|
||||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
## 技术栈
|
||||||
<p align="center">
|
|
||||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
|
||||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
|
||||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
|
||||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
|
||||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
|
||||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
|
||||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
|
||||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
|
||||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
|
||||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
|
||||||
</p>
|
|
||||||
<!--[](https://opencollective.com/nest#backer)
|
|
||||||
[](https://opencollective.com/nest#sponsor)-->
|
|
||||||
|
|
||||||
## Description
|
### 后端
|
||||||
|
- **框架**: NestJS
|
||||||
|
- **语言**: TypeScript
|
||||||
|
- **数据库**: PostgreSQL (TypeORM)
|
||||||
|
- **爬虫**: axios
|
||||||
|
- **任务调度**: @nestjs/schedule
|
||||||
|
- **AI 服务**: OpenAI API
|
||||||
|
|
||||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
### 前端
|
||||||
|
- **框架**: Vue.js 3
|
||||||
|
- **构建工具**: Vite
|
||||||
|
- **UI**: Element Plus
|
||||||
|
- **图标**: @element-plus/icons-vue
|
||||||
|
- **HTTP 客户端**: Axios
|
||||||
|
- **Tailwind CSS**: 用于样式辅助
|
||||||
|
|
||||||
## Project setup
|
## 项目结构
|
||||||
|
|
||||||
```bash
|
```
|
||||||
$ npm install
|
src/
|
||||||
|
├── ai/ # AI 模块
|
||||||
|
│ ├── entities/
|
||||||
|
│ │ └── ai-recommendation.entity.ts
|
||||||
|
│ ├── Prompt.ts
|
||||||
|
│ ├── ai.controller.ts
|
||||||
|
│ ├── ai.module.ts
|
||||||
|
│ └── ai.service.ts
|
||||||
|
├── bids/ # 投标业务模块
|
||||||
|
│ ├── controllers/
|
||||||
|
│ │ └── bid.controller.ts
|
||||||
|
│ ├── entities/
|
||||||
|
│ │ └── 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
|
||||||
|
│ │ ├── chng_target.ts
|
||||||
|
│ │ ├── ceic_target.ts
|
||||||
|
│ │ ├── cgnpc_target.ts
|
||||||
|
│ │ ├── chdtp_target.ts
|
||||||
|
│ │ ├── cnncecp_target.ts
|
||||||
|
│ │ ├── cnooc_target.ts
|
||||||
|
│ │ ├── eps_target.ts
|
||||||
|
│ │ ├── espic_target.ts
|
||||||
|
│ │ ├── powerbeijing_target.ts
|
||||||
|
│ │ ├── sdicc_target.ts
|
||||||
|
│ │ └── szecp_target.ts
|
||||||
|
│ ├── 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
|
||||||
|
│ └── schedule.module.ts
|
||||||
|
├── scripts/ # 脚本工具
|
||||||
|
│ ├── ai-recommendations.ts
|
||||||
|
│ ├── crawl.ts
|
||||||
|
│ ├── deploy.ps1
|
||||||
|
│ ├── remove-duplicates.ts
|
||||||
|
│ ├── sync.ts
|
||||||
|
│ └── update-source.ts
|
||||||
|
├── common/ # 公共模块
|
||||||
|
│ └── logger/
|
||||||
|
│ ├── logger.module.ts
|
||||||
|
│ ├── logger.service.ts
|
||||||
|
│ └── winston.config.ts
|
||||||
|
├── app.controller.ts
|
||||||
|
├── app.module.ts
|
||||||
|
├── app.service.ts
|
||||||
|
└── main.ts
|
||||||
|
|
||||||
|
frontend/
|
||||||
|
├── 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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Compile and run the project
|
## 快速开始
|
||||||
|
|
||||||
```bash
|
### 1. 环境准备
|
||||||
# development
|
|
||||||
$ npm run start
|
|
||||||
|
|
||||||
# watch mode
|
确保已安装 Node.js (18+) 和 PostgreSQL。
|
||||||
$ npm run start:dev
|
|
||||||
|
|
||||||
# production mode
|
### 2. 数据库配置
|
||||||
$ npm run start:prod
|
|
||||||
|
复制 `.env.example` 为 `.env` 并配置数据库连接:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_TYPE=postgres
|
||||||
|
DATABASE_HOST=localhost
|
||||||
|
DATABASE_PORT=5432
|
||||||
|
DATABASE_USERNAME=your_username
|
||||||
|
DATABASE_PASSWORD=your_password
|
||||||
|
DATABASE_NAME=bidding
|
||||||
|
DATABASE_SYNCHRONIZE=true
|
||||||
|
|
||||||
|
# 代理配置(可选)
|
||||||
|
PROXY_HOST=your_proxy_host
|
||||||
|
PROXY_PORT=your_proxy_port
|
||||||
|
PROXY_USERNAME=your_proxy_username
|
||||||
|
PROXY_PASSWORD=your_proxy_password
|
||||||
|
|
||||||
|
# AI 配置
|
||||||
|
OPENAI_API_KEY=your_openai_api_key
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run tests
|
### 3. 安装依赖
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# unit tests
|
# 安装后端依赖
|
||||||
$ npm run test
|
npm install
|
||||||
|
|
||||||
# e2e tests
|
# 安装前端依赖
|
||||||
$ npm run test:e2e
|
cd frontend && npm install
|
||||||
|
|
||||||
# test coverage
|
|
||||||
$ npm run test:cov
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deployment
|
### 4. 运行项目
|
||||||
|
|
||||||
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
|
||||||
|
|
||||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ npm install -g @nestjs/mau
|
# 开发模式 - 后端
|
||||||
$ mau deploy
|
npm run start:dev
|
||||||
|
|
||||||
|
# 开发模式 - 前端
|
||||||
|
cd frontend && npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
### 5. 构建生产版本
|
||||||
|
|
||||||
## Resources
|
```bash
|
||||||
|
# 构建前端
|
||||||
|
cd frontend && npm run build
|
||||||
|
|
||||||
Check out a few resources that may come in handy when working with NestJS:
|
# 构建后端
|
||||||
|
cd .. && npm run build
|
||||||
|
|
||||||
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
# 运行生产版本
|
||||||
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
npm run start:prod
|
||||||
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
```
|
||||||
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
|
||||||
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
|
||||||
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
|
||||||
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
|
||||||
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
|
||||||
|
|
||||||
## Support
|
## 核心功能
|
||||||
|
|
||||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
### 智能爬虫模块
|
||||||
|
|
||||||
## Stay in touch
|
- **多源爬取**: 支持 12 个主流招标网站
|
||||||
|
- 中国华电集团有限公司电子商务平台 (CHDTP)
|
||||||
|
- 中国华能集团有限公司电子商务平台 (CHNG)
|
||||||
|
- 深圳交易集团有限公司 (SZECP)
|
||||||
|
- 中国大唐集团电子商务平台 (CDT)
|
||||||
|
- 中国电力招标网 (EPS)
|
||||||
|
- 国家能源投资集团有限责任公司 (CNNCECP)
|
||||||
|
- 中国石油天然气集团有限公司 (CGNPC)
|
||||||
|
- 中国能源建设集团有限公司 (CEIC)
|
||||||
|
- 中国电力建设集团有限公司 (ESPIC)
|
||||||
|
- 北京电力交易中心 (POWERBEIJING)
|
||||||
|
- 山东能源集团有限公司 (SDICC)
|
||||||
|
- 中国海洋石油集团有限公司 (CNOOC)
|
||||||
|
|
||||||
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
- **智能防封策略**:
|
||||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
- 随机请求间隔 (1-3 秒)
|
||||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
- 固定 User-Agent
|
||||||
|
- 异常检测与自动重试机制
|
||||||
|
- 代理支持
|
||||||
|
|
||||||
## License
|
- **定时任务**:
|
||||||
|
- 爬虫任务:已暂停(默认每天午夜执行)
|
||||||
|
- 数据清理:每天午夜自动执行
|
||||||
|
|
||||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
### 数据处理与存储
|
||||||
|
|
||||||
How to Run:
|
- **数据模型**:
|
||||||
1. Database Setup: Update the .env file with your PostgreSQL credentials.
|
- 投标项目标题
|
||||||
|
- 详细页面 URL
|
||||||
|
- 发布时间
|
||||||
|
- 来源网站
|
||||||
|
- 置顶标记
|
||||||
|
- 创建时间
|
||||||
|
- 更新时间
|
||||||
|
|
||||||
1 DATABASE_TYPE=postgres
|
- **增量存储**:
|
||||||
2 DATABASE_HOST=localhost
|
- 通过 URL 哈希值判断是否为新数据
|
||||||
3 DATABASE_PORT=5432
|
- 仅存储当天和最近 7 天的历史数据
|
||||||
4 DATABASE_USERNAME=your_username
|
- 每日自动清理 30 天前的数据
|
||||||
5 DATABASE_PASSWORD=your_password
|
|
||||||
6 DATABASE_NAME=bidding
|
|
||||||
7 DATABASE_SYNCHRONIZE=true
|
|
||||||
2. Install Dependencies:
|
|
||||||
1 npm install
|
|
||||||
2 cd frontend && npm install
|
|
||||||
3. Build and Start:
|
|
||||||
|
|
||||||
1 # From the root directory
|
### 关键词智能监控
|
||||||
2 cd frontend && npm run build
|
|
||||||
3 cd ..
|
|
||||||
4 npm run build
|
|
||||||
5 npm run start
|
|
||||||
|
|
||||||
The system will automatically initialize with the preset keywords: "山东", "海", "建设", "工程", "采购". You can
|
- **预设关键词**: "山东", "海", "建设", "工程", "采购"
|
||||||
manage these and view crawled bidding information at http://localhost:3000.
|
- **自定义关键词**: 通过 Web 界面添加/删除关键词
|
||||||
|
- **权重设置**: 可设置关键词权重 (1-5 级)
|
||||||
|
- **匹配逻辑**:
|
||||||
|
- 标题完全匹配和部分匹配
|
||||||
|
- 多关键词叠加权重
|
||||||
|
- 支持正则表达式高级匹配
|
||||||
|
|
||||||
|
### AI 智能推荐
|
||||||
|
|
||||||
|
- **智能分析**: 使用 AI 分析投标信息的相关性
|
||||||
|
- **推荐评分**: 基于关键词匹配和内容分析生成推荐评分
|
||||||
|
- **智能摘要**: 自动生成投标信息摘要
|
||||||
|
|
||||||
|
### Web 展示界面
|
||||||
|
|
||||||
|
- **仪表盘**:
|
||||||
|
- 高优先级投标信息(匹配自定义关键词)
|
||||||
|
- 今日新增投标列表(按时间倒序)
|
||||||
|
- AI 推荐投标信息
|
||||||
|
- 置顶项目
|
||||||
|
|
||||||
|
- **交互功能**:
|
||||||
|
- 关键词管理面板
|
||||||
|
- 按日期/来源/关键词筛选
|
||||||
|
- 信息标记已读/未读状态
|
||||||
|
- 项目置顶功能
|
||||||
|
- 爬取信息查看
|
||||||
|
|
||||||
|
- **响应式设计**: 适配桌面和移动设备
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### 投标信息
|
||||||
|
- `GET /api/bids` - 获取投标列表(支持分页、筛选)
|
||||||
|
- `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` - 获取所有关键词
|
||||||
|
- `POST /api/keywords` - 添加新关键词
|
||||||
|
- `DELETE /api/keywords/:id` - 删除关键词
|
||||||
|
|
||||||
|
### AI 服务
|
||||||
|
- `POST /api/ai/recommendations` - 获取 AI 推荐
|
||||||
|
- `POST /api/ai/save-recommendations` - 保存 AI 推荐
|
||||||
|
- `GET /api/ai/latest-recommendations` - 获取最新 AI 推荐
|
||||||
|
|
||||||
|
### 爬虫管理
|
||||||
|
- `GET /api/crawler/status` - 获取爬虫状态
|
||||||
|
- `POST /api/crawler/run` - 运行爬虫
|
||||||
|
- `POST /api/crawler/crawl/:sourceName` - 爬取单个来源
|
||||||
|
|
||||||
|
## 前端路由
|
||||||
|
|
||||||
|
- `/` - 仪表盘(默认页面)
|
||||||
|
- `/bids` - 全部投标信息
|
||||||
|
- `/keywords` - 关键词管理
|
||||||
|
- `/ai` - AI 推荐页面
|
||||||
|
- `/crawl-info` - 爬取信息
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 单元测试
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
# E2E 测试
|
||||||
|
npm run test:e2e
|
||||||
|
|
||||||
|
# 测试覆盖率
|
||||||
|
npm run test:cov
|
||||||
|
```
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
项目包含 PowerShell 部署脚本 `src/scripts/deploy.ps1`,用于自动化部署流程。
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
| 变量名 | 说明 | 默认值 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| DATABASE_TYPE | 数据库类型 | postgres |
|
||||||
|
| DATABASE_HOST | 数据库主机 | localhost |
|
||||||
|
| DATABASE_PORT | 数据库端口 | 5432 |
|
||||||
|
| DATABASE_USERNAME | 数据库用户名 | - |
|
||||||
|
| DATABASE_PASSWORD | 数据库密码 | - |
|
||||||
|
| DATABASE_NAME | 数据库名称 | bidding |
|
||||||
|
| DATABASE_SYNCHRONIZE | 自动同步数据库 | true |
|
||||||
|
| PROXY_HOST | 代理主机 | - |
|
||||||
|
| PROXY_PORT | 代理端口 | - |
|
||||||
|
| PROXY_USERNAME | 代理用户名 | - |
|
||||||
|
| PROXY_PASSWORD | 代理密码 | - |
|
||||||
|
| OPENAI_API_KEY | OpenAI API 密钥 | - |
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
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-explicit-any': 'off',
|
||||||
'@typescript-eslint/no-floating-promises': 'warn',
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
"prettier/prettier": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
5
frontend/.env.development
Normal file
5
frontend/.env.development
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://localhost:3300/
|
||||||
2
frontend/.env.example
Normal file
2
frontend/.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# OpenAI API Key (用于 AI 推荐)
|
||||||
|
VITE_OPENAI_API_KEY=your_openai_api_key_here
|
||||||
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,8 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<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>frontend</title>
|
<title>投标</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
858
frontend/package-lock.json
generated
858
frontend/package-lock.json
generated
@@ -11,17 +11,35 @@
|
|||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"element-plus": "^2.13.1",
|
"element-plus": "^2.13.1",
|
||||||
|
"openai": "^6.16.0",
|
||||||
"vue": "^3.5.24"
|
"vue": "^3.5.24"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.2.4",
|
"vite": "^7.2.4",
|
||||||
"vue-tsc": "^3.1.4"
|
"vue-tsc": "^3.1.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@alloc/quick-lru": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://mirrors.cloud.tencent.com/npm/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||||
@@ -553,12 +571,55 @@
|
|||||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
|
"version": "0.3.13",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/remapping": {
|
||||||
|
"version": "2.3.5",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||||
|
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://mirrors.cloud.tencent.com/npm/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
|
"version": "0.3.31",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||||
|
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@popperjs/core": {
|
"node_modules/@popperjs/core": {
|
||||||
"name": "@sxzz/popperjs-es",
|
"name": "@sxzz/popperjs-es",
|
||||||
"version": "2.11.7",
|
"version": "2.11.7",
|
||||||
@@ -927,6 +988,277 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/node": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@tailwindcss/node/-/node-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/remapping": "^2.3.4",
|
||||||
|
"enhanced-resolve": "^5.18.3",
|
||||||
|
"jiti": "^2.6.1",
|
||||||
|
"lightningcss": "1.30.2",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"source-map-js": "^1.2.1",
|
||||||
|
"tailwindcss": "4.1.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tailwindcss/oxide-android-arm64": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-darwin-arm64": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-darwin-x64": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-freebsd-x64": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-linux-x64-musl": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
|
||||||
|
"bundleDependencies": [
|
||||||
|
"@napi-rs/wasm-runtime",
|
||||||
|
"@emnapi/core",
|
||||||
|
"@emnapi/runtime",
|
||||||
|
"@tybys/wasm-util",
|
||||||
|
"@emnapi/wasi-threads",
|
||||||
|
"tslib"
|
||||||
|
],
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "^1.7.1",
|
||||||
|
"@emnapi/runtime": "^1.7.1",
|
||||||
|
"@emnapi/wasi-threads": "^1.1.0",
|
||||||
|
"@napi-rs/wasm-runtime": "^1.1.0",
|
||||||
|
"@tybys/wasm-util": "^0.10.1",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/postcss": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@tailwindcss/postcss/-/postcss-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@alloc/quick-lru": "^5.2.0",
|
||||||
|
"@tailwindcss/node": "4.1.18",
|
||||||
|
"@tailwindcss/oxide": "4.1.18",
|
||||||
|
"postcss": "^8.4.41",
|
||||||
|
"tailwindcss": "4.1.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://mirrors.cloud.tencent.com/npm/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -1255,6 +1587,43 @@
|
|||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/autoprefixer": {
|
||||||
|
"version": "10.4.23",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/autoprefixer/-/autoprefixer-10.4.23.tgz",
|
||||||
|
"integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/postcss/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"browserslist": "^4.28.1",
|
||||||
|
"caniuse-lite": "^1.0.30001760",
|
||||||
|
"fraction.js": "^5.3.4",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"postcss-value-parser": "^4.2.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"autoprefixer": "bin/autoprefixer"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || >=14"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"postcss": "^8.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.13.2",
|
"version": "1.13.2",
|
||||||
"resolved": "https://mirrors.cloud.tencent.com/npm/axios/-/axios-1.13.2.tgz",
|
"resolved": "https://mirrors.cloud.tencent.com/npm/axios/-/axios-1.13.2.tgz",
|
||||||
@@ -1266,6 +1635,51 @@
|
|||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/baseline-browser-mapping": {
|
||||||
|
"version": "2.9.14",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz",
|
||||||
|
"integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"baseline-browser-mapping": "dist/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/browserslist": {
|
||||||
|
"version": "4.28.1",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/browserslist/-/browserslist-4.28.1.tgz",
|
||||||
|
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/browserslist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
|
"caniuse-lite": "^1.0.30001759",
|
||||||
|
"electron-to-chromium": "^1.5.263",
|
||||||
|
"node-releases": "^2.0.27",
|
||||||
|
"update-browserslist-db": "^1.2.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"browserslist": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/call-bind-apply-helpers": {
|
"node_modules/call-bind-apply-helpers": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://mirrors.cloud.tencent.com/npm/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://mirrors.cloud.tencent.com/npm/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
@@ -1279,6 +1693,27 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/caniuse-lite": {
|
||||||
|
"version": "1.0.30001764",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz",
|
||||||
|
"integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/browserslist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "CC-BY-4.0"
|
||||||
|
},
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://mirrors.cloud.tencent.com/npm/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://mirrors.cloud.tencent.com/npm/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@@ -1312,6 +1747,16 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://mirrors.cloud.tencent.com/npm/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://mirrors.cloud.tencent.com/npm/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -1326,6 +1771,13 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/electron-to-chromium": {
|
||||||
|
"version": "1.5.267",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
|
||||||
|
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/element-plus": {
|
"node_modules/element-plus": {
|
||||||
"version": "2.13.1",
|
"version": "2.13.1",
|
||||||
"resolved": "https://mirrors.cloud.tencent.com/npm/element-plus/-/element-plus-2.13.1.tgz",
|
"resolved": "https://mirrors.cloud.tencent.com/npm/element-plus/-/element-plus-2.13.1.tgz",
|
||||||
@@ -1351,6 +1803,20 @@
|
|||||||
"vue": "^3.3.0"
|
"vue": "^3.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/enhanced-resolve": {
|
||||||
|
"version": "5.18.4",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
|
||||||
|
"integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.2.4",
|
||||||
|
"tapable": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://mirrors.cloud.tencent.com/npm/entities/-/entities-7.0.0.tgz",
|
"resolved": "https://mirrors.cloud.tencent.com/npm/entities/-/entities-7.0.0.tgz",
|
||||||
@@ -1450,6 +1916,16 @@
|
|||||||
"@esbuild/win32-x64": "0.27.2"
|
"@esbuild/win32-x64": "0.27.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/escalade": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/estree-walker": {
|
"node_modules/estree-walker": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://mirrors.cloud.tencent.com/npm/estree-walker/-/estree-walker-2.0.2.tgz",
|
"resolved": "https://mirrors.cloud.tencent.com/npm/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||||
@@ -1510,6 +1986,20 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fraction.js": {
|
||||||
|
"version": "5.3.4",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||||
|
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/rawify"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://mirrors.cloud.tencent.com/npm/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://mirrors.cloud.tencent.com/npm/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1583,6 +2073,13 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/graceful-fs": {
|
||||||
|
"version": "4.2.11",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/has-symbols": {
|
"node_modules/has-symbols": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://mirrors.cloud.tencent.com/npm/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://mirrors.cloud.tencent.com/npm/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
@@ -1622,6 +2119,279 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jiti": {
|
||||||
|
"version": "2.6.1",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/jiti/-/jiti-2.6.1.tgz",
|
||||||
|
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"bin": {
|
||||||
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"lightningcss-android-arm64": "1.30.2",
|
||||||
|
"lightningcss-darwin-arm64": "1.30.2",
|
||||||
|
"lightningcss-darwin-x64": "1.30.2",
|
||||||
|
"lightningcss-freebsd-x64": "1.30.2",
|
||||||
|
"lightningcss-linux-arm-gnueabihf": "1.30.2",
|
||||||
|
"lightningcss-linux-arm64-gnu": "1.30.2",
|
||||||
|
"lightningcss-linux-arm64-musl": "1.30.2",
|
||||||
|
"lightningcss-linux-x64-gnu": "1.30.2",
|
||||||
|
"lightningcss-linux-x64-musl": "1.30.2",
|
||||||
|
"lightningcss-win32-arm64-msvc": "1.30.2",
|
||||||
|
"lightningcss-win32-x64-msvc": "1.30.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-android-arm64": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-arm64": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-x64": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-freebsd-x64": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-musl": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-gnu": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-musl": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-x64-msvc": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://mirrors.cloud.tencent.com/npm/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://mirrors.cloud.tencent.com/npm/lodash/-/lodash-4.17.21.tgz",
|
||||||
@@ -1717,12 +2487,40 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-releases": {
|
||||||
|
"version": "2.0.27",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/node-releases/-/node-releases-2.0.27.tgz",
|
||||||
|
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/normalize-wheel-es": {
|
"node_modules/normalize-wheel-es": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://mirrors.cloud.tencent.com/npm/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
|
"resolved": "https://mirrors.cloud.tencent.com/npm/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
|
||||||
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
|
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/openai": {
|
||||||
|
"version": "6.16.0",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/openai/-/openai-6.16.0.tgz",
|
||||||
|
"integrity": "sha512-fZ1uBqjFUjXzbGc35fFtYKEOxd20kd9fDpFeqWtsOZWiubY8CZ1NAlXHW3iathaFvqmNtCWMIsosCuyeI7Joxg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"openai": "bin/cli"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"zod": "^3.25 || ^4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"ws": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-browserify": {
|
"node_modules/path-browserify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://mirrors.cloud.tencent.com/npm/path-browserify/-/path-browserify-1.0.1.tgz",
|
"resolved": "https://mirrors.cloud.tencent.com/npm/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||||
@@ -1769,6 +2567,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -1778,6 +2577,13 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postcss-value-parser": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://mirrors.cloud.tencent.com/npm/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
"resolved": "https://mirrors.cloud.tencent.com/npm/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
@@ -1838,6 +2644,27 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwindcss": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tapable": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/tapable/-/tapable-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/webpack"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://mirrors.cloud.tencent.com/npm/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://mirrors.cloud.tencent.com/npm/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -1877,6 +2704,37 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/update-browserslist-db": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/browserslist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"escalade": "^3.2.0",
|
||||||
|
"picocolors": "^1.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"update-browserslist-db": "cli.js"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"browserslist": ">= 4.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://mirrors.cloud.tencent.com/npm/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://mirrors.cloud.tencent.com/npm/vite/-/vite-7.3.1.tgz",
|
||||||
|
|||||||
@@ -5,19 +5,25 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build --mode production",
|
||||||
|
"build:watch": "concurrently \"vue-tsc -b --watch\" \"vite build --watch --mode development\"",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"element-plus": "^2.13.1",
|
"element-plus": "^2.13.1",
|
||||||
|
"openai": "^6.16.0",
|
||||||
"vue": "^3.5.24"
|
"vue": "^3.5.24"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.2.4",
|
"vite": "^7.2.4",
|
||||||
"vue-tsc": "^3.1.4"
|
"vue-tsc": "^3.1.4"
|
||||||
|
|||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,353 +1,358 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-container class="layout-container" style="height: 100vh">
|
<div class="layout-container" :class="{ 'is-mobile': isMobile }">
|
||||||
<el-aside width="200px" style="background-color: #545c64">
|
<!-- 移动端顶部导航栏 -->
|
||||||
<div class="logo">BID MONITOR</div>
|
<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
|
<el-menu
|
||||||
active-text-color="#ffd04b"
|
active-text-color="#ffd04b"
|
||||||
background-color="#545c64"
|
background-color="#545c64"
|
||||||
class="el-menu-vertical-demo"
|
class="el-menu-vertical-demo"
|
||||||
default-active="1"
|
:default-active="activeIndex"
|
||||||
text-color="#fff"
|
text-color="#fff"
|
||||||
@select="handleSelect"
|
@select="handleSelect"
|
||||||
|
:collapse="isMobile"
|
||||||
>
|
>
|
||||||
<el-menu-item index="1">
|
<el-menu-item index="1">
|
||||||
<el-icon><DataBoard /></el-icon>
|
<el-icon>
|
||||||
<span>Dashboard</span>
|
<MagicStick />
|
||||||
|
</el-icon>
|
||||||
|
<span>Dashboard AI</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item index="2">
|
<el-menu-item index="2">
|
||||||
<el-icon><Document /></el-icon>
|
<el-icon>
|
||||||
<span>Bids</span>
|
<DataBoard />
|
||||||
|
</el-icon>
|
||||||
|
<span>Dashboard</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item index="3">
|
<el-menu-item index="3">
|
||||||
<el-icon><Setting /></el-icon>
|
<el-icon>
|
||||||
|
<Document />
|
||||||
|
</el-icon>
|
||||||
|
<span>Bids</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="4">
|
||||||
|
<el-icon>
|
||||||
|
<Setting />
|
||||||
|
</el-icon>
|
||||||
<span>Keywords</span>
|
<span>Keywords</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
<el-menu-item index="5">
|
||||||
|
<el-icon>
|
||||||
|
<Connection />
|
||||||
|
</el-icon>
|
||||||
|
<span>Crawl Info</span>
|
||||||
|
</el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-aside>
|
</el-aside>
|
||||||
|
|
||||||
<el-container>
|
<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-header>
|
||||||
|
|
||||||
<el-main>
|
<el-main>
|
||||||
<div v-if="activeIndex === '1'">
|
<DashboardAI v-if="activeIndex === '1'" :bids="bids" />
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
<Dashboard
|
||||||
<h2 style="margin: 0;">Dashboard</h2>
|
v-if="activeIndex === '2'"
|
||||||
<el-button type="primary" :loading="crawling" :disabled="isCrawling" @click="handleCrawl">
|
:today-bids="todayBids"
|
||||||
<el-icon style="margin-right: 5px"><Refresh /></el-icon>
|
:keywords="keywords"
|
||||||
立刻抓取
|
:loading="loading"
|
||||||
</el-button>
|
:is-crawling="isCrawling"
|
||||||
</div>
|
@refresh="fetchData"
|
||||||
<el-row :gutter="20">
|
@update-bids="updateBidsByDateRange"
|
||||||
<el-col :span="24">
|
/>
|
||||||
<el-card class="box-card" shadow="hover">
|
|
||||||
<template #header>
|
|
||||||
<div class="card-header">
|
|
||||||
<span>High Priority Bids</span>
|
|
||||||
<el-tag type="danger">Top 10</el-tag>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<el-table :data="highPriorityBids" style="width: 100%" size="small">
|
|
||||||
<el-table-column prop="title" label="Title">
|
|
||||||
<template #default="scope">
|
|
||||||
<a :href="scope.row.url" target="_blank">{{ scope.row.title }}</a>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="source" label="Source" width="120" />
|
|
||||||
<el-table-column prop="publishDate" label="Date" width="120">
|
|
||||||
<template #default="scope">{{ formatDate(scope.row.publishDate) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
</el-card>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
<el-divider />
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
|
||||||
<h3 style="margin: 0;">Today's Bids</h3>
|
|
||||||
<el-select
|
|
||||||
v-model="selectedKeywords"
|
|
||||||
multiple
|
|
||||||
collapse-tags
|
|
||||||
collapse-tags-tooltip
|
|
||||||
placeholder="Filter by Keywords"
|
|
||||||
clearable
|
|
||||||
style="width: 300px;"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="keyword in keywords"
|
|
||||||
:key="keyword.id"
|
|
||||||
:label="keyword.word"
|
|
||||||
:value="keyword.word"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</div>
|
|
||||||
<el-table :data="filteredTodayBids" v-loading="loading" style="width: 100%">
|
|
||||||
<el-table-column prop="title" label="Title">
|
|
||||||
<template #default="scope">
|
|
||||||
<a :href="scope.row.url" target="_blank">{{ scope.row.title }}</a>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="source" label="Source" width="150" />
|
|
||||||
<el-table-column prop="publishDate" label="Date" width="150">
|
|
||||||
<template #default="scope">{{ formatDate(scope.row.publishDate) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="activeIndex === '2'">
|
<Bids
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
v-if="activeIndex === '3'"
|
||||||
<h2 style="margin: 0;">All Bids</h2>
|
:bids="bids"
|
||||||
<el-select v-model="selectedSource" placeholder="Filter by Source" clearable style="width: 200px" @change="currentPage = 1; fetchData()">
|
:source-options="sourceOptions"
|
||||||
<el-option
|
:loading="loading"
|
||||||
v-for="source in sourceOptions"
|
:total="total"
|
||||||
:key="source"
|
@fetch="handleFetchBids"
|
||||||
:label="source"
|
/>
|
||||||
:value="source"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</div>
|
|
||||||
<el-table :data="bids" v-loading="loading" style="width: 100%">
|
|
||||||
<el-table-column prop="title" label="Title">
|
|
||||||
<template #default="scope">
|
|
||||||
<a :href="scope.row.url" target="_blank">{{ scope.row.title }}</a>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="source" label="Source" width="150" />
|
|
||||||
<el-table-column prop="publishDate" label="Date" width="150">
|
|
||||||
<template #default="scope">{{ formatDate(scope.row.publishDate) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
<el-pagination
|
|
||||||
v-model:current-page="currentPage"
|
|
||||||
v-model:page-size="pageSize"
|
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
|
||||||
:total="total"
|
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
|
||||||
@current-change="handlePageChange"
|
|
||||||
@size-change="handleSizeChange"
|
|
||||||
style="margin-top: 20px; justify-content: flex-end;"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="activeIndex === '3'">
|
<Keywords v-if="activeIndex === '4'" :keywords="keywords" :loading="loading" @refresh="fetchData" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div v-loading="loading" style="min-height: 200px;">
|
<CrawlInfo v-if="activeIndex === '5'" />
|
||||||
<el-tag
|
|
||||||
v-for="keyword in keywords"
|
|
||||||
:key="keyword.id"
|
|
||||||
closable
|
|
||||||
:type="getTagType(keyword.weight)"
|
|
||||||
@close="handleDeleteKeyword(keyword.id)"
|
|
||||||
style="margin: 5px;"
|
|
||||||
>
|
|
||||||
{{ keyword.word }}
|
|
||||||
</el-tag>
|
|
||||||
<el-empty v-if="keywords.length === 0" description="No keywords" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-main>
|
</el-main>
|
||||||
</el-container>
|
</el-container>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-dialog v-model="dialogVisible" title="Add Keyword" width="30%">
|
<!-- 登录对话框 -->
|
||||||
<el-form :model="form" label-width="120px">
|
<el-dialog v-model="loginDialogVisible" title="用户登录" width="90%" :style="{ maxWidth: '400px' }" :close-on-click-modal="false" :show-close="false">
|
||||||
<el-form-item label="Keyword">
|
<el-form :model="loginForm" label-width="80px">
|
||||||
<el-input v-model="form.word" />
|
<el-form-item label="用户名">
|
||||||
</el-form-item>
|
<el-input v-model="loginForm.username" placeholder="请输入用户名" @keyup.enter="handleLogin" />
|
||||||
<el-form-item label="Weight">
|
</el-form-item>
|
||||||
<el-input-number v-model="form.weight" :min="1" :max="5" />
|
<el-form-item label="密码">
|
||||||
</el-form-item>
|
<el-input v-model="loginForm.password" type="password" placeholder="请输入密码" show-password @keyup.enter="handleLogin" />
|
||||||
</el-form>
|
</el-form-item>
|
||||||
<template #footer>
|
</el-form>
|
||||||
<span class="dialog-footer">
|
<template #footer>
|
||||||
<el-button @click="dialogVisible = false">Cancel</el-button>
|
<el-button type="primary" @click="handleLogin" :loading="loginLoading">登录</el-button>
|
||||||
<el-button type="primary" @click="handleAddKeyword">Confirm</el-button>
|
</template>
|
||||||
</span>
|
</el-dialog>
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</el-container>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, reactive, computed, watch } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import axios from 'axios'
|
import api, { setAuthCredentials, clearAuthCredentials, isAuthenticated } from './utils/api'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { DataBoard, Document, Setting, Refresh } 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'
|
||||||
|
import Keywords from './components/Keywords.vue'
|
||||||
|
import CrawlInfo from './components/CrawlInfo.vue'
|
||||||
|
|
||||||
const activeIndex = ref('1')
|
const activeIndex = ref('1')
|
||||||
const bids = ref<any[]>([])
|
const bids = ref<any[]>([])
|
||||||
const highPriorityBids = ref<any[]>([])
|
const todayBids = ref<any[]>([])
|
||||||
const keywords = ref<any[]>([])
|
const keywords = ref<any[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const crawling = ref(false)
|
const isCrawling = ref(false)
|
||||||
const dialogVisible = ref(false)
|
|
||||||
const selectedSource = ref('')
|
|
||||||
const currentPage = ref(1)
|
|
||||||
const pageSize = ref(10)
|
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const sourceOptions = ref<string[]>([])
|
const sourceOptions = ref<string[]>([])
|
||||||
const isCrawling = ref(false)
|
|
||||||
const selectedKeywords = ref<string[]>([])
|
|
||||||
|
|
||||||
// 从 localStorage 加载保存的关键字
|
// 移动端状态
|
||||||
const loadSavedKeywords = () => {
|
const isMobile = ref(false)
|
||||||
const saved = localStorage.getItem('selectedKeywords')
|
const sidebarVisible = ref(false)
|
||||||
if (saved) {
|
|
||||||
try {
|
// 登录相关状态
|
||||||
selectedKeywords.value = JSON.parse(saved)
|
const loginDialogVisible = ref(false)
|
||||||
} catch (e) {
|
const loginLoading = ref(false)
|
||||||
console.error('Failed to parse saved keywords:', e)
|
const loginForm = ref({
|
||||||
}
|
username: '',
|
||||||
|
password: ''
|
||||||
|
})
|
||||||
|
const currentUser = ref<string | null>(null)
|
||||||
|
|
||||||
|
// 检测屏幕宽度
|
||||||
|
const checkScreenSize = () => {
|
||||||
|
isMobile.value = window.innerWidth < 768
|
||||||
|
if (!isMobile.value) {
|
||||||
|
sidebarVisible.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听关键字变化并保存到 localStorage
|
const toggleSidebar = () => {
|
||||||
watch(selectedKeywords, (newKeywords) => {
|
sidebarVisible.value = !sidebarVisible.value
|
||||||
localStorage.setItem('selectedKeywords', JSON.stringify(newKeywords))
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
word: '',
|
|
||||||
weight: 1
|
|
||||||
})
|
|
||||||
|
|
||||||
// 根据 weight 获取 tag 类型
|
|
||||||
const getTagType = (weight: number) => {
|
|
||||||
if (weight >= 5) return 'danger'
|
|
||||||
if (weight >= 4) return 'warning'
|
|
||||||
if (weight >= 3) return 'primary'
|
|
||||||
if (weight >= 2) return 'success'
|
|
||||||
return 'info'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelect = (key: string) => {
|
const handleSelect = (key: string) => {
|
||||||
activeIndex.value = key
|
activeIndex.value = key
|
||||||
}
|
// 移动端选择后关闭侧边栏
|
||||||
|
if (isMobile.value) {
|
||||||
// 处理分页变化
|
sidebarVisible.value = false
|
||||||
const handlePageChange = (page: number) => {
|
|
||||||
currentPage.value = page
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理每页数量变化
|
|
||||||
const handleSizeChange = (size: number) => {
|
|
||||||
pageSize.value = size
|
|
||||||
currentPage.value = 1
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
if (!dateString) return '-'
|
|
||||||
return new Date(dateString).toLocaleDateString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 过滤 Today's Bids,只显示包含所选关键字的项目
|
|
||||||
const filteredTodayBids = computed(() => {
|
|
||||||
if (selectedKeywords.value.length === 0) {
|
|
||||||
return bids.value
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return bids.value.filter(bid => {
|
const handleFetchBids = async (page: number, limit: number, source?: string) => {
|
||||||
return selectedKeywords.value.some(keyword =>
|
|
||||||
bid.title.toLowerCase().includes(keyword.toLowerCase())
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [bidsRes, highRes, kwRes, sourcesRes, statusRes] = await Promise.all([
|
const res = await api.get('/api/bids', {
|
||||||
axios.get('/api/bids', {
|
params: {
|
||||||
params: {
|
page,
|
||||||
page: currentPage.value,
|
limit,
|
||||||
limit: pageSize.value,
|
source: source || undefined
|
||||||
source: selectedSource.value || undefined
|
}
|
||||||
}
|
})
|
||||||
}),
|
bids.value = res.data.items
|
||||||
axios.get('/api/bids/high-priority'),
|
total.value = res.data.total
|
||||||
axios.get('/api/keywords'),
|
|
||||||
axios.get('/api/bids/sources'),
|
|
||||||
axios.get('/api/crawler/status')
|
|
||||||
])
|
|
||||||
bids.value = bidsRes.data.items
|
|
||||||
total.value = bidsRes.data.total
|
|
||||||
highPriorityBids.value = highRes.data
|
|
||||||
keywords.value = kwRes.data
|
|
||||||
sourceOptions.value = sourcesRes.data
|
|
||||||
isCrawling.value = statusRes.data.isCrawling
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error('Failed to fetch data')
|
console.error('Failed to fetch bids:', error)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCrawl = async () => {
|
const fetchData = async () => {
|
||||||
if (isCrawling.value) {
|
loading.value = true
|
||||||
ElMessage.warning('Crawl is already running')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
crawling.value = true
|
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/crawler/run')
|
const [bidsRes, recentRes, kwRes, sourcesRes, statusRes] = await Promise.all([
|
||||||
ElMessage.success('Crawl completed successfully')
|
api.get('/api/bids', {
|
||||||
fetchData() // Refresh data after crawl
|
params: {
|
||||||
|
page: 1,
|
||||||
|
limit: 10
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
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
|
||||||
|
todayBids.value = recentRes.data
|
||||||
|
keywords.value = kwRes.data
|
||||||
|
sourceOptions.value = sourcesRes.data
|
||||||
|
isCrawling.value = statusRes.data.isCrawling
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error('Failed to run crawl task')
|
console.error('Failed to fetch data:', error)
|
||||||
} finally {
|
} finally {
|
||||||
crawling.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddKeyword = async () => {
|
// 根据日期范围更新投标信息
|
||||||
if (!form.word) {
|
const updateBidsByDateRange = async (startDate: string, endDate?: string, keywords?: string[]) => {
|
||||||
ElMessage.warning('Please enter a keyword')
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: any = { startDate }
|
||||||
|
if (endDate) {
|
||||||
|
params.endDate = endDate
|
||||||
|
}
|
||||||
|
if (keywords && keywords.length > 0) {
|
||||||
|
params.keywords = keywords.join(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await api.get('/api/bids/by-date-range', { params })
|
||||||
|
todayBids.value = response.data
|
||||||
|
ElMessage.success(`更新成功,共 ${response.data.length} 条数据`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update bids by date range:', error)
|
||||||
|
ElMessage.error('更新失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理登录
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!loginForm.value.username || !loginForm.value.password) {
|
||||||
|
ElMessage.warning('请输入用户名和密码')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loginLoading.value = true
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/keywords', form)
|
// 保存凭证到 localStorage
|
||||||
ElMessage.success('Keyword added')
|
setAuthCredentials(loginForm.value.username, loginForm.value.password)
|
||||||
dialogVisible.value = false
|
|
||||||
form.word = ''
|
// 测试凭证是否有效
|
||||||
form.weight = 1
|
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()
|
fetchData()
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
ElMessage.error('Failed to add keyword')
|
console.error('登录失败:', error)
|
||||||
|
// 清除无效凭证
|
||||||
|
clearAuthCredentials()
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
ElMessage.error('用户名或密码错误')
|
||||||
|
} else {
|
||||||
|
ElMessage.error('登录失败,请稍后重试')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loginLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteKeyword = async (id: string) => {
|
// 处理登出
|
||||||
try {
|
const handleLogout = () => {
|
||||||
await axios.delete(`/api/keywords/${id}`)
|
clearAuthCredentials()
|
||||||
ElMessage.success('Keyword deleted')
|
currentUser.value = null
|
||||||
fetchData()
|
ElMessage.success('已退出登录')
|
||||||
} catch (error) {
|
}
|
||||||
ElMessage.error('Failed to delete keyword')
|
|
||||||
}
|
// 处理认证要求事件
|
||||||
|
const handleAuthRequired = () => {
|
||||||
|
loginDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadSavedKeywords()
|
// 检查是否已登录
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.layout-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-container .el-container {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.layout-container .el-header {
|
.layout-container .el-header {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
color: var(--el-text-color-primary);
|
color: var(--el-text-color-primary);
|
||||||
line-height: 60px;
|
line-height: 60px;
|
||||||
border-bottom: 1px solid #eee;
|
border-bottom: 1px solid #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-container .el-aside {
|
.layout-container .el-aside {
|
||||||
color: var(--el-text-color-primary);
|
color: var(--el-text-color-primary);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layout-container .el-menu {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
height: 60px;
|
height: 60px;
|
||||||
line-height: 60px;
|
line-height: 60px;
|
||||||
@@ -357,9 +362,109 @@ onMounted(() => {
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
background-color: #434a50;
|
background-color: #434a50;
|
||||||
}
|
}
|
||||||
.card-header {
|
|
||||||
|
/* 桌面端样式 */
|
||||||
|
.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;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
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>
|
</style>
|
||||||
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
197
frontend/src/components/Bids.vue
Normal file
197
frontend/src/components/Bids.vue
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
<template>
|
||||||
|
<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"
|
||||||
|
:label="source"
|
||||||
|
:value="source"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<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="{
|
||||||
|
color: scope.row.pin ? '#f56c6c' : '#909399',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '18px'
|
||||||
|
}"
|
||||||
|
@click="togglePin(scope.row)"
|
||||||
|
>
|
||||||
|
<Paperclip />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Title" min-width="150">
|
||||||
|
<template #default="scope">
|
||||||
|
<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" 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>
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
class="pagination"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
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[]
|
||||||
|
sourceOptions: string[]
|
||||||
|
loading: boolean
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
fetch: [page: number, limit: number, source?: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const selectedSource = ref('')
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
const handleSourceChange = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
emit('fetch', currentPage.value, pageSize.value, selectedSource.value || undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
currentPage.value = page
|
||||||
|
emit('fetch', currentPage.value, pageSize.value, selectedSource.value || undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSizeChange = (size: number) => {
|
||||||
|
pageSize.value = size
|
||||||
|
currentPage.value = 1
|
||||||
|
emit('fetch', currentPage.value, pageSize.value, selectedSource.value || undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换 Pin 状态
|
||||||
|
const togglePin = async (item: any) => {
|
||||||
|
try {
|
||||||
|
const newPinStatus = !item.pin
|
||||||
|
await api.patch(`/api/bids/${encodeURIComponent(item.title)}/pin`, { pin: newPinStatus })
|
||||||
|
item.pin = newPinStatus
|
||||||
|
ElMessage.success(newPinStatus ? '已置顶' : '已取消置顶')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
260
frontend/src/components/CrawlInfo.vue
Normal file
260
frontend/src/components/CrawlInfo.vue
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
<template>
|
||||||
|
<div class="crawl-info">
|
||||||
|
<el-card class="crawl-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">爬虫统计信息</span>
|
||||||
|
<el-button type="primary" size="small" @click="fetchCrawlStats" :loading="loading">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<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 }">
|
||||||
|
{{ formatDateTime(row.latestUpdate) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最新工程时间" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDateTime(row.latestPublishDate) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<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="操作" width="60">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="crawlSingleSource(row.source)"
|
||||||
|
:loading="crawlingSources.has(row.source)"
|
||||||
|
: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="2" border class="descriptions">
|
||||||
|
<el-descriptions-item label="爬虫来源总数">
|
||||||
|
{{ crawlStats.length }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="本次获取总数">
|
||||||
|
{{ totalCount }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="有数据来源">
|
||||||
|
{{ activeSources }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="出错来源">
|
||||||
|
{{ errorSources }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
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
|
||||||
|
count: number
|
||||||
|
latestUpdate: string | null
|
||||||
|
latestPublishDate: string | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeSources = computed(() => {
|
||||||
|
return crawlStats.value.filter(item => item.count > 0).length
|
||||||
|
})
|
||||||
|
|
||||||
|
const errorSources = computed(() => {
|
||||||
|
return crawlStats.value.filter(item => item.error && item.error.trim()).length
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchCrawlStats = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.get('/api/bids/crawl-info-stats')
|
||||||
|
crawlStats.value = res.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch crawl stats:', error)
|
||||||
|
ElMessage.error('获取爬虫统计信息失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格单元格类名,用于响应式处理
|
||||||
|
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 api.post(`/api/crawler/crawl/${encodeURIComponent(sourceName)}`)
|
||||||
|
|
||||||
|
if (res.data.success) {
|
||||||
|
ElMessage.success(`${sourceName} 更新成功,获取 ${res.data.count} 条数据`)
|
||||||
|
} else {
|
||||||
|
ElMessage.error(`${sourceName} 更新失败: ${res.data.error || '未知错误'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchCrawlStats()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to crawl single source:', error)
|
||||||
|
ElMessage.error(`${sourceName} 更新失败`)
|
||||||
|
} finally {
|
||||||
|
crawlingSources.value.delete(sourceName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crawl-card {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
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>
|
||||||
541
frontend/src/components/Dashboard-AI.vue
Normal file
541
frontend/src/components/Dashboard-AI.vue
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
<template>
|
||||||
|
<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" class="ai-section">
|
||||||
|
<el-col :span="24">
|
||||||
|
<el-card class="box-card" shadow="hover">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="aiRecommendations.length === 0" style="text-align: center; padding: 40px; color: #909399;">
|
||||||
|
<el-icon :size="40"><InfoFilled /></el-icon>
|
||||||
|
<p style="margin-top: 10px;">暂无 AI 推荐项目</p>
|
||||||
|
<p style="font-size: 12px; margin-top: 5px;">点击上方按钮获取 AI 推荐</p>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<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="{
|
||||||
|
color: scope.row.pin ? '#f56c6c' : '#909399',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '18px'
|
||||||
|
}"
|
||||||
|
@click="togglePin(scope.row)"
|
||||||
|
>
|
||||||
|
<Paperclip />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="项目名称" min-width="150">
|
||||||
|
<template #default="scope">
|
||||||
|
<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="来源" 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="70">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag :type="getConfidenceType(scope.row.confidence)" size="small">
|
||||||
|
{{ scope.row.confidence }}%
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<div class="filter-section">
|
||||||
|
<h3 class="filter-title">选择日期范围</h3>
|
||||||
|
<div class="filter-controls">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="daterange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
clearable
|
||||||
|
class="date-picker"
|
||||||
|
/>
|
||||||
|
<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;">
|
||||||
|
<el-col :span="24">
|
||||||
|
<el-card class="box-card" shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>时间范围内所有工程</span>
|
||||||
|
<el-tag type="info">{{ bidsByDateRange.length }} 个工程</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="bidsLoading" style="text-align: center; padding: 40px;">
|
||||||
|
<el-icon class="is-loading" :size="30"><Loading /></el-icon>
|
||||||
|
<p style="margin-top: 10px; color: #909399;">加载中...</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="bidsByDateRange.length === 0" style="text-align: center; padding: 40px; color: #909399;">
|
||||||
|
<el-icon :size="40"><InfoFilled /></el-icon>
|
||||||
|
<p style="margin-top: 10px;">该时间范围内暂无工程</p>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<el-table :data="bidsByDateRange" style="width: 100%" size="small" class="bids-table">
|
||||||
|
<el-table-column label="项目名称" min-width="150">
|
||||||
|
<template #default="scope">
|
||||||
|
<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="220" />
|
||||||
|
<el-table-column prop="publishDate" label="发布日期" width="95">
|
||||||
|
<template #default="scope">
|
||||||
|
{{ formatDate(scope.row.publishDate) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
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'
|
||||||
|
|
||||||
|
|
||||||
|
interface AIRecommendation {
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
source: string
|
||||||
|
confidence: number
|
||||||
|
publishDate?: string
|
||||||
|
pin?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
bids: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const aiRecommendations = ref<AIRecommendation[]>([])
|
||||||
|
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 = () => {
|
||||||
|
const saved = localStorage.getItem('dashboardAI_dateRange')
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
dateRange.value = JSON.parse(saved)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse saved date range:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听日期范围变化并保存到 localStorage
|
||||||
|
watch(dateRange, (newDateRange) => {
|
||||||
|
localStorage.setItem('dashboardAI_dateRange', JSON.stringify(newDateRange))
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// 从数据库加载最新的 AI 推荐
|
||||||
|
const loadLatestRecommendations = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/ai/latest-recommendations')
|
||||||
|
const { recommendations, generatedAt } = response.data
|
||||||
|
|
||||||
|
// 更新生成时间
|
||||||
|
lastRecommendationTime.value = generatedAt
|
||||||
|
|
||||||
|
// 获取所有置顶的项目
|
||||||
|
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,
|
||||||
|
pin: pinnedTitles.has(rec.title)
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load latest recommendations:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化时加载保存的日期范围和最新的 AI 推荐
|
||||||
|
loadSavedDateRange()
|
||||||
|
loadLatestRecommendations()
|
||||||
|
|
||||||
|
// 设置日期范围为最近3天
|
||||||
|
const setLast3Days = () => {
|
||||||
|
const endDate = new Date()
|
||||||
|
const startDate = new Date()
|
||||||
|
startDate.setDate(startDate.getDate() - 2) // 最近3天(包括今天)
|
||||||
|
|
||||||
|
const formatDateForPicker = (date: Date) => {
|
||||||
|
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}`
|
||||||
|
}
|
||||||
|
|
||||||
|
dateRange.value = [formatDateForPicker(startDate), formatDateForPicker(endDate)]
|
||||||
|
fetchBidsByDateRange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置日期范围为最近7天
|
||||||
|
const setLast7Days = () => {
|
||||||
|
const endDate = new Date()
|
||||||
|
const startDate = new Date()
|
||||||
|
startDate.setDate(startDate.getDate() - 6) // 最近7天(包括今天)
|
||||||
|
|
||||||
|
const formatDateForPicker = (date: Date) => {
|
||||||
|
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}`
|
||||||
|
}
|
||||||
|
|
||||||
|
dateRange.value = [formatDateForPicker(startDate), formatDateForPicker(endDate)]
|
||||||
|
fetchBidsByDateRange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 AI 推荐项目
|
||||||
|
const fetchAIRecommendations = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
// 准备发送给后端的数据(只包含 title)
|
||||||
|
const bidsData = bidsByDateRange.value.map(bid => ({
|
||||||
|
title: bid.title
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 调用后端 API
|
||||||
|
const response = await api.post('/api/ai/recommendations', {
|
||||||
|
bids: bidsData
|
||||||
|
})
|
||||||
|
|
||||||
|
// 根据 title 从 bidsByDateRange 中更新 url 和 source
|
||||||
|
const recommendations = response.data.map((rec: any) => {
|
||||||
|
const bid = bidsByDateRange.value.find(b => b.title === rec.title)
|
||||||
|
return {
|
||||||
|
title: rec.title,
|
||||||
|
url: bid?.url || '',
|
||||||
|
source: bid?.source || '',
|
||||||
|
confidence: rec.confidence,
|
||||||
|
publishDate: bid?.publishDate,
|
||||||
|
pin: bid?.pin || false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按发布时间倒序排列
|
||||||
|
recommendations.sort((a: AIRecommendation, b: AIRecommendation) => {
|
||||||
|
if (!a.publishDate) return 1
|
||||||
|
if (!b.publishDate) return -1
|
||||||
|
return new Date(b.publishDate).getTime() - new Date(a.publishDate).getTime()
|
||||||
|
})
|
||||||
|
|
||||||
|
aiRecommendations.value = recommendations
|
||||||
|
|
||||||
|
// 保存推荐结果到数据库
|
||||||
|
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 推荐失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取时间范围内的所有工程
|
||||||
|
const fetchBidsByDateRange = async () => {
|
||||||
|
if (!dateRange.value || dateRange.value.length !== 2) {
|
||||||
|
ElMessage.warning('请先选择日期范围')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showAllBids.value = true
|
||||||
|
bidsLoading.value = true
|
||||||
|
try {
|
||||||
|
const [startDate, endDate] = dateRange.value
|
||||||
|
|
||||||
|
// 检查 endDate 是否是今天
|
||||||
|
const today = new Date()
|
||||||
|
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||||
|
|
||||||
|
// 如果 endDate 是今天,则不传递 endDate 参数(不限制截止时间)
|
||||||
|
const params: any = { startDate }
|
||||||
|
if (endDate !== todayStr) {
|
||||||
|
params.endDate = endDate
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await api.get('/api/bids/by-date-range', { params })
|
||||||
|
bidsByDateRange.value = response.data
|
||||||
|
ElMessage.success(`获取成功,共 ${response.data.length} 个工程`)
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error('获取工程列表失败')
|
||||||
|
} finally {
|
||||||
|
bidsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期,只显示年月日
|
||||||
|
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}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据推荐度返回标签类型
|
||||||
|
const getConfidenceType = (confidence: number) => {
|
||||||
|
if (confidence >= 90) return 'success'
|
||||||
|
if (confidence >= 70) return 'warning'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
// PinnedProject 组件引用
|
||||||
|
const pinnedProjectRef = ref<any>(null)
|
||||||
|
|
||||||
|
// 处理 PinnedProject 组件的 pin 状态改变事件
|
||||||
|
const handlePinChanged = async (title: string) => {
|
||||||
|
// 更新对应推荐项目的 pin 状态
|
||||||
|
const rec = aiRecommendations.value.find(r => r.title === title)
|
||||||
|
if (rec) {
|
||||||
|
rec.pin = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换 AI 推荐项目的 Pin 状态
|
||||||
|
const togglePin = async (item: AIRecommendation) => {
|
||||||
|
try {
|
||||||
|
const newPinStatus = !item.pin
|
||||||
|
await api.patch(`/api/bids/${encodeURIComponent(item.title)}/pin`, { pin: newPinStatus })
|
||||||
|
item.pin = newPinStatus
|
||||||
|
ElMessage.success(newPinStatus ? '已置顶' : '已取消置顶')
|
||||||
|
// 刷新 PinnedProject 组件的数据
|
||||||
|
if (pinnedProjectRef.value) {
|
||||||
|
pinnedProjectRef.value.loadPinnedBids()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.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 {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
450
frontend/src/components/Dashboard.vue
Normal file
450
frontend/src/components/Dashboard.vue
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
<template>
|
||||||
|
<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>
|
||||||
|
立刻抓取
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<PinnedProject ref="pinnedProjectRef" @pin-changed="handlePinChanged" />
|
||||||
|
<el-divider />
|
||||||
|
<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="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
clearable
|
||||||
|
class="date-picker"
|
||||||
|
/>
|
||||||
|
<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="按关键字筛选"
|
||||||
|
clearable
|
||||||
|
class="keyword-select"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="keyword in keywords"
|
||||||
|
:key="keyword.id"
|
||||||
|
:label="keyword.word"
|
||||||
|
:value="keyword.word"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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="{
|
||||||
|
color: scope.row.pin ? '#f56c6c' : '#909399',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '18px'
|
||||||
|
}"
|
||||||
|
@click="togglePin(scope.row)"
|
||||||
|
>
|
||||||
|
<Paperclip />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Title" min-width="150">
|
||||||
|
<template #default="scope">
|
||||||
|
<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" 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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
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[]
|
||||||
|
keywords: any[]
|
||||||
|
loading: boolean
|
||||||
|
isCrawling: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
crawl: []
|
||||||
|
refresh: []
|
||||||
|
updateBids: [startDate: string, endDate?: string, keywords?: string[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const selectedKeywords = ref<string[]>([])
|
||||||
|
const dateRange = ref<[string, string] | null>(null)
|
||||||
|
const crawling = ref(false)
|
||||||
|
const updating = ref(false)
|
||||||
|
const isInitialized = ref(false)
|
||||||
|
const isManualClick = ref(false)
|
||||||
|
|
||||||
|
// 从 localStorage 加载保存的日期范围
|
||||||
|
const loadSavedDateRange = () => {
|
||||||
|
const saved = localStorage.getItem('dashboard_dateRange')
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
dateRange.value = JSON.parse(saved)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse saved date range:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听日期范围变化并保存到 localStorage
|
||||||
|
watch(dateRange, (newDateRange) => {
|
||||||
|
localStorage.setItem('dashboard_dateRange', JSON.stringify(newDateRange))
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// 从 localStorage 保存的关键字
|
||||||
|
const loadSavedKeywords = () => {
|
||||||
|
const saved = localStorage.getItem('selectedKeywords')
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
selectedKeywords.value = JSON.parse(saved)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse saved keywords:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听关键字变化并保存到 localStorage
|
||||||
|
watch(selectedKeywords, (newKeywords) => {
|
||||||
|
localStorage.setItem('selectedKeywords', JSON.stringify(newKeywords))
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// 监听日期范围变化并显示提示
|
||||||
|
watch(dateRange, () => {
|
||||||
|
// 初始化时不显示提示
|
||||||
|
if (!isInitialized.value) {
|
||||||
|
isInitialized.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动点击时不显示提示(避免和按钮点击重复)
|
||||||
|
if (isManualClick.value) {
|
||||||
|
isManualClick.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalBids = props.todayBids.length
|
||||||
|
const filteredCount = filteredTodayBids.value.length
|
||||||
|
|
||||||
|
if (totalBids > 0 && filteredCount < totalBids) {
|
||||||
|
ElMessage.info(`筛选结果:共 ${filteredCount} 条数据(总共 ${totalBids} 条)`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 过滤 Today's Bids,只显示包含所选关键字的项目,并且在日期范围内
|
||||||
|
const filteredTodayBids = computed(() => {
|
||||||
|
let result = props.todayBids
|
||||||
|
|
||||||
|
// 按关键字筛选
|
||||||
|
if (selectedKeywords.value.length > 0) {
|
||||||
|
result = result.filter(bid => {
|
||||||
|
return selectedKeywords.value.some(keyword =>
|
||||||
|
bid.title.toLowerCase().includes(keyword.toLowerCase())
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按日期范围筛选(只限制开始时间,不限制结束时间)
|
||||||
|
if (dateRange.value && dateRange.value.length === 2) {
|
||||||
|
const [startDate] = dateRange.value
|
||||||
|
result = result.filter(bid => {
|
||||||
|
if (!bid.publishDate) return false
|
||||||
|
const bidDate = new Date(bid.publishDate)
|
||||||
|
const start = new Date(startDate)
|
||||||
|
// 设置时间为当天的开始
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
return bidDate >= start
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// 设置日期范围为最近3天
|
||||||
|
const setLast3Days = async () => {
|
||||||
|
isManualClick.value = true
|
||||||
|
const endDate = new Date()
|
||||||
|
const startDate = new Date()
|
||||||
|
startDate.setDate(startDate.getDate() - 2) // 最近3天(包括今天)
|
||||||
|
|
||||||
|
const formatDateForPicker = (date: Date) => {
|
||||||
|
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}`
|
||||||
|
}
|
||||||
|
|
||||||
|
dateRange.value = [formatDateForPicker(startDate), formatDateForPicker(endDate)]
|
||||||
|
|
||||||
|
console.log('setLast3Days called, todayBids:', props.todayBids.length, 'dateRange:', dateRange.value)
|
||||||
|
|
||||||
|
// 调用更新函数
|
||||||
|
await updateBidsByDateRange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置日期范围为最近7天
|
||||||
|
const setLast7Days = async () => {
|
||||||
|
isManualClick.value = true
|
||||||
|
const endDate = new Date()
|
||||||
|
const startDate = new Date()
|
||||||
|
startDate.setDate(startDate.getDate() - 6) // 最近7天(包括今天)
|
||||||
|
|
||||||
|
const formatDateForPicker = (date: Date) => {
|
||||||
|
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}`
|
||||||
|
}
|
||||||
|
|
||||||
|
dateRange.value = [formatDateForPicker(startDate), formatDateForPicker(endDate)]
|
||||||
|
|
||||||
|
console.log('setLast7Days called, todayBids:', props.todayBids.length, 'dateRange:', dateRange.value)
|
||||||
|
|
||||||
|
// 调用更新函数
|
||||||
|
await updateBidsByDateRange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据日期范围更新投标信息
|
||||||
|
const updateBidsByDateRange = async () => {
|
||||||
|
if (!dateRange.value || dateRange.value.length !== 2) {
|
||||||
|
ElMessage.warning('请先选择日期范围')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updating.value = true
|
||||||
|
try {
|
||||||
|
const [startDate, endDate] = dateRange.value
|
||||||
|
|
||||||
|
// 检查 endDate 是否是今天
|
||||||
|
const today = new Date()
|
||||||
|
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||||
|
|
||||||
|
// 如果 endDate 是今天,则不传递 endDate 参数(不限制截止时间)
|
||||||
|
if (endDate === todayStr) {
|
||||||
|
emit('updateBids', startDate, undefined, selectedKeywords.value)
|
||||||
|
} else {
|
||||||
|
emit('updateBids', startDate, endDate, selectedKeywords.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('更新失败')
|
||||||
|
} finally {
|
||||||
|
updating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCrawl = async () => {
|
||||||
|
if (props.isCrawling) {
|
||||||
|
ElMessage.warning('Crawl is already running')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
crawling.value = true
|
||||||
|
try {
|
||||||
|
await api.post('/api/crawler/run')
|
||||||
|
ElMessage.success('Crawl completed successfully')
|
||||||
|
emit('refresh') // Refresh data after crawl
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('Failed to run crawl task')
|
||||||
|
} finally {
|
||||||
|
crawling.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PinnedProject 组件引用
|
||||||
|
const pinnedProjectRef = ref<any>(null)
|
||||||
|
|
||||||
|
// 处理 PinnedProject 组件的 pin 状态改变事件
|
||||||
|
const handlePinChanged = async (title: string) => {
|
||||||
|
// 更新 todayBids 中对应项目的 pin 状态
|
||||||
|
const bid = props.todayBids.find(b => b.title === title)
|
||||||
|
if (bid) {
|
||||||
|
bid.pin = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换 Today's Bids 的 Pin 状态
|
||||||
|
const togglePin = async (item: any) => {
|
||||||
|
try {
|
||||||
|
const newPinStatus = !item.pin
|
||||||
|
await api.patch(`/api/bids/${encodeURIComponent(item.title)}/pin`, { pin: newPinStatus })
|
||||||
|
item.pin = newPinStatus
|
||||||
|
ElMessage.success(newPinStatus ? '已置顶' : '已取消置顶')
|
||||||
|
// 刷新 PinnedProject 组件的数据
|
||||||
|
if (pinnedProjectRef.value) {
|
||||||
|
pinnedProjectRef.value.loadPinnedBids()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化时加载保存的关键字和日期范围
|
||||||
|
loadSavedKeywords()
|
||||||
|
loadSavedDateRange()
|
||||||
|
|
||||||
|
// 如果没有保存的日期范围,则设置默认为最近3天
|
||||||
|
if (!dateRange.value) {
|
||||||
|
setLast3Days()
|
||||||
|
}
|
||||||
|
</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;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,41 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
defineProps<{ msg: string }>()
|
|
||||||
|
|
||||||
const count = ref(0)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<h1>{{ msg }}</h1>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<button type="button" @click="count++">count is {{ count }}</button>
|
|
||||||
<p>
|
|
||||||
Edit
|
|
||||||
<code>components/HelloWorld.vue</code> to test HMR
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Check out
|
|
||||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
|
||||||
>create-vue</a
|
|
||||||
>, the official Vue + Vite starter
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Learn more about IDE Support for Vue in the
|
|
||||||
<a
|
|
||||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
|
||||||
target="_blank"
|
|
||||||
>Vue Docs Scaling up Guide</a
|
|
||||||
>.
|
|
||||||
</p>
|
|
||||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.read-the-docs {
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
157
frontend/src/components/Keywords.vue
Normal file
157
frontend/src/components/Keywords.vue
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<template>
|
||||||
|
<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;" class="keywords-list">
|
||||||
|
<el-tag
|
||||||
|
v-for="keyword in keywords"
|
||||||
|
:key="keyword.id"
|
||||||
|
closable
|
||||||
|
:type="getTagType(keyword.weight)"
|
||||||
|
@close="handleDeleteKeyword(keyword.id)"
|
||||||
|
class="keyword-tag"
|
||||||
|
>
|
||||||
|
{{ keyword.word }}
|
||||||
|
</el-tag>
|
||||||
|
<el-empty v-if="keywords.length === 0" description="暂无关键字" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="权重">
|
||||||
|
<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">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleAddKeyword">确认</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import api from '../utils/api'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
keywords: any[]
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
refresh: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
word: '',
|
||||||
|
weight: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
// 根据 weight 获取 tag 类型
|
||||||
|
const getTagType = (weight: number) => {
|
||||||
|
if (weight >= 5) return 'danger'
|
||||||
|
if (weight >= 4) return 'warning'
|
||||||
|
if (weight >= 3) return 'primary'
|
||||||
|
if (weight >= 2) return 'success'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddKeyword = async () => {
|
||||||
|
if (!form.word) {
|
||||||
|
ElMessage.warning('请输入关键字')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.post('/api/keywords', form)
|
||||||
|
ElMessage.success('关键字添加成功')
|
||||||
|
dialogVisible.value = false
|
||||||
|
form.word = ''
|
||||||
|
form.weight = 1
|
||||||
|
emit('refresh')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('添加关键字失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteKeyword = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/keywords/${id}`)
|
||||||
|
ElMessage.success('关键字删除成功')
|
||||||
|
emit('refresh')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('删除关键字失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.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>
|
||||||
178
frontend/src/components/PinnedProject.vue
Normal file
178
frontend/src/components/PinnedProject.vue
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<template>
|
||||||
|
<el-card class="box-card" shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">Pinned</span>
|
||||||
|
<el-tag type="danger">{{ pinnedBids.length }} 个置顶</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="pinnedLoading" style="text-align: center; padding: 40px;">
|
||||||
|
<el-icon class="is-loading" :size="30"><Loading /></el-icon>
|
||||||
|
<p style="margin-top: 10px; color: #909399;">加载中...</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="pinnedBids.length === 0" style="text-align: center; padding: 40px; color: #909399;">
|
||||||
|
<el-icon :size="40"><InfoFilled /></el-icon>
|
||||||
|
<p style="margin-top: 10px;">暂无置顶项目</p>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<el-table :data="pinnedBids" style="width: 100%" size="small" class="pinned-table">
|
||||||
|
<el-table-column label="Pin" width="45" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-icon
|
||||||
|
:style="{
|
||||||
|
color: '#f56c6c',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '18px'
|
||||||
|
}"
|
||||||
|
@click="togglePin(scope.row)"
|
||||||
|
>
|
||||||
|
<Paperclip />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="项目名称" min-width="150">
|
||||||
|
<template #default="scope">
|
||||||
|
<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="来源" min-width="100" />
|
||||||
|
<el-table-column prop="publishDate" label="发布日期" width="95">
|
||||||
|
<template #default="scope">
|
||||||
|
{{ formatSimpleDate(scope.row.publishDate) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
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]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const pinnedBids = ref<any[]>([])
|
||||||
|
const pinnedLoading = ref(false)
|
||||||
|
|
||||||
|
// 加载置顶项目
|
||||||
|
const loadPinnedBids = async () => {
|
||||||
|
pinnedLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/bids/pinned')
|
||||||
|
pinnedBids.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load pinned bids:', error)
|
||||||
|
} finally {
|
||||||
|
pinnedLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换置顶列表的 Pin 状态
|
||||||
|
const togglePin = async (item: any) => {
|
||||||
|
try {
|
||||||
|
await 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)
|
||||||
|
}
|
||||||
|
ElMessage.success('已取消置顶')
|
||||||
|
// 通知父组件 pin 状态已改变,传递 title
|
||||||
|
emit('pinChanged', item.title)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化时加载置顶项目
|
||||||
|
onMounted(() => {
|
||||||
|
loadPinnedBids()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 暴露方法给父组件调用
|
||||||
|
defineExpose({
|
||||||
|
loadPinnedBids
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,3 +1,6 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* 基础样式 */
|
||||||
:root {
|
:root {
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -13,6 +16,19 @@
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-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 {
|
a {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #646cff;
|
color: #646cff;
|
||||||
@@ -22,19 +38,6 @@ a:hover {
|
|||||||
color: #535bf2;
|
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 {
|
button {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
@@ -54,17 +57,6 @@ button:focus-visible {
|
|||||||
outline: 4px auto -webkit-focus-ring-color;
|
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) {
|
@media (prefers-color-scheme: light) {
|
||||||
:root {
|
:root {
|
||||||
color: #213547;
|
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}`
|
||||||
|
}
|
||||||
@@ -12,5 +12,5 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "../src/ai/Prompt.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import vue from '@vitejs/plugin-vue'
|
|||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
base: './',
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
})
|
||||||
13
jest.config.js
Normal file
13
jest.config.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
module.exports = {
|
||||||
|
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||||
|
rootDir: 'src',
|
||||||
|
testRegex: '.*\\.spec\\.ts$',
|
||||||
|
transform: {
|
||||||
|
'^.+\\.(t|j)s$': 'ts-jest',
|
||||||
|
},
|
||||||
|
collectCoverageFrom: ['**/*.(t|j)s'],
|
||||||
|
coverageDirectory: '../coverage',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
// 加载环境变量
|
||||||
|
setupFiles: ['<rootDir>/../jest.setup.js'],
|
||||||
|
};
|
||||||
2
jest.setup.js
Normal file
2
jest.setup.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// 加载环境变量
|
||||||
|
require('dotenv').config({ path: '.env' });
|
||||||
55
package.json
55
package.json
@@ -5,19 +5,30 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
"main": "app/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --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",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"crawl": "ts-node -r tsconfig-paths/register src/scripts/crawl.ts",
|
||||||
|
"update-source": "ts-node -r tsconfig-paths/register src/scripts/update-source.ts",
|
||||||
|
"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": "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": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
@@ -28,24 +39,42 @@
|
|||||||
"@nestjs/serve-static": "^5.0.4",
|
"@nestjs/serve-static": "^5.0.4",
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
"mysql2": "^3.16.0",
|
"mysql2": "^3.16.0",
|
||||||
|
"openai": "^6.16.0",
|
||||||
"puppeteer": "^24.34.0",
|
"puppeteer": "^24.34.0",
|
||||||
|
"puppeteer-extra": "^3.3.6",
|
||||||
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"typeorm": "^0.3.28"
|
"ssh2": "^1.17.0",
|
||||||
|
"typeorm": "^0.3.28",
|
||||||
|
"winston": "^3.19.0",
|
||||||
|
"winston-daily-rotate-file": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.14",
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/cacheable-request": "^6.0.3",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/fs-extra": "^11.0.4",
|
||||||
|
"@types/http-cache-semantics": "^4.0.4",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/responselike": "^1.0.3",
|
||||||
|
"@types/ssh2": "^1.15.5",
|
||||||
|
"@types/ssh2-sftp-client": "^9.0.6",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"electron": "^39.2.7",
|
||||||
|
"electron-builder": "^26.4.0",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-prettier": "^5.2.2",
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
@@ -53,6 +82,7 @@
|
|||||||
"jest": "^30.0.0",
|
"jest": "^30.0.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
|
"ssh2-sftp-client": "^12.0.1",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
"ts-loader": "^9.5.2",
|
"ts-loader": "^9.5.2",
|
||||||
@@ -60,22 +90,5 @@
|
|||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^8.20.0"
|
"typescript-eslint": "^8.20.0"
|
||||||
},
|
|
||||||
"jest": {
|
|
||||||
"moduleFileExtensions": [
|
|
||||||
"js",
|
|
||||||
"json",
|
|
||||||
"ts"
|
|
||||||
],
|
|
||||||
"rootDir": "src",
|
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
|
||||||
"transform": {
|
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
|
||||||
},
|
|
||||||
"collectCoverageFrom": [
|
|
||||||
"**/*.(t|j)s"
|
|
||||||
],
|
|
||||||
"coverageDirectory": "../coverage",
|
|
||||||
"testEnvironment": "node"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
src/ai/Prompt.ts
Normal file
1
src/ai/Prompt.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const PromptString: string = `先给我说你统计了多少个项目。我只对辽宁、山东、江苏、浙江、福建、广东、广西、海南、河北这些地方的海上风电、海上光伏、漂浮式光伏、滩涂光伏、滩涂风电、渔光互补、风光互补项目感兴趣。从我提供的这些工程里面找到我感兴趣的工程,无论如何至少推荐10个工程。如果没有推荐的,也要给出思考过程。不要修改或简化返回的工程名称。`;
|
||||||
35
src/ai/ai.controller.ts
Normal file
35
src/ai/ai.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Controller, Post, Body, Get } from '@nestjs/common';
|
||||||
|
import { AiService, AIRecommendation } from './ai.service';
|
||||||
|
|
||||||
|
export class BidDataDto {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BidsRequestDto {
|
||||||
|
bids: BidDataDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SaveRecommendationsDto {
|
||||||
|
recommendations: AIRecommendation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('api/ai')
|
||||||
|
export class AiController {
|
||||||
|
constructor(private readonly aiService: AiService) {}
|
||||||
|
|
||||||
|
@Post('recommendations')
|
||||||
|
async getRecommendations(@Body() request: BidsRequestDto) {
|
||||||
|
return this.aiService.getRecommendations(request.bids);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('save-recommendations')
|
||||||
|
async saveRecommendations(@Body() request: SaveRecommendationsDto) {
|
||||||
|
await this.aiService.saveRecommendations(request.recommendations);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('latest-recommendations')
|
||||||
|
async getLatestRecommendations() {
|
||||||
|
return this.aiService.getLatestRecommendations();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/ai/ai.module.ts
Normal file
18
src/ai/ai.module.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AiController } from './ai.controller';
|
||||||
|
import { AiService } from './ai.service';
|
||||||
|
import { AiRecommendation } from './entities/ai-recommendation.entity';
|
||||||
|
import { BidItem } from '../bids/entities/bid-item.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule,
|
||||||
|
TypeOrmModule.forFeature([AiRecommendation, BidItem]),
|
||||||
|
],
|
||||||
|
controllers: [AiController],
|
||||||
|
providers: [AiService],
|
||||||
|
exports: [AiService],
|
||||||
|
})
|
||||||
|
export class AiModule {}
|
||||||
182
src/ai/ai.service.ts
Normal file
182
src/ai/ai.service.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import OpenAI from 'openai';
|
||||||
|
import { PromptString } from './Prompt';
|
||||||
|
import { AiRecommendation as AiRecommendationEntity } from './entities/ai-recommendation.entity';
|
||||||
|
import { BidItem } from '../bids/entities/bid-item.entity';
|
||||||
|
|
||||||
|
export interface BidDataDto {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIRecommendation {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
source: string;
|
||||||
|
confidence: number;
|
||||||
|
publishDate?: Date;
|
||||||
|
generatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AiService {
|
||||||
|
private readonly logger = new Logger(AiService.name);
|
||||||
|
private openai: OpenAI;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
@InjectRepository(AiRecommendationEntity)
|
||||||
|
private readonly aiRecommendationRepository: Repository<AiRecommendationEntity>,
|
||||||
|
@InjectRepository(BidItem)
|
||||||
|
private readonly bidItemRepository: Repository<BidItem>,
|
||||||
|
) {
|
||||||
|
// this.openai = new OpenAI({
|
||||||
|
// apiKey: this.configService.get<string>('ARK_API_KEY') || '',
|
||||||
|
// baseURL: 'https://ark.cn-beijing.volces.com/api/v3',
|
||||||
|
// timeout: 120000, // 120秒超时
|
||||||
|
// });
|
||||||
|
this.openai = new OpenAI({
|
||||||
|
apiKey: 'sk-5sSOxrJl31MGz76bE14d2fDbA55b44869fCcA0C813Fc893a',
|
||||||
|
baseURL: 'https://aihubmix.com/v1',
|
||||||
|
timeout: 120000, // 120秒超时
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecommendations(bids: BidDataDto[]): Promise<AIRecommendation[]> {
|
||||||
|
this.logger.log('开始获取 AI 推荐');
|
||||||
|
this.logger.log(`发送给 AI 的数据数量: ${bids.length}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const prompt =
|
||||||
|
PromptString +
|
||||||
|
`请根据以下投标项目标题列表,筛选出我关心的项目。请以 JSON 格式返回,格式如下:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "项目标题",
|
||||||
|
"confidence": 推荐度(0-100的数字)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
投标项目标题列表:
|
||||||
|
${JSON.stringify(
|
||||||
|
bids.map((b) => b.title),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}`;
|
||||||
|
// this.logger.log('发给AI的内容',prompt);
|
||||||
|
const completion = await this.openai.chat.completions.create({
|
||||||
|
model: 'mimo-v2-flash-free',
|
||||||
|
// max_tokens: 32768,
|
||||||
|
reasoning_effort: 'medium',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log('AI API 响应成功');
|
||||||
|
|
||||||
|
const aiContent = completion.choices[0].message.content;
|
||||||
|
this.logger.log('AI 返回的内容:', aiContent);
|
||||||
|
|
||||||
|
if (!aiContent) {
|
||||||
|
throw new Error('AI 返回内容为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonMatch = aiContent.match(/\[[\s\S]*\]/);
|
||||||
|
|
||||||
|
if (!jsonMatch) {
|
||||||
|
throw new Error('AI 返回格式不正确');
|
||||||
|
}
|
||||||
|
|
||||||
|
const recommendations = JSON.parse(jsonMatch[0]) as AIRecommendation[];
|
||||||
|
this.logger.log(`解析后的推荐结果: ${recommendations.length} 个`);
|
||||||
|
|
||||||
|
return recommendations;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('获取 AI 推荐失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveRecommendations(
|
||||||
|
recommendations: AIRecommendation[],
|
||||||
|
): Promise<void> {
|
||||||
|
this.logger.log('开始保存 AI 推荐结果');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 删除所有旧的推荐
|
||||||
|
await this.aiRecommendationRepository.clear();
|
||||||
|
|
||||||
|
// 保存新的推荐结果(只保存 title 和 confidence)
|
||||||
|
const entities = recommendations.map((rec) => {
|
||||||
|
const entity = new AiRecommendationEntity();
|
||||||
|
entity.title = rec.title;
|
||||||
|
entity.confidence = rec.confidence;
|
||||||
|
return entity;
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.aiRecommendationRepository.save(entities);
|
||||||
|
this.logger.log(`成功保存 ${entities.length} 条 AI 推荐结果`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('保存 AI 推荐失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 从 bid-items 表获取 url、source 和 publishDate
|
||||||
|
const result: AIRecommendation[] = [];
|
||||||
|
for (const entity of entities) {
|
||||||
|
const bidItem = await this.bidItemRepository.findOne({
|
||||||
|
where: { title: entity.title },
|
||||||
|
});
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
title: entity.title,
|
||||||
|
url: bidItem?.url || '',
|
||||||
|
source: bidItem?.source || '',
|
||||||
|
confidence: entity.confidence,
|
||||||
|
publishDate: bidItem?.publishDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按发布时间倒序排列
|
||||||
|
result.sort((a, b) => {
|
||||||
|
if (!a.publishDate) return 1;
|
||||||
|
if (!b.publishDate) return -1;
|
||||||
|
return (
|
||||||
|
new Date(b.publishDate).getTime() - new Date(a.publishDate).getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { recommendations: result, generatedAt };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('获取最新 AI 推荐失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/ai/entities/ai-recommendation.entity.ts
Normal file
21
src/ai/entities/ai-recommendation.entity.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('ai_recommendations')
|
||||||
|
export class AiRecommendation {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@Column({ type: 'int' })
|
||||||
|
confidence: number;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
@@ -8,6 +8,11 @@ import { BidsModule } from './bids/bids.module';
|
|||||||
import { KeywordsModule } from './keywords/keywords.module';
|
import { KeywordsModule } from './keywords/keywords.module';
|
||||||
import { CrawlerModule } from './crawler/crawler.module';
|
import { CrawlerModule } from './crawler/crawler.module';
|
||||||
import { TasksModule } from './schedule/schedule.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -15,13 +20,21 @@ import { TasksModule } from './schedule/schedule.module';
|
|||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
ServeStaticModule.forRoot({
|
ServeStaticModule.forRoot({
|
||||||
rootPath: join(__dirname, '..', 'frontend', 'dist'),
|
rootPath: join(__dirname, '..', 'frontend', 'dist'),
|
||||||
exclude: ['/api*'],
|
exclude: ['/api'],
|
||||||
}),
|
}),
|
||||||
|
LoggerModule,
|
||||||
|
AuthModule,
|
||||||
|
UsersModule,
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
BidsModule,
|
BidsModule,
|
||||||
KeywordsModule,
|
KeywordsModule,
|
||||||
CrawlerModule,
|
CrawlerModule,
|
||||||
TasksModule,
|
TasksModule,
|
||||||
|
AiModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule implements NestModule {
|
||||||
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
consumer.apply(LoggingMiddleware).forRoutes('*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { BidItem } from './entities/bid-item.entity';
|
import { BidItem } from './entities/bid-item.entity';
|
||||||
import { BidsService } from './services/bid.service';
|
import { BidsService } from './services/bid.service';
|
||||||
import { BidsController } from './controllers/bid.controller';
|
import { BidsController } from './controllers/bid.controller';
|
||||||
|
import { CrawlInfoAdd } from '../crawler/entities/crawl-info-add.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([BidItem])],
|
imports: [TypeOrmModule.forFeature([BidItem, CrawlInfoAdd])],
|
||||||
providers: [BidsService],
|
providers: [BidsService],
|
||||||
controllers: [BidsController],
|
controllers: [BidsController],
|
||||||
exports: [BidsService],
|
exports: [BidsService],
|
||||||
|
|||||||
@@ -1,22 +1,58 @@
|
|||||||
import { Controller, Get, Query } from '@nestjs/common';
|
import { Controller, Get, Query, Patch, Param, Body } from '@nestjs/common';
|
||||||
import { BidsService } from '../services/bid.service';
|
import { BidsService } from '../services/bid.service';
|
||||||
|
|
||||||
|
interface FindAllQuery {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
source?: string;
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('api/bids')
|
@Controller('api/bids')
|
||||||
export class BidsController {
|
export class BidsController {
|
||||||
constructor(private readonly bidsService: BidsService) {}
|
constructor(private readonly bidsService: BidsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
findAll(@Query() query: any) {
|
findAll(@Query() query: FindAllQuery) {
|
||||||
return this.bidsService.findAll(query);
|
return this.bidsService.findAll(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('high-priority')
|
@Get('recent')
|
||||||
getHighPriority() {
|
getRecent() {
|
||||||
return this.bidsService.getHighPriorityCorrected();
|
return this.bidsService.getRecentBids();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('pinned')
|
||||||
|
getPinned() {
|
||||||
|
return this.bidsService.getPinnedBids();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('sources')
|
@Get('sources')
|
||||||
getSources() {
|
getSources() {
|
||||||
return this.bidsService.getSources();
|
return this.bidsService.getSources();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('by-date-range')
|
||||||
|
getByDateRange(
|
||||||
|
@Query('startDate') startDate: string,
|
||||||
|
@Query('endDate') endDate?: string,
|
||||||
|
@Query('keywords') keywords?: string,
|
||||||
|
) {
|
||||||
|
const keywordsArray = keywords ? keywords.split(',') : undefined;
|
||||||
|
return this.bidsService.getBidsByDateRange(
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
keywordsArray,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('crawl-info-stats')
|
||||||
|
getCrawlInfoStats() {
|
||||||
|
return this.bidsService.getCrawlInfoAddStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':title/pin')
|
||||||
|
updatePin(@Param('title') title: string, @Body() body: { pin: boolean }) {
|
||||||
|
return this.bidsService.updatePin(title, body.pin);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
@Entity('bid_items')
|
@Entity('bid_items')
|
||||||
export class BidItem {
|
export class BidItem {
|
||||||
@@ -18,13 +24,7 @@ export class BidItem {
|
|||||||
source: string;
|
source: string;
|
||||||
|
|
||||||
@Column({ default: false })
|
@Column({ default: false })
|
||||||
isRead: boolean;
|
pin: boolean;
|
||||||
|
|
||||||
@Column({ default: 0 })
|
|
||||||
priority: number;
|
|
||||||
|
|
||||||
@Column({ nullable: true })
|
|
||||||
unit: string;
|
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|||||||
@@ -2,15 +2,52 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, LessThan } from 'typeorm';
|
import { Repository, LessThan } from 'typeorm';
|
||||||
import { BidItem } from '../entities/bid-item.entity';
|
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;
|
||||||
|
limit?: number;
|
||||||
|
source?: string;
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SourceResult {
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrawlInfoAddStats {
|
||||||
|
source: string;
|
||||||
|
count: number;
|
||||||
|
latestUpdate: Date | string | null;
|
||||||
|
latestPublishDate: Date | string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CrawlInfoAddRawResult {
|
||||||
|
source: string;
|
||||||
|
count: number;
|
||||||
|
latestPublishDate: Date | string | null;
|
||||||
|
error: string | null;
|
||||||
|
latestUpdate: Date | string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BidsService {
|
export class BidsService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(BidItem)
|
@InjectRepository(BidItem)
|
||||||
private bidRepository: Repository<BidItem>,
|
private bidRepository: Repository<BidItem>,
|
||||||
|
@InjectRepository(CrawlInfoAdd)
|
||||||
|
private crawlInfoRepository: Repository<CrawlInfoAdd>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(query?: any) {
|
async findAll(query?: FindAllQuery) {
|
||||||
const { page = 1, limit = 10, source, keyword } = query || {};
|
const { page = 1, limit = 10, source, keyword } = query || {};
|
||||||
const qb = this.bidRepository.createQueryBuilder('bid');
|
const qb = this.bidRepository.createQueryBuilder('bid');
|
||||||
|
|
||||||
@@ -23,34 +60,18 @@ export class BidsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
qb.orderBy('bid.publishDate', 'DESC')
|
qb.orderBy('bid.publishDate', 'DESC')
|
||||||
.skip((page - 1) * limit)
|
.skip((Number(page) - 1) * Number(limit))
|
||||||
.take(limit);
|
.take(Number(limit));
|
||||||
|
|
||||||
const [items, total] = await qb.getManyAndCount();
|
const [items, total] = await qb.getManyAndCount();
|
||||||
return { items, total };
|
return { items, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
getHighPriority() {
|
|
||||||
return this.bidRepository.find({
|
|
||||||
where: { priority: LessThan(0) }, // This is just a placeholder logic, priority should be > 0
|
|
||||||
order: { priority: 'DESC', publishDate: 'DESC' },
|
|
||||||
take: 10,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update logic for priority
|
|
||||||
async getHighPriorityCorrected() {
|
|
||||||
return this.bidRepository.createQueryBuilder('bid')
|
|
||||||
.where('bid.priority > 0')
|
|
||||||
.orderBy('bid.priority', 'DESC')
|
|
||||||
.addOrderBy('bid.publishDate', 'DESC')
|
|
||||||
.limit(10)
|
|
||||||
.getMany();
|
|
||||||
}
|
|
||||||
|
|
||||||
async createOrUpdate(data: Partial<BidItem>) {
|
async createOrUpdate(data: Partial<BidItem>) {
|
||||||
// Use URL or a hash of URL to check for duplicates
|
// Use title or a hash of title to check for duplicates
|
||||||
let item = await this.bidRepository.findOne({ where: { url: data.url } });
|
const item = await this.bidRepository.findOne({
|
||||||
|
where: { title: data.title },
|
||||||
|
});
|
||||||
if (item) {
|
if (item) {
|
||||||
Object.assign(item, data);
|
Object.assign(item, data);
|
||||||
return this.bidRepository.save(item);
|
return this.bidRepository.save(item);
|
||||||
@@ -59,20 +80,126 @@ export class BidsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async cleanOldData() {
|
async cleanOldData() {
|
||||||
const thirtyDaysAgo = new Date();
|
const thirtyDaysAgo = getDaysAgo(30);
|
||||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
||||||
return this.bidRepository.delete({
|
return this.bidRepository.delete({
|
||||||
createdAt: LessThan(thirtyDaysAgo),
|
createdAt: LessThan(thirtyDaysAgo),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSources() {
|
async getSources(): Promise<string[]> {
|
||||||
const result = await this.bidRepository
|
const result = await this.bidRepository
|
||||||
.createQueryBuilder('bid')
|
.createQueryBuilder('bid')
|
||||||
.select('DISTINCT bid.source')
|
.select('DISTINCT bid.source', 'source')
|
||||||
.where('bid.source IS NOT NULL')
|
.where('bid.source IS NOT NULL')
|
||||||
.orderBy('bid.source', 'ASC')
|
.orderBy('bid.source', 'ASC')
|
||||||
.getRawMany();
|
.getRawMany<SourceResult>();
|
||||||
return result.map((item: any) => item.source);
|
return result.map((item) => item.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecentBids() {
|
||||||
|
const thirtyDaysAgo = setStartOfDay(getDaysAgo(30));
|
||||||
|
|
||||||
|
return this.bidRepository
|
||||||
|
.createQueryBuilder('bid')
|
||||||
|
.where('bid.publishDate >= :thirtyDaysAgo', { thirtyDaysAgo })
|
||||||
|
.orderBy('bid.publishDate', 'DESC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPinnedBids() {
|
||||||
|
return this.bidRepository
|
||||||
|
.createQueryBuilder('bid')
|
||||||
|
.where('bid.pin = :pin', { pin: true })
|
||||||
|
.orderBy('bid.publishDate', 'DESC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBidsByDateRange(
|
||||||
|
startDate?: string,
|
||||||
|
endDate?: string,
|
||||||
|
keywords?: string[],
|
||||||
|
) {
|
||||||
|
const qb = this.bidRepository.createQueryBuilder('bid');
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
const start = setStartOfDay(new Date(startDate));
|
||||||
|
qb.andWhere('bid.publishDate >= :startDate', { startDate: start });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
const end = setEndOfDay(new Date(endDate));
|
||||||
|
qb.andWhere('bid.publishDate <= :endDate', { endDate: end });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keywords && keywords.length > 0) {
|
||||||
|
const keywordConditions = keywords
|
||||||
|
.map((keyword, index) => {
|
||||||
|
return `bid.title LIKE :keyword${index}`;
|
||||||
|
})
|
||||||
|
.join(' OR ');
|
||||||
|
qb.andWhere(
|
||||||
|
`(${keywordConditions})`,
|
||||||
|
keywords.reduce((params, keyword, index) => {
|
||||||
|
params[`keyword${index}`] = `%${keyword}%`;
|
||||||
|
return params;
|
||||||
|
}, {}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return qb.orderBy('bid.publishDate', 'DESC').getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePin(title: string, pin: boolean) {
|
||||||
|
const item = await this.bidRepository.findOne({ where: { title } });
|
||||||
|
if (!item) {
|
||||||
|
throw new Error('Bid not found');
|
||||||
|
}
|
||||||
|
item.pin = pin;
|
||||||
|
return this.bidRepository.save(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCrawlInfoAddStats(): Promise<CrawlInfoAddStats[]> {
|
||||||
|
// 获取每个来源的最新一次爬虫记录(按 createdAt 降序)
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
source,
|
||||||
|
count,
|
||||||
|
latestPublishDate,
|
||||||
|
error,
|
||||||
|
createdAt as latestUpdate
|
||||||
|
FROM crawl_info_add
|
||||||
|
WHERE (source, createdAt) IN (
|
||||||
|
SELECT source, MAX(createdAt)
|
||||||
|
FROM crawl_info_add
|
||||||
|
GROUP BY source
|
||||||
|
)
|
||||||
|
ORDER BY source ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const results =
|
||||||
|
await this.crawlInfoRepository.query<CrawlInfoAddRawResult[]>(query);
|
||||||
|
|
||||||
|
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 {}
|
||||||
9
src/common/logger/logger.module.ts
Normal file
9
src/common/logger/logger.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module, Global } from '@nestjs/common';
|
||||||
|
import { CustomLogger } from './logger.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [CustomLogger],
|
||||||
|
exports: [CustomLogger],
|
||||||
|
})
|
||||||
|
export class LoggerModule {}
|
||||||
57
src/common/logger/logger.service.ts
Normal file
57
src/common/logger/logger.service.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Injectable, LoggerService, Scope } from '@nestjs/common';
|
||||||
|
import { winstonLogger } from './winston.config';
|
||||||
|
|
||||||
|
type LogMessage = string | Error | Record<string, unknown>;
|
||||||
|
|
||||||
|
function formatMessage(message: LogMessage): string {
|
||||||
|
if (typeof message === 'string') {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
if (message instanceof Error) {
|
||||||
|
return message.message;
|
||||||
|
}
|
||||||
|
if (typeof message === 'object' && message !== null) {
|
||||||
|
return JSON.stringify(message);
|
||||||
|
}
|
||||||
|
return String(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ scope: Scope.TRANSIENT })
|
||||||
|
export class CustomLogger implements LoggerService {
|
||||||
|
private context?: string;
|
||||||
|
|
||||||
|
setContext(context: string) {
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(message: LogMessage, context?: string) {
|
||||||
|
winstonLogger.info(formatMessage(message), {
|
||||||
|
context: context || this.context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: LogMessage, trace?: string, context?: string) {
|
||||||
|
winstonLogger.error(formatMessage(message), {
|
||||||
|
context: context || this.context,
|
||||||
|
trace,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(message: LogMessage, context?: string) {
|
||||||
|
winstonLogger.warn(formatMessage(message), {
|
||||||
|
context: context || this.context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(message: LogMessage, context?: string) {
|
||||||
|
winstonLogger.debug(formatMessage(message), {
|
||||||
|
context: context || this.context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
verbose(message: LogMessage, context?: string) {
|
||||||
|
winstonLogger.verbose(formatMessage(message), {
|
||||||
|
context: context || this.context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/common/logger/winston.config.ts
Normal file
85
src/common/logger/winston.config.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import * as winston from 'winston';
|
||||||
|
import DailyRotateFile from 'winston-daily-rotate-file';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
const logDir = path.join(process.cwd(), 'logs');
|
||||||
|
|
||||||
|
// 确保日志目录存在
|
||||||
|
if (!fs.existsSync(logDir)) {
|
||||||
|
fs.mkdirSync(logDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日志格式
|
||||||
|
const logFormat = winston.format.combine(
|
||||||
|
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||||
|
winston.format.errors({ stack: true }),
|
||||||
|
winston.format.splat(),
|
||||||
|
winston.format.printf(({ timestamp, level, message, context, stack }) => {
|
||||||
|
const timestampStr =
|
||||||
|
typeof timestamp === 'string' ? timestamp : String(timestamp);
|
||||||
|
const levelStr = typeof level === 'string' ? level : String(level);
|
||||||
|
const messageStr = typeof message === 'string' ? message : String(message);
|
||||||
|
const contextStr = context
|
||||||
|
? typeof context === 'string'
|
||||||
|
? context
|
||||||
|
: JSON.stringify(context)
|
||||||
|
: '';
|
||||||
|
let stackStr = '';
|
||||||
|
if (stack) {
|
||||||
|
if (typeof stack === 'string') {
|
||||||
|
stackStr = stack;
|
||||||
|
} else if (typeof stack === 'object' && stack !== null) {
|
||||||
|
stackStr = JSON.stringify(stack);
|
||||||
|
} else {
|
||||||
|
stackStr = String(stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let log = `${timestampStr} [${levelStr}]`;
|
||||||
|
if (contextStr) {
|
||||||
|
log += ` [${contextStr}]`;
|
||||||
|
}
|
||||||
|
log += ` ${messageStr}`;
|
||||||
|
if (stackStr) {
|
||||||
|
log += `\n${stackStr}`;
|
||||||
|
}
|
||||||
|
return log;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 控制台传输
|
||||||
|
const consoleTransport = new winston.transports.Console({
|
||||||
|
format: winston.format.combine(winston.format.colorize(), logFormat),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 应用日志传输(按天轮转)
|
||||||
|
const appLogTransport = new DailyRotateFile({
|
||||||
|
dirname: logDir,
|
||||||
|
filename: 'application-%DATE%.log',
|
||||||
|
datePattern: 'YYYY-MM-DD',
|
||||||
|
maxSize: '20m',
|
||||||
|
maxFiles: '30d',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 错误日志传输(按天轮转)
|
||||||
|
const errorLogTransport = new DailyRotateFile({
|
||||||
|
dirname: logDir,
|
||||||
|
filename: 'error-%DATE%.log',
|
||||||
|
datePattern: 'YYYY-MM-DD',
|
||||||
|
maxSize: '20m',
|
||||||
|
maxFiles: '30d',
|
||||||
|
level: 'error',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建 winston logger 实例
|
||||||
|
export const winstonLogger = winston.createLogger({
|
||||||
|
level: process.env.LOG_LEVEL || 'info',
|
||||||
|
format: logFormat,
|
||||||
|
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`;
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
import { Controller, Post, Get } from '@nestjs/common';
|
import { Controller, Post, Get, Param, Body } from '@nestjs/common';
|
||||||
import { BidCrawlerService } from './services/bid-crawler.service';
|
import { BidCrawlerService } from './services/bid-crawler.service';
|
||||||
|
|
||||||
@Controller('api/crawler')
|
@Controller('api/crawler')
|
||||||
export class CrawlerController {
|
export class CrawlerController {
|
||||||
private isCrawling = false;
|
private isCrawling = false;
|
||||||
|
private crawlingSources = new Set<string>();
|
||||||
|
|
||||||
constructor(private readonly crawlerService: BidCrawlerService) {}
|
constructor(private readonly crawlerService: BidCrawlerService) {}
|
||||||
|
|
||||||
@Get('status')
|
@Get('status')
|
||||||
getStatus() {
|
getStatus() {
|
||||||
return { isCrawling: this.isCrawling };
|
return {
|
||||||
|
isCrawling: this.isCrawling,
|
||||||
|
crawlingSources: Array.from(this.crawlingSources),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('run')
|
@Post('run')
|
||||||
@@ -35,4 +39,30 @@ export class CrawlerController {
|
|||||||
this.isCrawling = false;
|
this.isCrawling = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('crawl/:sourceName')
|
||||||
|
async crawlSingleSource(@Param('sourceName') sourceName: string) {
|
||||||
|
if (this.crawlingSources.has(sourceName)) {
|
||||||
|
return { message: `Source ${sourceName} is already being crawled` };
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { BidCrawlerService } from './services/bid-crawler.service';
|
import { BidCrawlerService } from './services/bid-crawler.service';
|
||||||
import { CrawlerController } from './crawler.controller';
|
import { CrawlerController } from './crawler.controller';
|
||||||
import { BidsModule } from '../bids/bids.module';
|
import { BidsModule } from '../bids/bids.module';
|
||||||
|
import { CrawlInfoAdd } from './entities/crawl-info-add.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [BidsModule],
|
imports: [BidsModule, TypeOrmModule.forFeature([CrawlInfoAdd])],
|
||||||
controllers: [CrawlerController],
|
controllers: [CrawlerController],
|
||||||
providers: [BidCrawlerService],
|
providers: [BidCrawlerService],
|
||||||
exports: [BidCrawlerService],
|
exports: [BidCrawlerService],
|
||||||
|
|||||||
27
src/crawler/entities/crawl-info-add.entity.ts
Normal file
27
src/crawler/entities/crawl-info-add.entity.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('crawl_info_add')
|
||||||
|
export class CrawlInfoAdd {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
source: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
count: number;
|
||||||
|
|
||||||
|
@Column({ type: 'datetime', nullable: true })
|
||||||
|
latestPublishDate: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
import * as puppeteer from 'puppeteer';
|
import * as puppeteer from 'puppeteer';
|
||||||
import { BidsService } from '../../bids/services/bid.service';
|
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 { ChdtpCrawler } from './chdtp_target';
|
||||||
import { ChngCrawler } from './chng_target';
|
import { ChngCrawler } from './chng_target';
|
||||||
import { SzecpCrawler } from './szecp_target';
|
import { SzecpCrawler } from './szecp_target';
|
||||||
@@ -12,6 +16,28 @@ import { CgnpcCrawler } from './cgnpc_target';
|
|||||||
import { CeicCrawler } from './ceic_target';
|
import { CeicCrawler } from './ceic_target';
|
||||||
import { EspicCrawler } from './espic_target';
|
import { EspicCrawler } from './espic_target';
|
||||||
import { PowerbeijingCrawler } from './powerbeijing_target';
|
import { PowerbeijingCrawler } from './powerbeijing_target';
|
||||||
|
import { SdiccCrawler } from './sdicc_target';
|
||||||
|
import { CnoocCrawler } from './cnooc_target';
|
||||||
|
|
||||||
|
interface CrawlResult {
|
||||||
|
title: string;
|
||||||
|
publishDate: Date;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnyCrawler =
|
||||||
|
| typeof ChdtpCrawler
|
||||||
|
| typeof ChngCrawler
|
||||||
|
| typeof SzecpCrawler
|
||||||
|
| typeof CdtCrawler
|
||||||
|
| typeof EpsCrawler
|
||||||
|
| typeof CnncecpCrawler
|
||||||
|
| typeof CgnpcCrawler
|
||||||
|
| typeof CeicCrawler
|
||||||
|
| typeof EspicCrawler
|
||||||
|
| typeof PowerbeijingCrawler
|
||||||
|
| typeof SdiccCrawler
|
||||||
|
| typeof CnoocCrawler;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BidCrawlerService {
|
export class BidCrawlerService {
|
||||||
@@ -20,14 +46,252 @@ export class BidCrawlerService {
|
|||||||
constructor(
|
constructor(
|
||||||
private bidsService: BidsService,
|
private bidsService: BidsService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
|
@InjectRepository(CrawlInfoAdd)
|
||||||
|
private crawlInfoRepository: Repository<CrawlInfoAdd>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async crawlAll() {
|
async crawlAll() {
|
||||||
this.logger.log('Starting crawl task with Puppeteer...');
|
this.logger.log('Starting crawl task with Puppeteer...');
|
||||||
|
|
||||||
// 设置最大执行时间为1小时
|
// 设置最大执行时间为3小时
|
||||||
const maxExecutionTime = 60 * 60 * 1000; // 1小时(毫秒)
|
const maxExecutionTime = 3 * 60 * 60 * 1000; // 3小时(毫秒)
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
// 统计结果
|
||||||
|
const crawlResults: Record<string, { success: number; error?: string }> =
|
||||||
|
{};
|
||||||
|
// 记录数据为0的爬虫,用于重试
|
||||||
|
const zeroDataCrawlers: AnyCrawler[] = [];
|
||||||
|
// 从环境变量读取代理配置
|
||||||
|
const proxyHost = this.configService.get<string>('PROXY_HOST');
|
||||||
|
const proxyPort = this.configService.get<string>('PROXY_PORT');
|
||||||
|
const proxyUsername = this.configService.get<string>('PROXY_USERNAME');
|
||||||
|
const proxyPassword = this.configService.get<string>('PROXY_PASSWORD');
|
||||||
|
|
||||||
|
// 构建代理参数
|
||||||
|
const args = [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-blink-features=AutomationControlled',
|
||||||
|
'--disable-infobars',
|
||||||
|
'--window-position=0,0',
|
||||||
|
'--ignore-certifcate-errors',
|
||||||
|
'--ignore-certifcate-errors-spki-list',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (proxyHost && proxyPort) {
|
||||||
|
const proxyUrl =
|
||||||
|
proxyUsername && proxyPassword
|
||||||
|
? `http://${proxyUsername}:${proxyPassword}@${proxyHost}:${proxyPort}`
|
||||||
|
: `http://${proxyHost}:${proxyPort}`;
|
||||||
|
args.push(`--proxy-server=${proxyUrl}`);
|
||||||
|
this.logger.log(`Using proxy: ${proxyHost}:${proxyPort}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: false,
|
||||||
|
args,
|
||||||
|
});
|
||||||
|
|
||||||
|
const crawlers = [
|
||||||
|
ChdtpCrawler,
|
||||||
|
ChngCrawler,
|
||||||
|
SzecpCrawler,
|
||||||
|
CdtCrawler,
|
||||||
|
EpsCrawler,
|
||||||
|
CnncecpCrawler,
|
||||||
|
CgnpcCrawler,
|
||||||
|
CeicCrawler,
|
||||||
|
EspicCrawler,
|
||||||
|
PowerbeijingCrawler,
|
||||||
|
SdiccCrawler,
|
||||||
|
CnoocCrawler,
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const crawler of crawlers) {
|
||||||
|
this.logger.log(`Crawling: ${crawler.name}`);
|
||||||
|
|
||||||
|
// 检查是否超时
|
||||||
|
const elapsedTime = Date.now() - startTime;
|
||||||
|
if (elapsedTime > maxExecutionTime) {
|
||||||
|
this.logger.warn(
|
||||||
|
`⚠️ Crawl task exceeded maximum execution time of 3 hours. Stopping...`,
|
||||||
|
);
|
||||||
|
this.logger.warn(
|
||||||
|
`⚠️ Total elapsed time: ${Math.floor(elapsedTime / 1000 / 60)} minutes`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await (crawler as any).crawl(browser);
|
||||||
|
this.logger.log(
|
||||||
|
`Extracted ${results.length} items from ${crawler.name}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 记录成功数量
|
||||||
|
crawlResults[crawler.name] = { success: results.length };
|
||||||
|
|
||||||
|
// 如果数据为0,记录下来用于重试
|
||||||
|
if (results.length === 0) {
|
||||||
|
zeroDataCrawlers.push(crawler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取最新的发布日期
|
||||||
|
const latestPublishDate =
|
||||||
|
results.length > 0
|
||||||
|
? results.reduce((latest, item) => {
|
||||||
|
const itemDate = new Date(item.publishDate);
|
||||||
|
return itemDate > latest ? itemDate : latest;
|
||||||
|
}, new Date(0))
|
||||||
|
: 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: publishDateUtc,
|
||||||
|
source: crawler.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存爬虫统计信息到数据库(将北京时间转为UTC)
|
||||||
|
await this.saveCrawlInfo(
|
||||||
|
crawler.name,
|
||||||
|
results.length,
|
||||||
|
latestPublishDate ? beijingToUtc(latestPublishDate) : null,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
this.logger.error(`Error crawling ${crawler.name}: ${errorMessage}`);
|
||||||
|
// 记录错误信息
|
||||||
|
crawlResults[crawler.name] = { success: 0, error: errorMessage };
|
||||||
|
|
||||||
|
// 保存错误信息到数据库
|
||||||
|
await this.saveCrawlInfo(crawler.name, 0, null, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对数据为0的爬虫进行重试
|
||||||
|
if (zeroDataCrawlers.length > 0) {
|
||||||
|
this.logger.log(
|
||||||
|
`Retrying ${zeroDataCrawlers.length} crawlers with zero data...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const crawler of zeroDataCrawlers) {
|
||||||
|
this.logger.log(`Retrying: ${crawler.name}`);
|
||||||
|
|
||||||
|
// 检查是否超时
|
||||||
|
const elapsedTime = Date.now() - startTime;
|
||||||
|
if (elapsedTime > maxExecutionTime) {
|
||||||
|
this.logger.warn(
|
||||||
|
`⚠️ Crawl task exceeded maximum execution time of 3 hours. Stopping retry...`,
|
||||||
|
);
|
||||||
|
this.logger.warn(
|
||||||
|
`⚠️ Total elapsed time: ${Math.floor(elapsedTime / 1000 / 60)} minutes`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await (crawler as any).crawl(browser);
|
||||||
|
this.logger.log(
|
||||||
|
`Retry extracted ${results.length} items from ${crawler.name}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新统计结果
|
||||||
|
crawlResults[crawler.name] = { success: results.length };
|
||||||
|
|
||||||
|
// 获取最新的发布日期
|
||||||
|
const latestPublishDate =
|
||||||
|
results.length > 0
|
||||||
|
? results.reduce((latest, item) => {
|
||||||
|
const itemDate = new Date(item.publishDate);
|
||||||
|
return itemDate > latest ? itemDate : latest;
|
||||||
|
}, new Date(0))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
for (const item of results) {
|
||||||
|
await this.bidsService.createOrUpdate({
|
||||||
|
title: item.title,
|
||||||
|
url: item.url,
|
||||||
|
publishDate: item.publishDate,
|
||||||
|
source: crawler.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新爬虫统计信息到数据库(将北京时间转为UTC)
|
||||||
|
await this.saveCrawlInfo(
|
||||||
|
crawler.name,
|
||||||
|
results.length,
|
||||||
|
latestPublishDate ? beijingToUtc(latestPublishDate) : null,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage =
|
||||||
|
err instanceof Error ? err.message : String(err);
|
||||||
|
this.logger.error(
|
||||||
|
`Error retrying ${crawler.name}: ${errorMessage}`,
|
||||||
|
);
|
||||||
|
// 记录错误信息
|
||||||
|
crawlResults[crawler.name] = { success: 0, error: errorMessage };
|
||||||
|
|
||||||
|
// 更新错误信息到数据库
|
||||||
|
await this.saveCrawlInfo(crawler.name, 0, null, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.error(`Crawl task failed: ${errorMessage}`);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
const totalTime = Date.now() - startTime;
|
||||||
|
const minutes = Math.floor(totalTime / 1000 / 60);
|
||||||
|
this.logger.log(`Crawl task finished. Total time: ${minutes} minutes`);
|
||||||
|
|
||||||
|
if (totalTime > maxExecutionTime) {
|
||||||
|
this.logger.warn(
|
||||||
|
`⚠️ Crawl task exceeded maximum execution time of 3 hours.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输出统计总结
|
||||||
|
this.logger.log('='.repeat(50));
|
||||||
|
this.logger.log('爬虫执行总结 / Crawl Summary');
|
||||||
|
this.logger.log('='.repeat(50));
|
||||||
|
|
||||||
|
let totalSuccess = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (const [source, result] of Object.entries(crawlResults)) {
|
||||||
|
if (result.error) {
|
||||||
|
this.logger.error(`❌ ${source}: 出错 - ${result.error}`);
|
||||||
|
errorCount++;
|
||||||
|
} else {
|
||||||
|
this.logger.log(
|
||||||
|
`✅ ${source}: 成功获取 ${result.success} 条工程信息`,
|
||||||
|
);
|
||||||
|
totalSuccess += result.success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('='.repeat(50));
|
||||||
|
this.logger.log(
|
||||||
|
`总计: ${totalSuccess} 条工程信息, ${errorCount} 个来源出错`,
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`Total: ${totalSuccess} items, ${errorCount} sources failed`,
|
||||||
|
);
|
||||||
|
this.logger.log('='.repeat(50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async crawlSingleSource(sourceName: string) {
|
||||||
|
this.logger.log(`Starting single source crawl for: ${sourceName}`);
|
||||||
|
|
||||||
// 从环境变量读取代理配置
|
// 从环境变量读取代理配置
|
||||||
const proxyHost = this.configService.get<string>('PROXY_HOST');
|
const proxyHost = this.configService.get<string>('PROXY_HOST');
|
||||||
@@ -47,9 +311,10 @@ export class BidCrawlerService {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (proxyHost && proxyPort) {
|
if (proxyHost && proxyPort) {
|
||||||
const proxyUrl = proxyUsername && proxyPassword
|
const proxyUrl =
|
||||||
? `http://${proxyUsername}:${proxyPassword}@${proxyHost}:${proxyPort}`
|
proxyUsername && proxyPassword
|
||||||
: `http://${proxyHost}:${proxyPort}`;
|
? `http://${proxyUsername}:${proxyPassword}@${proxyHost}:${proxyPort}`
|
||||||
|
: `http://${proxyHost}:${proxyPort}`;
|
||||||
args.push(`--proxy-server=${proxyUrl}`);
|
args.push(`--proxy-server=${proxyUrl}`);
|
||||||
this.logger.log(`Using proxy: ${proxyHost}:${proxyPort}`);
|
this.logger.log(`Using proxy: ${proxyHost}:${proxyPort}`);
|
||||||
}
|
}
|
||||||
@@ -59,49 +324,144 @@ export class BidCrawlerService {
|
|||||||
args,
|
args,
|
||||||
});
|
});
|
||||||
|
|
||||||
const crawlers = [ChdtpCrawler, ChngCrawler, SzecpCrawler, CdtCrawler, EpsCrawler, CnncecpCrawler, CgnpcCrawler, CeicCrawler, EspicCrawler, PowerbeijingCrawler];
|
const crawlers = [
|
||||||
|
ChdtpCrawler,
|
||||||
|
ChngCrawler,
|
||||||
|
SzecpCrawler,
|
||||||
|
CdtCrawler,
|
||||||
|
EpsCrawler,
|
||||||
|
CnncecpCrawler,
|
||||||
|
CgnpcCrawler,
|
||||||
|
CeicCrawler,
|
||||||
|
EspicCrawler,
|
||||||
|
PowerbeijingCrawler,
|
||||||
|
SdiccCrawler,
|
||||||
|
CnoocCrawler,
|
||||||
|
];
|
||||||
|
|
||||||
|
const targetCrawler = crawlers.find((c) => c.name === sourceName);
|
||||||
|
|
||||||
|
if (!targetCrawler) {
|
||||||
|
await browser.close();
|
||||||
|
throw new Error(`Crawler not found for source: ${sourceName}`);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const crawler of crawlers) {
|
this.logger.log(`Crawling: ${targetCrawler.name}`);
|
||||||
this.logger.log(`Crawling: ${crawler.name}`);
|
|
||||||
|
|
||||||
// 检查是否超时
|
const results = await (targetCrawler as any).crawl(browser);
|
||||||
const elapsedTime = Date.now() - startTime;
|
this.logger.log(
|
||||||
if (elapsedTime > maxExecutionTime) {
|
`Extracted ${results.length} items from ${targetCrawler.name}`,
|
||||||
this.logger.warn(`⚠️ Crawl task exceeded maximum execution time of 1 hour. Stopping...`);
|
);
|
||||||
this.logger.warn(`⚠️ Total elapsed time: ${Math.floor(elapsedTime / 1000 / 60)} minutes`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
// 获取最新的发布日期
|
||||||
const results = await crawler.crawl(browser);
|
const latestPublishDate =
|
||||||
this.logger.log(`Extracted ${results.length} items from ${crawler.name}`);
|
results.length > 0
|
||||||
|
? results.reduce((latest, item) => {
|
||||||
|
const itemDate = new Date(item.publishDate);
|
||||||
|
return itemDate > latest ? itemDate : latest;
|
||||||
|
}, new Date(0))
|
||||||
|
: null;
|
||||||
|
|
||||||
for (const item of results) {
|
for (const item of results) {
|
||||||
await this.bidsService.createOrUpdate({
|
// 将北京时间转换为UTC存储
|
||||||
title: item.title,
|
const publishDateUtc = beijingToUtc(new Date(item.publishDate));
|
||||||
url: item.url,
|
await this.bidsService.createOrUpdate({
|
||||||
publishDate: item.publishDate,
|
title: item.title,
|
||||||
source: crawler.name,
|
url: item.url,
|
||||||
unit: '',
|
publishDate: publishDateUtc,
|
||||||
});
|
source: targetCrawler.name,
|
||||||
}
|
});
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(`Error crawling ${crawler.name}: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Crawl task failed: ${error.message}`);
|
// 保存爬虫统计信息到数据库(将北京时间转为UTC)
|
||||||
|
await this.saveCrawlInfo(
|
||||||
|
targetCrawler.name,
|
||||||
|
results.length,
|
||||||
|
latestPublishDate ? beijingToUtc(latestPublishDate) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
source: targetCrawler.name,
|
||||||
|
count: results.length,
|
||||||
|
latestPublishDate,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
this.logger.error(
|
||||||
|
`Error crawling ${targetCrawler.name}: ${errorMessage}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 保存错误信息到数据库
|
||||||
|
await this.saveCrawlInfo(targetCrawler.name, 0, null, errorMessage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
source: targetCrawler.name,
|
||||||
|
count: 0,
|
||||||
|
error: errorMessage,
|
||||||
|
};
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const totalTime = Date.now() - startTime;
|
private async saveCrawlInfo(
|
||||||
const minutes = Math.floor(totalTime / 1000 / 60);
|
source: string,
|
||||||
this.logger.log(`Crawl task finished. Total time: ${minutes} minutes`);
|
count: number,
|
||||||
|
latestPublishDate: Date | null,
|
||||||
|
error?: string,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const crawlInfo = this.crawlInfoRepository.create({
|
||||||
|
source,
|
||||||
|
count,
|
||||||
|
latestPublishDate,
|
||||||
|
// 确保 error 字段正确处理:undefined 或空字符串都转换为 null
|
||||||
|
error: error && error.trim() !== '' ? error : null,
|
||||||
|
});
|
||||||
|
await this.crawlInfoRepository.save(crawlInfo);
|
||||||
|
this.logger.log(`Saved crawl info for ${source}: ${count} items`);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to save crawl info for ${source}: ${errorMessage}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (totalTime > maxExecutionTime) {
|
// 更新爬虫状态,count = -1 表示正在更新
|
||||||
this.logger.warn(`⚠️ Crawl task exceeded maximum execution time of 1 hour.`);
|
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}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,37 @@ import { CdtCrawler } from './cdt_target';
|
|||||||
import * as puppeteer from 'puppeteer';
|
import * as puppeteer from 'puppeteer';
|
||||||
|
|
||||||
// Increase timeout to 60 seconds for network operations
|
// Increase timeout to 60 seconds for network operations
|
||||||
jest.setTimeout(60000*5);
|
jest.setTimeout(60000 * 5);
|
||||||
|
|
||||||
|
// 获取代理配置
|
||||||
|
const getProxyArgs = (): string[] => {
|
||||||
|
const proxyHost = process.env.PROXY_HOST;
|
||||||
|
const proxyPort = process.env.PROXY_PORT;
|
||||||
|
const proxyUsername = process.env.PROXY_USERNAME;
|
||||||
|
const proxyPassword = process.env.PROXY_PASSWORD;
|
||||||
|
|
||||||
|
if (proxyHost && proxyPort) {
|
||||||
|
const args = [`--proxy-server=${proxyHost}:${proxyPort}`];
|
||||||
|
if (proxyUsername && proxyPassword) {
|
||||||
|
args.push(`--proxy-auth=${proxyUsername}:${proxyPassword}`);
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
describe('CdtCrawler Real Site Test', () => {
|
describe('CdtCrawler Real Site Test', () => {
|
||||||
let browser: puppeteer.Browser;
|
let browser: puppeteer.Browser;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
const proxyArgs = getProxyArgs();
|
||||||
|
if (proxyArgs.length > 0) {
|
||||||
|
console.log('Using proxy:', proxyArgs.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
browser = await puppeteer.launch({
|
browser = await puppeteer.launch({
|
||||||
headless: false, // Change to false to see browser UI
|
headless: false, // Change to false to see browser UI
|
||||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
args: ['--no-sandbox', '--disable-setuid-sandbox', ...proxyArgs],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,7 +51,9 @@ describe('CdtCrawler Real Site Test', () => {
|
|||||||
console.log(`\nSuccessfully found ${results.length} items:\n`);
|
console.log(`\nSuccessfully found ${results.length} items:\n`);
|
||||||
console.log('----------------------------------------');
|
console.log('----------------------------------------');
|
||||||
results.forEach((item, index) => {
|
results.forEach((item, index) => {
|
||||||
console.log(`${index + 1}. [${item.publishDate.toLocaleDateString()}] ${item.title}`);
|
console.log(
|
||||||
|
`${index + 1}. [${item.publishDate.toLocaleDateString()}] ${item.title}`,
|
||||||
|
);
|
||||||
console.log(` Link: ${item.url}`);
|
console.log(` Link: ${item.url}`);
|
||||||
console.log('----------------------------------------');
|
console.log('----------------------------------------');
|
||||||
});
|
});
|
||||||
@@ -39,13 +63,15 @@ describe('CdtCrawler Real Site Test', () => {
|
|||||||
expect(Array.isArray(results)).toBeTruthy();
|
expect(Array.isArray(results)).toBeTruthy();
|
||||||
// Warn but don't fail if site returns 0 items (could be empty or changed structure)
|
// Warn but don't fail if site returns 0 items (could be empty or changed structure)
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
console.warn('Warning: No items found. Check if website structure has changed or if list is currently empty.');
|
console.warn(
|
||||||
|
'Warning: No items found. Check if website structure has changed or if list is currently empty.',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Check data integrity of first item
|
// Check data integrity of first item
|
||||||
const firstItem = results[0];
|
const firstItem = results[0];
|
||||||
expect(firstItem.title).toBeTruthy();
|
expect(firstItem.title).toBeTruthy();
|
||||||
expect(firstItem.url).toMatch(/^https?:\/\//);
|
expect(firstItem.url).toMatch(/^https?:\/\//);
|
||||||
expect(firstItem.publishDate).toBeInstanceOf(Date);
|
expect(firstItem.publishDate).toBeInstanceOf(Date);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ async function simulateHumanMouseMovement(page: puppeteer.Page) {
|
|||||||
const y = Math.floor(Math.random() * viewport.height);
|
const y = Math.floor(Math.random() * viewport.height);
|
||||||
|
|
||||||
await page.mouse.move(x, y, {
|
await page.mouse.move(x, y, {
|
||||||
steps: 10 + Math.floor(Math.random() * 20) // 10-30步,使移动更平滑
|
steps: 10 + Math.floor(Math.random() * 20), // 10-30步,使移动更平滑
|
||||||
});
|
});
|
||||||
|
|
||||||
// 随机停顿 100-500ms
|
// 随机停顿 100-500ms
|
||||||
await new Promise(r => setTimeout(r, 100 + Math.random() * 400));
|
await new Promise((r) => setTimeout(r, 100 + Math.random() * 400));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,19 +31,69 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
|||||||
await page.evaluate((distance) => {
|
await page.evaluate((distance) => {
|
||||||
window.scrollBy({
|
window.scrollBy({
|
||||||
top: distance,
|
top: distance,
|
||||||
behavior: 'smooth'
|
behavior: 'smooth',
|
||||||
});
|
});
|
||||||
}, scrollDistance);
|
}, scrollDistance);
|
||||||
|
|
||||||
// 随机停顿 500-1500ms
|
// 随机停顿 500-1500ms
|
||||||
await new Promise(r => setTimeout(r, 500 + Math.random() * 1000));
|
await new Promise((r) => setTimeout(r, 500 + Math.random() * 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 滚动回顶部
|
// 滚动回顶部
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
});
|
});
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
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 {
|
export interface CdtResult {
|
||||||
@@ -52,12 +102,22 @@ export interface CdtResult {
|
|||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CdtCrawlerType {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
baseUrl: string;
|
||||||
|
extract(html: string): CdtResult[];
|
||||||
|
}
|
||||||
|
|
||||||
export const CdtCrawler = {
|
export const CdtCrawler = {
|
||||||
name: '中国大唐集团电子商务平台',
|
name: '中国大唐集团电子商务平台',
|
||||||
url: 'https://tang.cdt-ec.com/home/index.html',
|
url: 'https://tang.cdt-ec.com/home/index.html',
|
||||||
baseUrl: 'https://tang.cdt-ec.com',
|
baseUrl: 'https://tang.cdt-ec.com',
|
||||||
|
|
||||||
async crawl(browser: puppeteer.Browser): Promise<CdtResult[]> {
|
async crawl(
|
||||||
|
this: CdtCrawlerType,
|
||||||
|
browser: puppeteer.Browser,
|
||||||
|
): Promise<CdtResult[]> {
|
||||||
const logger = new Logger('CdtCrawler');
|
const logger = new Logger('CdtCrawler');
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
|
|
||||||
@@ -67,7 +127,9 @@ export const CdtCrawler = {
|
|||||||
await page.authenticate({ username, password });
|
await page.authenticate({ username, password });
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36');
|
await page.setUserAgent(
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36',
|
||||||
|
);
|
||||||
|
|
||||||
const allResults: CdtResult[] = [];
|
const allResults: CdtResult[] = [];
|
||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
@@ -75,7 +137,17 @@ export const CdtCrawler = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.log(`Navigating to ${this.url}...`);
|
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...');
|
logger.log('Simulating human mouse movements...');
|
||||||
@@ -86,19 +158,26 @@ export const CdtCrawler = {
|
|||||||
|
|
||||||
// 点击"招标公告"标签
|
// 点击"招标公告"标签
|
||||||
logger.log('Looking for "招标公告" tab...');
|
logger.log('Looking for "招标公告" tab...');
|
||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(
|
||||||
const tabs = Array.from(document.querySelectorAll('span.notice-tab'));
|
() => {
|
||||||
return tabs.some(tab => tab.textContent && tab.textContent.includes('招标公告'));
|
const tabs = Array.from(document.querySelectorAll('span.notice-tab'));
|
||||||
}, { timeout: 30000 });
|
return tabs.some(
|
||||||
|
(tab) => tab.textContent && tab.textContent.includes('招标公告'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const tabs = Array.from(document.querySelectorAll('span.notice-tab'));
|
const tabs = Array.from(document.querySelectorAll('span.notice-tab'));
|
||||||
const target = tabs.find(tab => tab.textContent && tab.textContent.includes('招标公告')) as HTMLElement;
|
const target = tabs.find(
|
||||||
|
(tab) => tab.textContent && tab.textContent.includes('招标公告'),
|
||||||
|
) as HTMLElement;
|
||||||
if (target) target.click();
|
if (target) target.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.log('Clicked "招标公告" tab.');
|
logger.log('Clicked "招标公告" tab.');
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
|
||||||
// 模拟人类行为
|
// 模拟人类行为
|
||||||
logger.log('Simulating human mouse movements...');
|
logger.log('Simulating human mouse movements...');
|
||||||
@@ -109,26 +188,43 @@ export const CdtCrawler = {
|
|||||||
|
|
||||||
// 点击"招标公告"下的"更多+"链接
|
// 点击"招标公告"下的"更多+"链接
|
||||||
logger.log('Looking for "更多+" link under "招标公告"...');
|
logger.log('Looking for "更多+" link under "招标公告"...');
|
||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(
|
||||||
const titles = Array.from(document.querySelectorAll('span.h-notice-title'));
|
() => {
|
||||||
return titles.some(title => title.textContent && title.textContent.includes('招标公告'));
|
const titles = Array.from(
|
||||||
}, { timeout: 30000 });
|
document.querySelectorAll('span.h-notice-title'),
|
||||||
|
);
|
||||||
|
return titles.some(
|
||||||
|
(title) =>
|
||||||
|
title.textContent && title.textContent.includes('招标公告'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 30000 },
|
||||||
|
);
|
||||||
|
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const titles = Array.from(document.querySelectorAll('span.h-notice-title'));
|
const titles = Array.from(
|
||||||
const targetTitle = titles.find(title => title.textContent && title.textContent.includes('招标公告'));
|
document.querySelectorAll('span.h-notice-title'),
|
||||||
|
);
|
||||||
|
const targetTitle = titles.find(
|
||||||
|
(title) =>
|
||||||
|
title.textContent && title.textContent.includes('招标公告'),
|
||||||
|
);
|
||||||
if (targetTitle) {
|
if (targetTitle) {
|
||||||
const parent = targetTitle.parentElement;
|
const parent = targetTitle.parentElement;
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const moreLink = parent.querySelector('a.h-notice-more') as HTMLElement;
|
const moreLink = parent.querySelector(
|
||||||
|
'a.h-notice-more',
|
||||||
|
) as HTMLElement;
|
||||||
if (moreLink) moreLink.click();
|
if (moreLink) moreLink.click();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.log('Clicked "更多+" link under "招标公告".');
|
logger.log('Clicked "更多+" link under "招标公告".');
|
||||||
await page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 60000 }).catch(() => {});
|
await page
|
||||||
await new Promise(r => setTimeout(r, 3000));
|
.waitForNavigation({ waitUntil: 'networkidle2', timeout: 60000 })
|
||||||
|
.catch(() => {});
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
|
||||||
// 模拟人类行为
|
// 模拟人类行为
|
||||||
logger.log('Simulating human mouse movements...');
|
logger.log('Simulating human mouse movements...');
|
||||||
@@ -137,16 +233,27 @@ export const CdtCrawler = {
|
|||||||
logger.log('Simulating human scrolling...');
|
logger.log('Simulating human scrolling...');
|
||||||
await simulateHumanScrolling(page);
|
await simulateHumanScrolling(page);
|
||||||
|
|
||||||
|
// 等待表格加载完成
|
||||||
|
logger.log('Waiting for table to load...');
|
||||||
|
await page.waitForSelector('table.layui-table', { timeout: 30000 });
|
||||||
|
|
||||||
while (currentPage <= maxPages) {
|
while (currentPage <= maxPages) {
|
||||||
|
// 等待表格数据加载
|
||||||
|
await page.waitForSelector('tbody tr', { timeout: 10000 });
|
||||||
|
|
||||||
|
// 获取当前页面的 HTML 内容
|
||||||
const content = await page.content();
|
const content = await page.content();
|
||||||
const pageResults = this.extract(content);
|
const pageResults = this.extract(content);
|
||||||
|
|
||||||
if (pageResults.length === 0) {
|
if (pageResults.length === 0) {
|
||||||
logger.warn(`No results found on page ${currentPage}, stopping.`);
|
logger.warn(`No results found on page ${currentPage}, stopping.`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
allResults.push(...pageResults);
|
allResults.push(...pageResults);
|
||||||
logger.log(`Extracted ${pageResults.length} items from page ${currentPage}`);
|
logger.log(
|
||||||
|
`Extracted ${pageResults.length} items from page ${currentPage}`,
|
||||||
|
);
|
||||||
|
|
||||||
// 模拟人类行为 - 翻页前
|
// 模拟人类行为 - 翻页前
|
||||||
logger.log('Simulating human mouse movements before pagination...');
|
logger.log('Simulating human mouse movements before pagination...');
|
||||||
@@ -155,52 +262,91 @@ export const CdtCrawler = {
|
|||||||
logger.log('Simulating human scrolling before pagination...');
|
logger.log('Simulating human scrolling before pagination...');
|
||||||
await simulateHumanScrolling(page);
|
await simulateHumanScrolling(page);
|
||||||
|
|
||||||
// Find the "Next Page" button - layui pagination
|
// 查找下一页按钮
|
||||||
const nextButtonSelector = 'a.layui-laypage-next:not(.layui-disabled)';
|
const nextButtonSelector = 'a.layui-laypage-next:not(.layui-disabled)';
|
||||||
const nextButton = await page.$(nextButtonSelector);
|
const nextButtonExists = await page.evaluate((selector) => {
|
||||||
|
const btn = document.querySelector(selector);
|
||||||
|
return btn !== null && !btn.classList.contains('layui-disabled');
|
||||||
|
}, nextButtonSelector);
|
||||||
|
|
||||||
if (!nextButton) {
|
if (!nextButtonExists) {
|
||||||
logger.log('Next page button not found. Reached end of list.');
|
logger.log(
|
||||||
|
'Next page button not found or disabled. Reached end of list.',
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log(`Navigating to page ${currentPage + 1}...`);
|
logger.log(`Navigating to page ${currentPage + 1}...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
// 点击下一页按钮
|
||||||
page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 60000 }),
|
await page.evaluate((selector) => {
|
||||||
nextButton.click(),
|
const btn = document.querySelector(selector) as HTMLElement;
|
||||||
]);
|
if (btn) btn.click();
|
||||||
|
}, nextButtonSelector);
|
||||||
|
|
||||||
|
// 等待 AJAX 请求完成(通过监听网络请求)
|
||||||
|
await page
|
||||||
|
.waitForFunction(
|
||||||
|
() => {
|
||||||
|
// 检查表格是否正在加载
|
||||||
|
const loading = document.querySelector('.layui-table-loading');
|
||||||
|
return !loading;
|
||||||
|
},
|
||||||
|
{ timeout: 30000 },
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// 额外等待确保数据加载完成
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
|
||||||
|
// 检查是否真的翻页了(通过检查当前页码)
|
||||||
|
const currentActivePage = await page.evaluate(() => {
|
||||||
|
const activeSpan = document.querySelector(
|
||||||
|
'.layui-laypage-curr em:last-child',
|
||||||
|
);
|
||||||
|
return activeSpan ? parseInt(activeSpan.textContent || '1') : 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentActivePage <= currentPage) {
|
||||||
|
logger.log('Page did not change, stopping.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
} catch (navError) {
|
} catch (navError) {
|
||||||
logger.error(`Navigation to page ${currentPage + 1} failed: ${navError.message}`);
|
const navErrorMessage =
|
||||||
|
navError instanceof Error ? navError.message : String(navError);
|
||||||
|
logger.error(
|
||||||
|
`Navigation to page ${currentPage + 1} failed: ${navErrorMessage}`,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return allResults;
|
return allResults;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to crawl ${this.name}: ${error.message}`);
|
const errorMessage =
|
||||||
return allResults;
|
error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error(`Failed to crawl ${this.name}: ${errorMessage}`);
|
||||||
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await page.close();
|
await page.close();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
extract(html: string): CdtResult[] {
|
extract(this: CdtCrawlerType, html: string): CdtResult[] {
|
||||||
const results: CdtResult[] = [];
|
const results: CdtResult[] = [];
|
||||||
/**
|
/**
|
||||||
* Regex groups for tang.cdt-ec.com:
|
* Regex groups for tang.cdt-ec.com:
|
||||||
@@ -208,22 +354,24 @@ export const CdtCrawler = {
|
|||||||
* 2: Title (项目名称)
|
* 2: Title (项目名称)
|
||||||
* 3: Date (发布时间)
|
* 3: Date (发布时间)
|
||||||
*/
|
*/
|
||||||
const regex = /<tr[^>]*data-index="[^"]*"[^>]*>[\s\S]*?<a[^>]*class="layui-table-link"[^>]*href="([^"]*)"[^>]*>([^<]*)<\/a>[\s\S]*?<td[^>]*data-field="publish_time"[^>]*>[\s\S]*?<div[^>]*class="layui-table-cell[^"]*"[^>]*>([^<]*)<\/div>[\s\S]*?<\/td>[\s\S]*?<\/tr>/gs;
|
const regex =
|
||||||
|
/<tr[^>]*data-index="[^"]*"[^>]*>[\s\S]*?<a[^>]*class="layui-table-link"[^>]*href="([^"]*)"[^>]*>([^<]*)<\/a>[\s\S]*?<td[^>]*data-field="publish_time"[^>]*>[\s\S]*?<div[^>]*class="layui-table-cell[^"]*"[^>]*>([^<]*)<\/div>[\s\S]*?<\/td>[\s\S]*?<\/tr>/gs;
|
||||||
|
|
||||||
let match;
|
let match: RegExpExecArray | null;
|
||||||
while ((match = regex.exec(html)) !== null) {
|
while ((match = regex.exec(html)) !== null) {
|
||||||
const url = match[1]?.trim();
|
const url = match[1]?.trim() ?? '';
|
||||||
const title = match[2]?.trim();
|
const title = match[2]?.trim() ?? '';
|
||||||
const dateStr = match[3]?.trim();
|
const dateStr = match[3]?.trim() ?? '';
|
||||||
|
|
||||||
if (title && url) {
|
if (title && url) {
|
||||||
|
const fullUrl = url.startsWith('http') ? url : this.baseUrl + url;
|
||||||
results.push({
|
results.push({
|
||||||
title,
|
title,
|
||||||
publishDate: dateStr ? new Date(dateStr) : new Date(),
|
publishDate: dateStr ? new Date(dateStr) : new Date(),
|
||||||
url: url.startsWith('http') ? url : this.baseUrl + url
|
url: fullUrl.replace(/\/\//g, '/'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,10 +4,32 @@ import * as puppeteer from 'puppeteer';
|
|||||||
// Increase timeout to 120 seconds for manual inspection and slow sites
|
// Increase timeout to 120 seconds for manual inspection and slow sites
|
||||||
jest.setTimeout(120000);
|
jest.setTimeout(120000);
|
||||||
|
|
||||||
|
// 获取代理配置
|
||||||
|
const getProxyArgs = (): string[] => {
|
||||||
|
const proxyHost = process.env.PROXY_HOST;
|
||||||
|
const proxyPort = process.env.PROXY_PORT;
|
||||||
|
const proxyUsername = process.env.PROXY_USERNAME;
|
||||||
|
const proxyPassword = process.env.PROXY_PASSWORD;
|
||||||
|
|
||||||
|
if (proxyHost && proxyPort) {
|
||||||
|
const args = [`--proxy-server=${proxyHost}:${proxyPort}`];
|
||||||
|
if (proxyUsername && proxyPassword) {
|
||||||
|
args.push(`--proxy-auth=${proxyUsername}:${proxyPassword}`);
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
describe('CeicCrawler Real Site Test', () => {
|
describe('CeicCrawler Real Site Test', () => {
|
||||||
let browser: puppeteer.Browser;
|
let browser: puppeteer.Browser;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
const proxyArgs = getProxyArgs();
|
||||||
|
if (proxyArgs.length > 0) {
|
||||||
|
console.log('Using proxy:', proxyArgs.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
browser = await puppeteer.launch({
|
browser = await puppeteer.launch({
|
||||||
headless: false, // Run in non-headless mode
|
headless: false, // Run in non-headless mode
|
||||||
args: [
|
args: [
|
||||||
@@ -16,15 +38,16 @@ describe('CeicCrawler Real Site Test', () => {
|
|||||||
'--disable-blink-features=AutomationControlled',
|
'--disable-blink-features=AutomationControlled',
|
||||||
'--window-size=1920,1080',
|
'--window-size=1920,1080',
|
||||||
'--disable-infobars',
|
'--disable-infobars',
|
||||||
|
...proxyArgs,
|
||||||
],
|
],
|
||||||
defaultViewport: null
|
defaultViewport: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
// Keep open for a few seconds after test to see result
|
// Keep open for a few seconds after test to see result
|
||||||
await new Promise(r => setTimeout(r, 50000));
|
await new Promise((r) => setTimeout(r, 50000));
|
||||||
await browser.close();
|
await browser.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -41,7 +64,9 @@ Successfully found ${results.length} items:
|
|||||||
`);
|
`);
|
||||||
console.log('----------------------------------------');
|
console.log('----------------------------------------');
|
||||||
results.forEach((item, index) => {
|
results.forEach((item, index) => {
|
||||||
console.log(`${index + 1}. [${item.publishDate.toLocaleDateString()}] ${item.title}`);
|
console.log(
|
||||||
|
`${index + 1}. [${item.publishDate.toLocaleDateString()}] ${item.title}`,
|
||||||
|
);
|
||||||
console.log(` Link: ${item.url}`);
|
console.log(` Link: ${item.url}`);
|
||||||
console.log('----------------------------------------');
|
console.log('----------------------------------------');
|
||||||
});
|
});
|
||||||
@@ -50,12 +75,14 @@ Successfully found ${results.length} items:
|
|||||||
expect(Array.isArray(results)).toBeTruthy();
|
expect(Array.isArray(results)).toBeTruthy();
|
||||||
|
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
console.warn('Warning: No items found. Observe browser window to see if content is loading or if there is a verification challenge.');
|
console.warn(
|
||||||
|
'Warning: No items found. Observe browser window to see if content is loading or if there is a verification challenge.',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const firstItem = results[0];
|
const firstItem = results[0];
|
||||||
expect(firstItem.title).toBeTruthy();
|
expect(firstItem.title).toBeTruthy();
|
||||||
expect(firstItem.url).toMatch(/^https?:\/\//);
|
expect(firstItem.url).toMatch(/^https?:\/\//);
|
||||||
expect(firstItem.publishDate).toBeInstanceOf(Date);
|
expect(firstItem.publishDate).toBeInstanceOf(Date);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ async function simulateHumanMouseMovement(page: puppeteer.Page) {
|
|||||||
const y = Math.floor(Math.random() * viewport.height);
|
const y = Math.floor(Math.random() * viewport.height);
|
||||||
|
|
||||||
await page.mouse.move(x, y, {
|
await page.mouse.move(x, y, {
|
||||||
steps: 10 + Math.floor(Math.random() * 20) // 10-30步,使移动更平滑
|
steps: 10 + Math.floor(Math.random() * 20), // 10-30步,使移动更平滑
|
||||||
});
|
});
|
||||||
|
|
||||||
// 随机停顿 100-500ms
|
// 随机停顿 100-500ms
|
||||||
await new Promise(r => setTimeout(r, 100 + Math.random() * 400));
|
await new Promise((r) => setTimeout(r, 100 + Math.random() * 400));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,27 +32,86 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
|||||||
await page.evaluate((distance) => {
|
await page.evaluate((distance) => {
|
||||||
window.scrollBy({
|
window.scrollBy({
|
||||||
top: distance,
|
top: distance,
|
||||||
behavior: 'smooth'
|
behavior: 'smooth',
|
||||||
});
|
});
|
||||||
}, scrollDistance);
|
}, scrollDistance);
|
||||||
|
|
||||||
// 随机停顿 500-1500ms
|
// 随机停顿 500-1500ms
|
||||||
await new Promise(r => setTimeout(r, 500 + Math.random() * 1000));
|
await new Promise((r) => setTimeout(r, 500 + Math.random() * 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 滚动回顶部
|
// 滚动回顶部
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
});
|
});
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
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;
|
||||||
|
baseUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CeicCrawler = {
|
export const CeicCrawler = {
|
||||||
name: '大连能源采购平台',
|
name: '国家能源集团生态协作平台',
|
||||||
url: 'https://ceic.dlnyzb.com/3001',
|
url: 'https://ceic.dlnyzb.com/3001',
|
||||||
baseUrl: 'https://ceic.dlnyzb.com',
|
baseUrl: 'https://ceic.dlnyzb.com/',
|
||||||
|
|
||||||
async crawl(browser: puppeteer.Browser): Promise<ChdtpResult[]> {
|
async crawl(
|
||||||
|
this: CeicCrawlerType,
|
||||||
|
browser: puppeteer.Browser,
|
||||||
|
): Promise<ChdtpResult[]> {
|
||||||
const logger = new Logger('CeicCrawler');
|
const logger = new Logger('CeicCrawler');
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
|
|
||||||
@@ -65,10 +124,14 @@ export const CeicCrawler = {
|
|||||||
await page.evaluateOnNewDocument(() => {
|
await page.evaluateOnNewDocument(() => {
|
||||||
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
||||||
Object.defineProperty(navigator, 'language', { get: () => 'zh-CN' });
|
Object.defineProperty(navigator, 'language', { get: () => 'zh-CN' });
|
||||||
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
|
Object.defineProperty(navigator, 'plugins', {
|
||||||
|
get: () => [1, 2, 3, 4, 5],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36');
|
await page.setUserAgent(
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36',
|
||||||
|
);
|
||||||
await page.setViewport({ width: 1920, height: 1080 });
|
await page.setViewport({ width: 1920, height: 1080 });
|
||||||
|
|
||||||
const allResults: ChdtpResult[] = [];
|
const allResults: ChdtpResult[] = [];
|
||||||
@@ -77,7 +140,17 @@ export const CeicCrawler = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.log(`Navigating to ${this.url}...`);
|
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...');
|
logger.log('Simulating human mouse movements...');
|
||||||
@@ -90,16 +163,25 @@ export const CeicCrawler = {
|
|||||||
logger.log(`Processing page ${currentPage}...`);
|
logger.log(`Processing page ${currentPage}...`);
|
||||||
|
|
||||||
// Wait for content to load - MUI list items
|
// Wait for content to load - MUI list items
|
||||||
await page.waitForFunction(() => {
|
await page
|
||||||
return document.querySelectorAll('li.MuiListItem-root').length > 0;
|
.waitForFunction(
|
||||||
}, { timeout: 60000 }).catch(() => logger.warn('Content not found. Site might be slow.'));
|
() => {
|
||||||
|
return (
|
||||||
|
document.querySelectorAll('li.MuiListItem-root').length > 0
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 60000 },
|
||||||
|
)
|
||||||
|
.catch(() => logger.warn('Content not found. Site might be slow.'));
|
||||||
|
|
||||||
const pageResults = await page.evaluate(() => {
|
const pageResults = await page.evaluate(() => {
|
||||||
const results: { title: string; dateStr: string; url: string }[] = [];
|
const results: { title: string; dateStr: string; url: string }[] = [];
|
||||||
|
|
||||||
// Extract from MUI list items
|
// Extract from MUI list items
|
||||||
const listItems = Array.from(document.querySelectorAll('li.MuiListItem-root'));
|
const listItems = Array.from(
|
||||||
listItems.forEach(item => {
|
document.querySelectorAll('li.MuiListItem-root'),
|
||||||
|
);
|
||||||
|
listItems.forEach((item) => {
|
||||||
// Find the title link
|
// Find the title link
|
||||||
const titleLink = item.querySelector('a.css-1vdw90h');
|
const titleLink = item.querySelector('a.css-1vdw90h');
|
||||||
const title = titleLink?.textContent?.trim() || '';
|
const title = titleLink?.textContent?.trim() || '';
|
||||||
@@ -125,15 +207,19 @@ export const CeicCrawler = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (pageResults.length === 0) {
|
if (pageResults.length === 0) {
|
||||||
logger.warn(`No results found on page ${currentPage}. Extraction failed.`);
|
logger.warn(
|
||||||
|
`No results found on page ${currentPage}. Extraction failed.`,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
allResults.push(...pageResults.map(r => ({
|
allResults.push(
|
||||||
title: r.title,
|
...pageResults.map((r) => ({
|
||||||
publishDate: r.dateStr ? new Date(r.dateStr) : new Date(),
|
title: r.title,
|
||||||
url: r.url
|
publishDate: r.dateStr ? new Date(r.dateStr) : new Date(),
|
||||||
})));
|
url: r.url.replace(/\/\//g, '/'),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
logger.log(`Extracted ${pageResults.length} items.`);
|
logger.log(`Extracted ${pageResults.length} items.`);
|
||||||
|
|
||||||
@@ -142,7 +228,7 @@ export const CeicCrawler = {
|
|||||||
if (!nextButton) break;
|
if (!nextButton) break;
|
||||||
|
|
||||||
await nextButton.click();
|
await nextButton.click();
|
||||||
await new Promise(r => setTimeout(r, 3000));
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
|
||||||
// 模拟人类行为
|
// 模拟人类行为
|
||||||
logger.log('Simulating human mouse movements...');
|
logger.log('Simulating human mouse movements...');
|
||||||
@@ -155,14 +241,17 @@ export const CeicCrawler = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return allResults;
|
return allResults;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Crawl failed: ${error.message}`);
|
const errorMessage =
|
||||||
return allResults;
|
error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error(`Crawl failed: ${errorMessage}`);
|
||||||
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (page) await page.close();
|
if (page) await page.close();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
extract() { return []; }
|
extract() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,15 +2,37 @@ import { CgnpcCrawler } from './cgnpc_target';
|
|||||||
import * as puppeteer from 'puppeteer';
|
import * as puppeteer from 'puppeteer';
|
||||||
|
|
||||||
// Increase timeout to 60 seconds for network operations
|
// Increase timeout to 60 seconds for network operations
|
||||||
jest.setTimeout(60000*5);
|
jest.setTimeout(60000 * 5);
|
||||||
|
|
||||||
|
// 获取代理配置
|
||||||
|
const getProxyArgs = (): string[] => {
|
||||||
|
const proxyHost = process.env.PROXY_HOST;
|
||||||
|
const proxyPort = process.env.PROXY_PORT;
|
||||||
|
const proxyUsername = process.env.PROXY_USERNAME;
|
||||||
|
const proxyPassword = process.env.PROXY_PASSWORD;
|
||||||
|
|
||||||
|
if (proxyHost && proxyPort) {
|
||||||
|
const args = [`--proxy-server=${proxyHost}:${proxyPort}`];
|
||||||
|
if (proxyUsername && proxyPassword) {
|
||||||
|
args.push(`--proxy-auth=${proxyUsername}:${proxyPassword}`);
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
describe('CgnpcCrawler Real Site Test', () => {
|
describe('CgnpcCrawler Real Site Test', () => {
|
||||||
let browser: puppeteer.Browser;
|
let browser: puppeteer.Browser;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
const proxyArgs = getProxyArgs();
|
||||||
|
if (proxyArgs.length > 0) {
|
||||||
|
console.log('Using proxy:', proxyArgs.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
browser = await puppeteer.launch({
|
browser = await puppeteer.launch({
|
||||||
headless: false, // Change to false to see browser UI
|
headless: false, // Change to false to see browser UI
|
||||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
args: ['--no-sandbox', '--disable-setuid-sandbox', ...proxyArgs],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,7 +51,9 @@ describe('CgnpcCrawler Real Site Test', () => {
|
|||||||
console.log(`\nSuccessfully found ${results.length} items:\n`);
|
console.log(`\nSuccessfully found ${results.length} items:\n`);
|
||||||
console.log('----------------------------------------');
|
console.log('----------------------------------------');
|
||||||
results.forEach((item, index) => {
|
results.forEach((item, index) => {
|
||||||
console.log(`${index + 1}. [${item.publishDate.toLocaleDateString()}] ${item.title}`);
|
console.log(
|
||||||
|
`${index + 1}. [${item.publishDate.toLocaleDateString()}] ${item.title}`,
|
||||||
|
);
|
||||||
console.log(` Link: ${item.url}`);
|
console.log(` Link: ${item.url}`);
|
||||||
console.log('----------------------------------------');
|
console.log('----------------------------------------');
|
||||||
});
|
});
|
||||||
@@ -39,13 +63,15 @@ describe('CgnpcCrawler Real Site Test', () => {
|
|||||||
expect(Array.isArray(results)).toBeTruthy();
|
expect(Array.isArray(results)).toBeTruthy();
|
||||||
// Warn but don't fail if site returns 0 items (could be empty or changed structure)
|
// Warn but don't fail if site returns 0 items (could be empty or changed structure)
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
console.warn('Warning: No items found. Check if website structure has changed or if list is currently empty.');
|
console.warn(
|
||||||
|
'Warning: No items found. Check if website structure has changed or if list is currently empty.',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Check data integrity of first item
|
// Check data integrity of first item
|
||||||
const firstItem = results[0];
|
const firstItem = results[0];
|
||||||
expect(firstItem.title).toBeTruthy();
|
expect(firstItem.title).toBeTruthy();
|
||||||
expect(firstItem.url).toMatch(/^https?:\/\//);
|
expect(firstItem.url).toMatch(/^https?:\/\//);
|
||||||
expect(firstItem.publishDate).toBeInstanceOf(Date);
|
expect(firstItem.publishDate).toBeInstanceOf(Date);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ async function simulateHumanMouseMovement(page: puppeteer.Page) {
|
|||||||
const y = Math.floor(Math.random() * viewport.height);
|
const y = Math.floor(Math.random() * viewport.height);
|
||||||
|
|
||||||
await page.mouse.move(x, y, {
|
await page.mouse.move(x, y, {
|
||||||
steps: 10 + Math.floor(Math.random() * 20) // 10-30步,使移动更平滑
|
steps: 10 + Math.floor(Math.random() * 20), // 10-30步,使移动更平滑
|
||||||
});
|
});
|
||||||
|
|
||||||
// 随机停顿 100-500ms
|
// 随机停顿 100-500ms
|
||||||
await new Promise(r => setTimeout(r, 100 + Math.random() * 400));
|
await new Promise((r) => setTimeout(r, 100 + Math.random() * 400));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,19 +31,69 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
|||||||
await page.evaluate((distance) => {
|
await page.evaluate((distance) => {
|
||||||
window.scrollBy({
|
window.scrollBy({
|
||||||
top: distance,
|
top: distance,
|
||||||
behavior: 'smooth'
|
behavior: 'smooth',
|
||||||
});
|
});
|
||||||
}, scrollDistance);
|
}, scrollDistance);
|
||||||
|
|
||||||
// 随机停顿 500-1500ms
|
// 随机停顿 500-1500ms
|
||||||
await new Promise(r => setTimeout(r, 500 + Math.random() * 1000));
|
await new Promise((r) => setTimeout(r, 500 + Math.random() * 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 滚动回顶部
|
// 滚动回顶部
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
});
|
});
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
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 {
|
export interface CgnpcResult {
|
||||||
@@ -52,12 +102,22 @@ export interface CgnpcResult {
|
|||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CgnpcCrawlerType {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
baseUrl: string;
|
||||||
|
extract(html: string): CgnpcResult[];
|
||||||
|
}
|
||||||
|
|
||||||
export const CgnpcCrawler = {
|
export const CgnpcCrawler = {
|
||||||
name: '中广核电子商务平台',
|
name: '中广核电子商务平台',
|
||||||
url: 'https://ecp.cgnpc.com.cn/zbgg.html',
|
url: 'https://ecp.cgnpc.com.cn/zbgg.html',
|
||||||
baseUrl: 'https://ecp.cgnpc.com.cn',
|
baseUrl: 'https://ecp.cgnpc.com.cn/',
|
||||||
|
|
||||||
async crawl(browser: puppeteer.Browser): Promise<CgnpcResult[]> {
|
async crawl(
|
||||||
|
this: CgnpcCrawlerType,
|
||||||
|
browser: puppeteer.Browser,
|
||||||
|
): Promise<CgnpcResult[]> {
|
||||||
const logger = new Logger('CgnpcCrawler');
|
const logger = new Logger('CgnpcCrawler');
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
|
|
||||||
@@ -69,11 +129,15 @@ export const CgnpcCrawler = {
|
|||||||
|
|
||||||
await page.evaluateOnNewDocument(() => {
|
await page.evaluateOnNewDocument(() => {
|
||||||
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
||||||
Object.defineProperty(navigator, 'language', { get: () => "zh-CN"});
|
Object.defineProperty(navigator, 'language', { get: () => 'zh-CN' });
|
||||||
Object.defineProperty(navigator, 'plugins', { get: () => [1,2,3,4,5]});
|
Object.defineProperty(navigator, 'plugins', {
|
||||||
|
get: () => [1, 2, 3, 4, 5],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36');
|
await page.setUserAgent(
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36',
|
||||||
|
);
|
||||||
await page.setViewport({ width: 1920, height: 1080 });
|
await page.setViewport({ width: 1920, height: 1080 });
|
||||||
|
|
||||||
const allResults: CgnpcResult[] = [];
|
const allResults: CgnpcResult[] = [];
|
||||||
@@ -82,7 +146,17 @@ export const CgnpcCrawler = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.log(`Navigating to ${this.url}...`);
|
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...');
|
logger.log('Simulating human mouse movements...');
|
||||||
@@ -103,7 +177,9 @@ export const CgnpcCrawler = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
allResults.push(...pageResults);
|
allResults.push(...pageResults);
|
||||||
logger.log(`Extracted ${pageResults.length} items from page ${currentPage}`);
|
logger.log(
|
||||||
|
`Extracted ${pageResults.length} items from page ${currentPage}`,
|
||||||
|
);
|
||||||
|
|
||||||
// 模拟人类行为 - 翻页前
|
// 模拟人类行为 - 翻页前
|
||||||
logger.log('Simulating human mouse movements before pagination...');
|
logger.log('Simulating human mouse movements before pagination...');
|
||||||
@@ -127,9 +203,13 @@ export const CgnpcCrawler = {
|
|||||||
try {
|
try {
|
||||||
// 点击下一页按钮
|
// 点击下一页按钮
|
||||||
await nextButton.click();
|
await nextButton.click();
|
||||||
await new Promise(r => setTimeout(r, 3000)); // 等待页面加载
|
await new Promise((r) => setTimeout(r, 3000)); // 等待页面加载
|
||||||
} catch (navError) {
|
} catch (navError) {
|
||||||
logger.error(`Navigation to page ${currentPage + 1} failed: ${navError.message}`);
|
const navErrorMessage =
|
||||||
|
navError instanceof Error ? navError.message : String(navError);
|
||||||
|
logger.error(
|
||||||
|
`Navigation to page ${currentPage + 1} failed: ${navErrorMessage}`,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,20 +224,21 @@ export const CgnpcCrawler = {
|
|||||||
|
|
||||||
// Random delay between pages
|
// Random delay between pages
|
||||||
const delay = Math.floor(Math.random() * (3000 - 1000 + 1)) + 1000;
|
const delay = Math.floor(Math.random() * (3000 - 1000 + 1)) + 1000;
|
||||||
await new Promise(resolve => setTimeout(resolve, delay));
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
}
|
}
|
||||||
|
|
||||||
return allResults;
|
return allResults;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to crawl ${this.name}: ${error.message}`);
|
const errorMessage =
|
||||||
return allResults;
|
error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error(`Failed to crawl ${this.name}: ${errorMessage}`);
|
||||||
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await page.close();
|
await page.close();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
extract(html: string): CgnpcResult[] {
|
extract(this: CgnpcCrawlerType, html: string): CgnpcResult[] {
|
||||||
const results: CgnpcResult[] = [];
|
const results: CgnpcResult[] = [];
|
||||||
/**
|
/**
|
||||||
* Regex groups for ecp.cgnpc.com.cn:
|
* Regex groups for ecp.cgnpc.com.cn:
|
||||||
@@ -181,23 +262,25 @@ export const CgnpcCrawler = {
|
|||||||
* </div>
|
* </div>
|
||||||
* </div>
|
* </div>
|
||||||
*/
|
*/
|
||||||
const regex = /<div class="zbnr">[\s\S]*?<a[^>]*title="([^"]*)"[^>]*href="([^"]*)"[^>]*>[\s\S]*?<dt>[\s\S]*?<p>文件获取截止时间<\/p>[\s\S]*?<h2>\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})\s*<\/h2>[\s\S]*?<\/div>/gs;
|
const regex =
|
||||||
|
/<div class="zbnr">[\s\S]*?<a[^>]*title="([^"]*)"[^>]*href="([^"]*)"[^>]*>[\s\S]*?<dt>[\s\S]*?<p>文件获取截止时间<\/p>[\s\S]*?<h2>\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})\s*<\/h2>[\s\S]*?<\/div>/gs;
|
||||||
|
|
||||||
let match;
|
let match: RegExpExecArray | null;
|
||||||
while ((match = regex.exec(html)) !== null) {
|
while ((match = regex.exec(html)) !== null) {
|
||||||
const title = match[1]?.trim();
|
const title = match[1]?.trim() ?? '';
|
||||||
const url = match[2]?.trim();
|
const url = match[2]?.trim() ?? '';
|
||||||
const dateStr = match[3]?.trim();
|
const dateStr = match[3]?.trim() ?? '';
|
||||||
|
|
||||||
if (title && url) {
|
if (title && url) {
|
||||||
|
const fullUrl = url.startsWith('http') ? url : this.baseUrl + url;
|
||||||
results.push({
|
results.push({
|
||||||
title,
|
title,
|
||||||
publishDate: dateStr ? new Date(dateStr) : new Date(),
|
publishDate: dateStr ? new Date(dateStr) : new Date(),
|
||||||
url: url.startsWith('http') ? url : this.baseUrl + url
|
url: fullUrl.replace(/\/\//g, '/'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,13 +4,35 @@ import * as puppeteer from 'puppeteer';
|
|||||||
// Increase timeout to 60 seconds for network operations
|
// Increase timeout to 60 seconds for network operations
|
||||||
jest.setTimeout(60000);
|
jest.setTimeout(60000);
|
||||||
|
|
||||||
|
// 获取代理配置
|
||||||
|
const getProxyArgs = (): string[] => {
|
||||||
|
const proxyHost = process.env.PROXY_HOST;
|
||||||
|
const proxyPort = process.env.PROXY_PORT;
|
||||||
|
const proxyUsername = process.env.PROXY_USERNAME;
|
||||||
|
const proxyPassword = process.env.PROXY_PASSWORD;
|
||||||
|
|
||||||
|
if (proxyHost && proxyPort) {
|
||||||
|
const args = [`--proxy-server=${proxyHost}:${proxyPort}`];
|
||||||
|
if (proxyUsername && proxyPassword) {
|
||||||
|
args.push(`--proxy-auth=${proxyUsername}:${proxyPassword}`);
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
describe('ChdtpCrawler Real Site Test', () => {
|
describe('ChdtpCrawler Real Site Test', () => {
|
||||||
let browser: puppeteer.Browser;
|
let browser: puppeteer.Browser;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
const proxyArgs = getProxyArgs();
|
||||||
|
if (proxyArgs.length > 0) {
|
||||||
|
console.log('Using proxy:', proxyArgs.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
browser = await puppeteer.launch({
|
browser = await puppeteer.launch({
|
||||||
headless: true, // Change to false to see the browser UI
|
headless: true, // Change to false to see the browser UI
|
||||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
args: ['--no-sandbox', '--disable-setuid-sandbox', ...proxyArgs],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,7 +51,9 @@ describe('ChdtpCrawler Real Site Test', () => {
|
|||||||
console.log(`\nSuccessfully found ${results.length} items:\n`);
|
console.log(`\nSuccessfully found ${results.length} items:\n`);
|
||||||
console.log('----------------------------------------');
|
console.log('----------------------------------------');
|
||||||
results.forEach((item, index) => {
|
results.forEach((item, index) => {
|
||||||
console.log(`${index + 1}. [${item.publishDate.toLocaleDateString()}] ${item.title}`);
|
console.log(
|
||||||
|
`${index + 1}. [${item.publishDate.toLocaleDateString()}] ${item.title}`,
|
||||||
|
);
|
||||||
console.log(` Link: ${item.url}`);
|
console.log(` Link: ${item.url}`);
|
||||||
console.log('----------------------------------------');
|
console.log('----------------------------------------');
|
||||||
});
|
});
|
||||||
@@ -39,13 +63,15 @@ describe('ChdtpCrawler Real Site Test', () => {
|
|||||||
expect(Array.isArray(results)).toBeTruthy();
|
expect(Array.isArray(results)).toBeTruthy();
|
||||||
// Warn but don't fail if site returns 0 items (could be empty or changed structure)
|
// Warn but don't fail if site returns 0 items (could be empty or changed structure)
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
console.warn('Warning: No items found. Check if the website structure has changed or if the list is currently empty.');
|
console.warn(
|
||||||
|
'Warning: No items found. Check if the website structure has changed or if the list is currently empty.',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Check data integrity of the first item
|
// Check data integrity of the first item
|
||||||
const firstItem = results[0];
|
const firstItem = results[0];
|
||||||
expect(firstItem.title).toBeTruthy();
|
expect(firstItem.title).toBeTruthy();
|
||||||
expect(firstItem.url).toMatch(/^https?:\/\//);
|
expect(firstItem.url).toMatch(/^https?:\/\//);
|
||||||
expect(firstItem.publishDate).toBeInstanceOf(Date);
|
expect(firstItem.publishDate).toBeInstanceOf(Date);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,18 +1,118 @@
|
|||||||
import * as puppeteer from 'puppeteer';
|
import * as puppeteer from 'puppeteer';
|
||||||
import { Logger } from '@nestjs/common';
|
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 {
|
export interface ChdtpResult {
|
||||||
title: string;
|
title: string;
|
||||||
publishDate: Date;
|
publishDate: Date;
|
||||||
url: string; // Necessary for system uniqueness
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChdtpCrawlerType {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
baseUrl: string;
|
||||||
|
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 = {
|
export const ChdtpCrawler = {
|
||||||
name: '中国华能集团',
|
name: '华电集团电子商务平台 ',
|
||||||
url: 'https://www.chdtp.com/webs/queryWebZbgg.action?zbggType=1',
|
url: 'https://www.chdtp.com/webs/queryWebZbgg.action?zbggType=1',
|
||||||
baseUrl: 'https://www.chdtp.com/webs/',
|
baseUrl: 'https://www.chdtp.com/webs/',
|
||||||
|
|
||||||
async crawl(browser: puppeteer.Browser): Promise<ChdtpResult[]> {
|
async crawl(
|
||||||
|
this: ChdtpCrawlerType,
|
||||||
|
browser: puppeteer.Browser,
|
||||||
|
): Promise<ChdtpResult[]> {
|
||||||
const logger = new Logger('ChdtpCrawler');
|
const logger = new Logger('ChdtpCrawler');
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
|
|
||||||
@@ -22,7 +122,9 @@ export const ChdtpCrawler = {
|
|||||||
await page.authenticate({ username, password });
|
await page.authenticate({ username, password });
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36');
|
await page.setUserAgent(
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36',
|
||||||
|
);
|
||||||
|
|
||||||
const allResults: ChdtpResult[] = [];
|
const allResults: ChdtpResult[] = [];
|
||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
@@ -30,7 +132,23 @@ export const ChdtpCrawler = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.log(`Navigating to ${this.url}...`);
|
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) {
|
while (currentPage <= maxPages) {
|
||||||
const content = await page.content();
|
const content = await page.content();
|
||||||
@@ -42,7 +160,15 @@ export const ChdtpCrawler = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
allResults.push(...pageResults);
|
allResults.push(...pageResults);
|
||||||
logger.log(`Extracted ${pageResults.length} items from page ${currentPage}`);
|
logger.log(
|
||||||
|
`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
|
// Find the "Next Page" button
|
||||||
// Using partial match for src to be robust against path variations
|
// Using partial match for src to be robust against path variations
|
||||||
@@ -54,39 +180,50 @@ export const ChdtpCrawler = {
|
|||||||
break;
|
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}...`);
|
logger.log(`Navigating to page ${currentPage + 1}...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 60000 }),
|
page.waitForNavigation({
|
||||||
|
waitUntil: 'networkidle2',
|
||||||
|
timeout: 60000,
|
||||||
|
}),
|
||||||
nextButton.click(),
|
nextButton.click(),
|
||||||
]);
|
]);
|
||||||
} catch (navError) {
|
} catch (navError) {
|
||||||
logger.error(`Navigation to page ${currentPage + 1} failed: ${navError.message}`);
|
const navErrorMessage =
|
||||||
|
navError instanceof Error ? navError.message : String(navError);
|
||||||
|
logger.error(
|
||||||
|
`Navigation to page ${currentPage + 1} failed: ${navErrorMessage}`,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentPage++;
|
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
|
// Random delay between pages
|
||||||
const delay = Math.floor(Math.random() * (3000 - 1000 + 1)) + 1000;
|
const delay = Math.floor(Math.random() * (3000 - 1000 + 1)) + 1000;
|
||||||
await new Promise(resolve => setTimeout(resolve, delay));
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
}
|
}
|
||||||
|
|
||||||
return allResults;
|
return allResults;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to crawl ${this.name}: ${error.message}`);
|
const errorMessage =
|
||||||
return allResults; // Return what we have so far
|
error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error(`Failed to crawl ${this.name}: ${errorMessage}`);
|
||||||
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await page.close();
|
await page.close();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
extract(html: string): ChdtpResult[] {
|
extract(this: ChdtpCrawlerType, html: string): ChdtpResult[] {
|
||||||
const results: ChdtpResult[] = [];
|
const results: ChdtpResult[] = [];
|
||||||
/**
|
/**
|
||||||
* Regex groups for chdtp.com:
|
* Regex groups for chdtp.com:
|
||||||
@@ -96,22 +233,24 @@ export const ChdtpCrawler = {
|
|||||||
* 4: Business Type
|
* 4: Business Type
|
||||||
* 5: Date
|
* 5: Date
|
||||||
*/
|
*/
|
||||||
const regex = /<tr[^>]*>\s*<td class="td_1">.*?<span[^>]*>\s*(.*?)\s*<\/span>.*?<\/td>\s*<td class="td_2">\s*<a[^>]*href="javascript:toGetContent\('(.*?)'\)" title="(.*?)">.*?<\/a><\/td>\s*<td class="td_3">\s*<a[^>]*>\s*(.*?)\s*<\/a>\s*<\/td>\s*<td class="td_4"><span>\[(.*?)\]<\/span><\/td>/gs;
|
const regex =
|
||||||
|
/<tr[^>]*>\s*<td class="td_1">.*?<span[^>]*>\s*(.*?)\s*<\/span>.*?<\/td>\s*<td class="td_2">\s*<a[^>]*href="javascript:toGetContent\('(.*?)'\)" title="(.*?)">.*?<\/a><\/td>\s*<td class="td_3">\s*<a[^>]*>\s*(.*?)\s*<\/a>\s*<\/td>\s*<td class="td_4"><span>\[(.*?)\]<\/span><\/td>/gs;
|
||||||
|
|
||||||
let match;
|
let match: RegExpExecArray | null;
|
||||||
while ((match = regex.exec(html)) !== null) {
|
while ((match = regex.exec(html)) !== null) {
|
||||||
const urlSuffix = match[2]?.trim();
|
const urlSuffix = match[2]?.trim() ?? '';
|
||||||
const title = match[3]?.trim();
|
const title = match[3]?.trim() ?? '';
|
||||||
const dateStr = match[5]?.trim();
|
const dateStr = match[5]?.trim() ?? '';
|
||||||
|
|
||||||
if (title && urlSuffix) {
|
if (title && urlSuffix) {
|
||||||
|
const fullUrl = this.baseUrl + urlSuffix;
|
||||||
results.push({
|
results.push({
|
||||||
title,
|
title,
|
||||||
publishDate: dateStr ? new Date(dateStr) : new Date(),
|
publishDate: dateStr ? new Date(dateStr) : new Date(),
|
||||||
url: this.baseUrl + urlSuffix
|
url: fullUrl.replace(/\/\//g, '/'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user