Compare commits

...

8 Commits

Author SHA1 Message Date
dmy
3f6d10061d feat: 添加单源爬取功能并优化数据库同步
新增单源爬取功能,支持在界面上单独更新每个数据源
添加数据库同步脚本,支持主从数据库结构同步和数据同步
优化华能集团爬虫的页面导航和稳定性
新增系统托盘功能,支持最小化到托盘
2026-01-14 16:25:01 +08:00
dmy
bcd7af4e69 feat: 添加爬虫状态监控功能
新增爬虫统计信息展示组件,包括后端数据查询接口和前端展示界面。同时简化日期显示格式并添加刷新提示功能。
2026-01-14 09:26:04 +08:00
dmy
571eea0f66 build: 更新.gitignore并添加TailwindCSS和OpenAI依赖 2026-01-14 01:10:48 +08:00
dmy
6a9c52fe10 feat: 添加AI推荐功能及自动刷新机制
新增AI推荐模块,包括后端数据获取接口和前端展示组件
实现自动刷新功能,每5分钟自动更新当前标签页数据
添加手动刷新按钮,优化用户交互体验
2026-01-14 01:06:15 +08:00
dmy
8e4429558c feat: 添加投标项目查看器组件及后端支持 2026-01-14 00:56:30 +08:00
dmy
2fcfb452ec docs: 更新README以反映投标监控系统项目详情 2026-01-13 21:32:59 +08:00
dmy
e410053ddd refactor: 从Dashboard-AI组件中移除未使用的ArrowDown图标 2026-01-13 21:26:52 +08:00
dmy
f32c04b8df refactor: 移除未使用的 ArrowDown 图标导入 2026-01-13 21:26:25 +08:00
40 changed files with 3844 additions and 197 deletions

8
.env
View File

@@ -6,6 +6,14 @@ DATABASE_PASSWORD=410491
DATABASE_NAME=bidding
DATABASE_SYNCHRONIZE=true
# 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_PORT=3211

View File

@@ -6,6 +6,14 @@ DATABASE_PASSWORD=root
DATABASE_NAME=bidding
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_PORT=6000

3
.gitignore vendored
View File

@@ -5,3 +5,6 @@ public
*.xls*
pw-browsers
logs
build
*.exe
*.png

402
README.md
View File

@@ -1,106 +1,88 @@
<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
[circleci-url]: https://circleci.com/gh/nestjs/nest
一个基于 TypeScript 的 Web 应用,用于自动爬取商务投标平台的最新信息,将符合条件的投标项目突出显示,为用户提供精准的投标信息监控服务。
<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>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](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**: Tailwind CSS
- **状态管理**: Pinia
## Project setup
## 项目结构
```bash
$ npm install
```
src/
├── ai/ # AI 模块
│ ├── ai.controller.ts
│ ├── ai.service.ts
│ ├── Prompt.ts
│ └── entities/
├── bids/ # 投标业务模块
│ ├── controllers/
│ ├── entities/
│ └── services/
├── crawler/ # 爬虫模块
│ ├── 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
│ └── entities/
├── database/ # 数据库模块
├── keywords/ # 关键词管理模块
├── schedule/ # 定时任务
│ └── tasks/
│ └── bid-crawl.task.ts
├── scripts/ # 脚本工具
│ ├── ai-recommendations.ts
│ ├── crawl.ts
│ ├── deploy.ps1
│ ├── remove-duplicates.ts
│ └── update-source.ts
└── common/ # 公共模块
└── logger/
frontend/
└── src/
├── components/
│ ├── Dashboard.vue
│ ├── Dashboard-AI.vue
│ ├── PinnedProject.vue
│ ├── Bids.vue
│ ├── Keywords.vue
│ └── CrawlInfo.vue
├── App.vue
└── main.ts
```
## Compile and run the project
## 快速开始
```bash
# development
$ npm run start
### 1. 环境准备
# watch mode
$ npm run start:dev
确保已安装 Node.js (18+) 和 PostgreSQL。
# production mode
$ npm run start:prod
```
### 2. 数据库配置
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
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
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- 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).
- 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
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## 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:
复制 `.env.example``.env` 并配置数据库连接:
```env
DATABASE_TYPE=postgres
@@ -110,73 +92,187 @@ DATABASE_USERNAME=your_username
DATABASE_PASSWORD=your_password
DATABASE_NAME=bidding
DATABASE_SYNCHRONIZE=true
```
### 2. Install Dependencies
```bash
npm install
cd frontend && npm install
```
### 3. Build and Start
```bash
# From the root directory
cd frontend && npm run build
cd ..
npm run build
npm run start
```
## Features
### Frontend Features
- **Dashboard**: View high priority bids and today's bids
- **Date Filtering**:
- Click "3天" or "7天" buttons to filter bids from the last 3 or 7 days
- The filter only limits the start date, showing all data from the selected start date onwards (including data newer than the end date)
- **Keyword Filtering**: Filter bids by keywords (saved in localStorage)
- **All Bids**: View all bids with pagination and source filtering
- **Keyword Management**: Add and delete keywords with weight-based priority
### Backend Features
- **Multi-Source Crawling**: Crawls bidding information from multiple sources:
- ChdtpCrawler
- ChngCrawler
- SzecpCrawler
- CdtCrawler
- EpsCrawler
- CnncecpCrawler
- CgnpcCrawler
- CeicCrawler
- EspicCrawler
- PowerbeijingCrawler
- **Automatic Retry**: If a crawler returns 0 items, it will be retried after all crawlers complete
- **Proxy Support**: Configurable proxy settings via environment variables
- **Scheduled Tasks**: Automatic crawling at scheduled intervals
### Environment Variables
```env
# Database
DATABASE_TYPE=postgres
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USERNAME=your_username
DATABASE_PASSWORD=your_password
DATABASE_NAME=bidding
DATABASE_SYNCHRONIZE=true
# Proxy (optional)
# 代理配置(可选)
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
```
## Initial Setup
### 3. 安装依赖
The system will automatically initialize with the preset keywords: "山东", "海", "建设", "工程", "采购". You can manage these and view crawled bidding information at http://localhost:3000.
```bash
# 安装后端依赖
npm install
# 安装前端依赖
cd frontend && npm install
```
### 4. 运行项目
```bash
# 开发模式 - 后端
npm run start:dev
# 开发模式 - 前端
cd frontend && npm run dev
```
### 5. 构建生产版本
```bash
# 构建前端
cd frontend && npm run build
# 构建后端
cd .. && npm run build
# 运行生产版本
npm run start:prod
```
## 核心功能
### 智能爬虫模块
- **多源爬取**: 支持 12 个主流招标网站
- 中国大唐集团电子商务平台 (CDT)
- 中国华能集团有限公司电子商务平台 (CHNG)
- 中国南方电网电子商务平台 (CSG)
- 中国海洋石油集团有限公司 (CNOOC)
- 中国华电集团有限公司电子商务平台 (CHDTP)
- 国家能源投资集团有限责任公司 (CNNCECP)
- 中国核工业集团有限公司 (CNNC)
- 中国电力建设集团有限公司 (POWERCHINA)
- 中国能源建设集团有限公司 (CEIC)
- 中国石油天然气集团有限公司 (CNPC)
- 国家电网有限公司 (SGCC)
- 北京电力交易中心 (POWERBEIJING)
- **智能防封策略**:
- 随机请求间隔 (3-8 秒)
- 轮换 User-Agent
- 异常检测与自动重试机制
- 代理支持
- **定时任务**: 每 30 分钟自动执行爬取
### 数据处理与存储
- **数据模型**:
- 投标项目标题
- 详细页面 URL
- 发布时间
- 招标单位
- 截止日期
- 关键词匹配
- 优先级评分
- **增量存储**:
- 通过 URL 哈希值判断是否为新数据
- 仅存储当天和最近 7 天的历史数据
- 每日自动清理 30 天前的数据
### 关键词智能监控
- **预设关键词**: "山东", "海", "建设", "工程", "采购"
- **自定义关键词**: 通过 Web 界面添加/删除关键词
- **权重设置**: 可设置关键词权重 (1-5 级)
- **匹配逻辑**:
- 标题完全匹配和部分匹配
- 多关键词叠加权重
- 支持正则表达式高级匹配
### AI 智能推荐
- **智能分析**: 使用 AI 分析投标信息的相关性
- **推荐评分**: 基于关键词匹配和内容分析生成推荐评分
- **智能摘要**: 自动生成投标信息摘要
### Web 展示界面
- **仪表盘**:
- 高优先级投标信息(匹配自定义关键词)
- 今日新增投标列表(按时间倒序)
- AI 推荐投标信息
- 置顶项目
- **交互功能**:
- 关键词管理面板
- 按日期/来源/关键词筛选
- 信息标记已读/未读状态
- 项目置顶功能
- 爬取信息查看
- **响应式设计**: 适配桌面和移动设备
## API 接口
### 投标信息
- `GET /api/bids` - 获取投标列表(支持分页、筛选)
- `GET /api/bids/high-priority` - 获取高优先级投标
- `GET /api/bids/today` - 获取今日投标
### 关键词管理
- `GET /api/keywords` - 获取所有关键词
- `POST /api/keywords` - 添加新关键词
- `DELETE /api/keywords/:id` - 删除关键词
### AI 服务
- `GET /api/ai/recommendations` - 获取 AI 推荐投标
- `POST /api/ai/analyze` - 分析投标信息
### 爬虫管理
- `GET /api/crawler/info` - 获取爬取信息
- `POST /api/crawler/trigger` - 手动触发爬取
## 前端路由
- `/` - 仪表盘(默认页面)
- `/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

View File

@@ -11,17 +11,35 @@
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.2",
"element-plus": "^2.13.1",
"openai": "^6.16.0",
"vue": "^3.5.24"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.23",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"vite": "^7.2.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": {
"version": "7.27.1",
"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==",
"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": {
"version": "1.5.5",
"resolved": "https://mirrors.cloud.tencent.com/npm/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"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": {
"name": "@sxzz/popperjs-es",
"version": "2.11.7",
@@ -927,6 +988,277 @@
"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": {
"version": "1.0.8",
"resolved": "https://mirrors.cloud.tencent.com/npm/@types/estree/-/estree-1.0.8.tgz",
@@ -1255,6 +1587,43 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"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": {
"version": "1.13.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/axios/-/axios-1.13.2.tgz",
@@ -1266,6 +1635,51 @@
"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": {
"version": "1.0.2",
"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_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": {
"version": "1.0.8",
"resolved": "https://mirrors.cloud.tencent.com/npm/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -1312,6 +1747,16 @@
"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": {
"version": "1.0.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1326,6 +1771,13 @@
"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": {
"version": "2.13.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/element-plus/-/element-plus-2.13.1.tgz",
@@ -1351,6 +1803,20 @@
"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": {
"version": "7.0.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/entities/-/entities-7.0.0.tgz",
@@ -1450,6 +1916,16 @@
"@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": {
"version": "2.0.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/estree-walker/-/estree-walker-2.0.2.tgz",
@@ -1510,6 +1986,20 @@
"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": {
"version": "2.3.3",
"resolved": "https://mirrors.cloud.tencent.com/npm/fsevents/-/fsevents-2.3.3.tgz",
@@ -1583,6 +2073,13 @@
"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": {
"version": "1.1.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -1622,6 +2119,279 @@
"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": {
"version": "4.17.21",
"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_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": {
"version": "1.2.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
"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": {
"version": "1.0.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -1769,6 +2567,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -1778,6 +2577,13 @@
"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": {
"version": "1.1.0",
"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_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": {
"version": "0.2.15",
"resolved": "https://mirrors.cloud.tencent.com/npm/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -1877,6 +2704,37 @@
"dev": true,
"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": {
"version": "7.3.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/vite/-/vite-7.3.1.tgz",

View File

@@ -37,6 +37,20 @@
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<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">
@@ -75,6 +89,7 @@ interface CrawlStat {
const crawlStats = ref<CrawlStat[]>([])
const loading = ref(false)
const crawlingSources = ref<Set<string>>(new Set())
const totalCount = computed(() => {
return crawlStats.value.reduce((sum, item) => sum + item.count, 0)
@@ -113,6 +128,28 @@ const fetchCrawlStats = async () => {
}
}
const crawlSingleSource = async (sourceName: string) => {
crawlingSources.value.add(sourceName)
try {
ElMessage.info(`正在更新 ${sourceName}...`)
const res = await axios.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)
}
}
onMounted(() => {
fetchCrawlStats()
})

View File

@@ -129,7 +129,7 @@
import { ref, watch } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { MagicStick, Loading, InfoFilled, List, ArrowDown, Paperclip } from '@element-plus/icons-vue'
import { MagicStick, Loading, InfoFilled, List, Paperclip } from '@element-plus/icons-vue'
import PinnedProject from './PinnedProject.vue'

View File

@@ -79,7 +79,7 @@
import { ref, computed, watch } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { Refresh, ArrowDown, Paperclip } from '@element-plus/icons-vue'
import { Refresh, Paperclip } from '@element-plus/icons-vue'
import PinnedProject from './PinnedProject.vue'
interface Props {

View File

@@ -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';
@Controller('api/crawler')
export class CrawlerController {
private isCrawling = false;
private crawlingSources = new Set<string>();
constructor(private readonly crawlerService: BidCrawlerService) {}
@Get('status')
getStatus() {
return { isCrawling: this.isCrawling };
return {
isCrawling: this.isCrawling,
crawlingSources: Array.from(this.crawlingSources)
};
}
@Post('run')
@@ -35,4 +39,20 @@ export class CrawlerController {
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 {
const result = await this.crawlerService.crawlSingleSource(sourceName);
return result;
} finally {
this.crawlingSources.delete(sourceName);
}
}
}

View File

@@ -216,6 +216,97 @@ export class BidCrawlerService {
}
}
async crawlSingleSource(sourceName: string) {
this.logger.log(`Starting single source crawl for: ${sourceName}`);
// 从环境变量读取代理配置
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];
const targetCrawler = crawlers.find(c => c.name === sourceName);
if (!targetCrawler) {
await browser.close();
throw new Error(`Crawler not found for source: ${sourceName}`);
}
try {
this.logger.log(`Crawling: ${targetCrawler.name}`);
const results = await targetCrawler.crawl(browser);
this.logger.log(`Extracted ${results.length} items from ${targetCrawler.name}`);
// 获取最新的发布日期
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: targetCrawler.name,
});
}
// 保存爬虫统计信息到数据库
await this.saveCrawlInfo(targetCrawler.name, results.length, latestPublishDate);
return {
success: true,
source: targetCrawler.name,
count: results.length,
latestPublishDate,
};
} catch (err) {
this.logger.error(`Error crawling ${targetCrawler.name}: ${err.message}`);
// 保存错误信息到数据库
await this.saveCrawlInfo(targetCrawler.name, 0, null, err.message);
return {
success: false,
source: targetCrawler.name,
count: 0,
error: err.message,
};
} finally {
await browser.close();
}
}
private async saveCrawlInfo(
source: string,
count: number,

View File

@@ -4,53 +4,75 @@ import { ChdtpResult } from './chdtp_target';
// 模拟人类鼠标移动
async function simulateHumanMouseMovement(page: puppeteer.Page) {
const viewport = page.viewport();
if (!viewport) return;
try {
const viewport = page.viewport();
if (!viewport) return;
const movements = 5 + Math.floor(Math.random() * 5); // 5-10次随机移动
const movements = 5 + Math.floor(Math.random() * 5); // 5-10次随机移动
for (let i = 0; i < movements; i++) {
const x = Math.floor(Math.random() * viewport.width);
const y = Math.floor(Math.random() * viewport.height);
for (let i = 0; i < movements; i++) {
// 检查页面是否仍然有效
if (page.isClosed()) {
console.log('Page was closed during mouse movement simulation');
return;
}
await page.mouse.move(x, y, {
steps: 10 + Math.floor(Math.random() * 20) // 10-30步使移动更平滑
});
const x = Math.floor(Math.random() * viewport.width);
const y = Math.floor(Math.random() * viewport.height);
// 随机停顿 100-500ms
await new Promise(r => setTimeout(r, 100 + Math.random() * 400));
await page.mouse.move(x, y, {
steps: 10 + Math.floor(Math.random() * 20) // 10-30步使移动更平滑
});
// 随机停顿 100-500ms
await new Promise(r => setTimeout(r, 100 + Math.random() * 400));
}
} catch (error) {
console.log('Mouse movement simulation interrupted:', error.message);
}
}
// 模拟人类滚动
async function simulateHumanScrolling(page: puppeteer.Page) {
const scrollCount = 3 + Math.floor(Math.random() * 5); // 3-7次滚动
try {
const scrollCount = 3 + Math.floor(Math.random() * 5); // 3-7次滚动
for (let i = 0; i < scrollCount; i++) {
const scrollDistance = 100 + Math.floor(Math.random() * 400); // 100-500px
for (let i = 0; i < scrollCount; i++) {
// 检查页面是否仍然有效
if (page.isClosed()) {
console.log('Page was closed during scrolling simulation');
return;
}
await page.evaluate((distance) => {
window.scrollBy({
top: distance,
behavior: 'smooth'
const scrollDistance = 100 + Math.floor(Math.random() * 400); // 100-500px
await page.evaluate((distance) => {
window.scrollBy({
top: distance,
behavior: 'smooth'
});
}, scrollDistance);
// 随机停顿 500-1500ms
await new Promise(r => setTimeout(r, 500 + Math.random() * 1000));
}
// 滚动回顶部
if (!page.isClosed()) {
await page.evaluate(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}, scrollDistance);
// 随机停顿 500-1500ms
await new Promise(r => setTimeout(r, 500 + Math.random() * 1000));
await new Promise(r => setTimeout(r, 1000));
}
} catch (error) {
console.log('Scrolling simulation interrupted:', error.message);
}
// 滚动回顶部
await page.evaluate(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
await new Promise(r => setTimeout(r, 1000));
}
export const ChngCrawler = {
name: '华能集团电子商务平台',
url: 'https://ec.chng.com.cn/ecmall/index.html#/purchase/home?top=0',
baseUrl: 'https://ec.chng.com.cn/ecmall/index.html',
url: 'https://ec.chng.com.cn/channel/home/#/purchase?top=0',
baseUrl: 'https://ec.chng.com.cn/channel/home/#',
async crawl(browser: puppeteer.Browser): Promise<ChdtpResult[]> {
const logger = new Logger('ChngCrawler');
@@ -106,14 +128,16 @@ export const ChngCrawler = {
await page.authenticate({ username, password });
}
}
// 模拟人类行为
// 模拟人类行为
logger.log('Simulating human mouse movements...');
await simulateHumanMouseMovement(page);
logger.log('Simulating human scrolling...');
await simulateHumanScrolling(page);
await page.waitForNavigation({ waitUntil: 'domcontentloaded' }).catch(() => {});
// 等待页面稳定,不强制等待导航
await new Promise(r => setTimeout(r, 3000));
// 模拟人类行为
logger.log('Simulating human mouse movements...');
await simulateHumanMouseMovement(page);
@@ -215,8 +239,24 @@ export const ChngCrawler = {
const nextButton = await page.$('svg[data-icon="right"]');
if (!nextButton) break;
// 点击下一页前保存当前页面状态
const currentUrl = page.url();
await nextButton.click();
await new Promise(r => setTimeout(r, 5000));
// 等待页面导航完成
try {
await page.waitForFunction(
(oldUrl) => window.location.href !== oldUrl,
{ timeout: 10000 },
currentUrl
);
} catch (e) {
logger.warn('Navigation timeout, continuing anyway');
}
// 等待页面内容加载
await new Promise(r => setTimeout(r, 15000));
currentPage++;
}

252
src/scripts/sync.ts Normal file
View File

@@ -0,0 +1,252 @@
import 'dotenv/config';
import { DataSource, DataSourceOptions } from 'typeorm';
import mysql from 'mysql2/promise';
import { BidItem } from '../bids/entities/bid-item.entity';
import { Keyword } from '../keywords/keyword.entity';
import { AiRecommendation } from '../ai/entities/ai-recommendation.entity';
import { CrawlInfoAdd } from '../crawler/entities/crawl-info-add.entity';
// 主数据库配置
const masterDbConfig: DataSourceOptions = {
type: process.env.DATABASE_TYPE as any || 'mariadb',
host: process.env.DATABASE_HOST || 'localhost',
port: parseInt(process.env.DATABASE_PORT || '3306'),
username: process.env.DATABASE_USERNAME || 'root',
password: process.env.DATABASE_PASSWORD || 'root',
database: process.env.DATABASE_NAME || 'bidding',
entities: [BidItem, Keyword, AiRecommendation, CrawlInfoAdd],
synchronize: false,
};
// Slave 数据库配置
const slaveDbConfig: DataSourceOptions = {
type: process.env.SLAVE_DATABASE_TYPE as any || 'mariadb',
host: process.env.SLAVE_DATABASE_HOST || 'localhost',
port: parseInt(process.env.SLAVE_DATABASE_PORT || '3306'),
username: process.env.SLAVE_DATABASE_USERNAME || 'root',
password: process.env.SLAVE_DATABASE_PASSWORD || 'root',
database: process.env.SLAVE_DATABASE_NAME || 'bidding_slave',
entities: [BidItem, Keyword, AiRecommendation, CrawlInfoAdd],
synchronize: false,
};
// 日志工具
const logger = {
log: (message: string, ...args: any[]) => {
console.log(`[INFO] ${new Date().toISOString()} - ${message}`, ...args);
},
error: (message: string, ...args: any[]) => {
console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, ...args);
},
warn: (message: string, ...args: any[]) => {
console.warn(`[WARN] ${new Date().toISOString()} - ${message}`, ...args);
},
};
// 同步单个表的数据
async function syncTable<T>(
masterDataSource: DataSource,
slaveDataSource: DataSource,
entityClass: any,
tableName: string,
): Promise<number> {
const masterRepo = masterDataSource.getRepository(entityClass);
const slaveRepo = slaveDataSource.getRepository(entityClass);
logger.log(`开始同步表: ${tableName}`);
// 从主数据库获取所有数据
const masterData = await masterRepo.find();
logger.log(`主数据库 ${tableName} 表中有 ${masterData.length} 条记录`);
// 从 slave 数据库获取所有数据
const slaveData = await slaveRepo.find();
logger.log(`Slave 数据库 ${tableName} 表中有 ${slaveData.length} 条记录`);
// 创建主数据库记录的 ID 集合
const masterIds = new Set(masterData.map((item: any) => item.id));
// 删除 slave 数据库中不存在于主数据库的记录
const toDelete = slaveData.filter((item: any) => !masterIds.has(item.id));
if (toDelete.length > 0) {
await slaveRepo.remove(toDelete);
logger.log(`从 slave 数据库删除了 ${toDelete.length}${tableName} 记录`);
}
// 同步数据:使用 save 方法进行 upsert 操作
let syncedCount = 0;
for (const item of masterData) {
await slaveRepo.save(item);
syncedCount++;
}
logger.log(`成功同步 ${syncedCount}${tableName} 记录到 slave 数据库`);
return syncedCount;
}
// 创建数据库(如果不存在)
async function createDatabaseIfNotExists(config: DataSourceOptions) {
const connection = await mysql.createConnection({
host: (config as any).host,
port: (config as any).port,
user: (config as any).username,
password: (config as any).password,
});
await connection.query(`CREATE DATABASE IF NOT EXISTS \`${(config as any).database}\``);
await connection.end();
}
// 同步表结构
async function syncSchema(masterDataSource: DataSource, slaveDataSource: DataSource): Promise<DataSource> {
logger.log('开始同步表结构...');
// 获取主数据库的所有表
const tables = await masterDataSource.query(`
SELECT TABLE_NAME
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = '${(masterDbConfig as any).database}'
`);
for (const table of tables) {
const tableName = table.TABLE_NAME;
logger.log(`同步表结构: ${tableName}`);
// 获取主数据库的建表语句
const createTableResult = await masterDataSource.query(`
SHOW CREATE TABLE \`${tableName}\`
`);
let createTableSql = createTableResult[0]['Create Table'];
// 转换 MariaDB 语法到 MySQL 语法
// 将 uuid 类型转换为 CHAR(36)
createTableSql = createTableSql.replace(/\buuid\b/gi, 'CHAR(36)');
// 检查 slave 数据库中是否存在该表
const tableExists = await slaveDataSource.query(`
SELECT COUNT(*) as count
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = '${(slaveDbConfig as any).database}'
AND TABLE_NAME = '${tableName}'
`);
const tempTableName = `temp_${tableName}_${Date.now()}`;
if (tableExists[0].count > 0) {
// 表存在,先备份数据到临时表
logger.log(`备份表 ${tableName} 的数据到 ${tempTableName}...`);
await slaveDataSource.query(`CREATE TABLE ${tempTableName} AS SELECT * FROM \`${tableName}\``);
logger.log(`备份完成,共备份 ${await slaveDataSource.query(`SELECT COUNT(*) as count FROM ${tempTableName}`).then(r => r[0].count)} 条记录`);
}
// 删除 slave 数据库中的表(如果存在)
await slaveDataSource.query(`DROP TABLE IF EXISTS \`${tableName}\``);
// 在 slave 数据库中创建表
await slaveDataSource.query(createTableSql);
// 如果之前有备份数据,尝试恢复
if (tableExists[0].count > 0) {
try {
logger.log(`${tempTableName} 恢复数据到 ${tableName}...`);
// 获取临时表的列名
const columns = await slaveDataSource.query(`
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = '${(slaveDbConfig as any).database}'
AND TABLE_NAME = '${tempTableName}'
`);
const columnNames = columns.map((c: any) => `\`${c.COLUMN_NAME}\``).join(', ');
// 将数据从临时表插入到新表
await slaveDataSource.query(`
INSERT INTO \`${tableName}\` (${columnNames})
SELECT ${columnNames} FROM ${tempTableName}
`);
const restoredCount = await slaveDataSource.query(`SELECT COUNT(*) as count FROM \`${tableName}\``);
logger.log(`数据恢复完成,共恢复 ${restoredCount[0].count} 条记录`);
// 删除临时表
await slaveDataSource.query(`DROP TABLE IF EXISTS ${tempTableName}`);
} catch (error) {
logger.warn(`恢复数据失败: ${error.message}`);
logger.warn(`临时表 ${tempTableName} 保留,请手动处理`);
}
}
}
logger.log('表结构同步完成');
// 重新初始化 slave 数据库连接以清除 TypeORM 元数据缓存
logger.log('重新初始化 slave 数据库连接...');
await slaveDataSource.destroy();
await slaveDataSource.initialize();
logger.log('Slave 数据库连接重新初始化完成');
return slaveDataSource;
}
// 主同步函数
async function syncDatabase() {
let masterDataSource: DataSource | null = null;
let slaveDataSource: DataSource | null = null;
try {
logger.log('开始数据库同步...');
// 创建 slave 数据库(如果不存在)
logger.log('检查并创建 slave 数据库...');
await createDatabaseIfNotExists(slaveDbConfig);
logger.log('Slave 数据库准备就绪');
// 创建主数据库连接
masterDataSource = new DataSource(masterDbConfig);
await masterDataSource.initialize();
logger.log('主数据库连接成功');
// 创建 slave 数据库连接
slaveDataSource = new DataSource(slaveDbConfig);
await slaveDataSource.initialize();
logger.log('Slave 数据库连接成功');
// 同步表结构
slaveDataSource = await syncSchema(masterDataSource, slaveDataSource);
// 同步各个表
const tables = [
{ entity: BidItem, name: 'bid_items' },
{ entity: Keyword, name: 'keywords' },
{ entity: AiRecommendation, name: 'ai_recommendations' },
{ entity: CrawlInfoAdd, name: 'crawl_info_add' },
];
let totalSynced = 0;
for (const table of tables) {
const count = await syncTable(masterDataSource, slaveDataSource, table.entity, table.name);
totalSynced += count;
}
logger.log(`数据库同步完成,共同步 ${totalSynced} 条记录`);
await masterDataSource.destroy();
await slaveDataSource.destroy();
process.exit(0);
} catch (error) {
logger.error('数据库同步失败:', error);
if (masterDataSource && masterDataSource.isInitialized) {
await masterDataSource.destroy();
}
if (slaveDataSource && slaveDataSource.isInitialized) {
await slaveDataSource.destroy();
}
process.exit(1);
}
}
// 执行同步
syncDatabase();

3
widget/looker/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
build/bin
node_modules
frontend/dist

19
widget/looker/README.md Normal file
View File

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

371
widget/looker/app.go Normal file
View File

@@ -0,0 +1,371 @@
package main
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/joho/godotenv"
)
// DatabaseConfig 数据库配置
type DatabaseConfig struct {
Type string
Host string
Port string
Username string
Password string
Name string
}
// BidItem 投标项目结构体
type BidItem struct {
ID string `json:"id"`
Title string `json:"title"`
URL string `json:"url"`
PublishDate string `json:"publishDate"`
Source string `json:"source"`
Pin bool `json:"pin"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// AiRecommendation AI推荐结构体
type AiRecommendation struct {
ID string `json:"id"`
Title string `json:"title"`
Confidence int `json:"confidence"`
CreatedAt string `json:"createdAt"`
}
// CrawlInfoStat 爬虫统计信息结构体
type CrawlInfoStat struct {
Source string `json:"source"`
Count int `json:"count"`
LatestUpdate string `json:"latestUpdate"`
LatestPublishDate string `json:"latestPublishDate"`
Error string `json:"error"`
}
// App struct
type App struct {
ctx context.Context
dbConfig *DatabaseConfig
projectRootDir string
}
// NewApp creates a new App application struct
func NewApp() *App {
return &App{}
}
// startup is called when the app starts. The context is saved
// so we can call the runtime methods
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
a.loadEnv()
}
// loadEnv 加载 .env 文件
func (a *App) loadEnv() {
// 获取项目根目录(从当前工作目录向上查找 .env 文件)
wd, err := os.Getwd()
if err != nil {
fmt.Printf("获取工作目录失败: %v\n", err)
return
}
// 查找 .env 文件
envPath := a.findEnvFile(wd)
if envPath == "" {
fmt.Println("未找到 .env 文件")
return
}
// 加载 .env 文件
if err := godotenv.Load(envPath); err != nil {
fmt.Printf("加载 .env 文件失败: %v\n", err)
return
}
// 保存项目根目录
a.projectRootDir = filepath.Dir(envPath)
// 读取数据库配置
a.dbConfig = &DatabaseConfig{
Type: os.Getenv("DATABASE_TYPE"),
Host: os.Getenv("DATABASE_HOST"),
Port: os.Getenv("DATABASE_PORT"),
Username: os.Getenv("DATABASE_USERNAME"),
Password: os.Getenv("DATABASE_PASSWORD"),
Name: os.Getenv("DATABASE_NAME"),
}
fmt.Printf("数据库配置已加载: %s@%s:%s/%s\n", a.dbConfig.Username, a.dbConfig.Host, a.dbConfig.Port, a.dbConfig.Name)
}
// findEnvFile 查找 .env 文件
func (a *App) findEnvFile(startDir string) string {
dir := startDir
for {
envPath := filepath.Join(dir, ".env")
if _, err := os.Stat(envPath); err == nil {
return envPath
}
// 检查是否到达根目录
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return ""
}
// GetDatabaseConfig 获取数据库配置
func (a *App) GetDatabaseConfig() *DatabaseConfig {
return a.dbConfig
}
// GetDatabaseDSN 获取数据库连接字符串
func (a *App) GetDatabaseDSN() string {
if a.dbConfig == nil {
return ""
}
// 根据数据库类型生成 DSN
switch strings.ToLower(a.dbConfig.Type) {
case "mariadb", "mysql":
return fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
a.dbConfig.Username,
a.dbConfig.Password,
a.dbConfig.Host,
a.dbConfig.Port,
a.dbConfig.Name,
)
case "postgres", "postgresql":
return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
a.dbConfig.Host,
a.dbConfig.Port,
a.dbConfig.Username,
a.dbConfig.Password,
a.dbConfig.Name,
)
default:
return ""
}
}
// Greet returns a greeting for the given name
func (a *App) Greet(name string) string {
return fmt.Sprintf("Hello %s, It's show time!", name)
}
// GetPinnedBidItems 获取 pin=true 的投标项目
func (a *App) GetPinnedBidItems() ([]BidItem, error) {
dsn := a.GetDatabaseDSN()
if dsn == "" {
return nil, fmt.Errorf("数据库配置未加载")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("连接数据库失败: %v", err)
}
defer db.Close()
// 测试连接
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("数据库连接测试失败: %v", err)
}
// 查询 pin=true 的记录
query := `SELECT id, title, url, publishDate, source, pin, createdAt, updatedAt
FROM bid_items
WHERE pin = true
ORDER BY createdAt DESC`
rows, err := db.Query(query)
if err != nil {
return nil, fmt.Errorf("查询失败: %v", err)
}
defer rows.Close()
var items []BidItem
for rows.Next() {
var item BidItem
var publishDate, createdAt, updatedAt time.Time
err := rows.Scan(
&item.ID,
&item.Title,
&item.URL,
&publishDate,
&item.Source,
&item.Pin,
&createdAt,
&updatedAt,
)
if err != nil {
return nil, fmt.Errorf("扫描行失败: %v", err)
}
item.PublishDate = publishDate.Format("2006-01-02")
item.CreatedAt = createdAt.Format("2006-01-02")
item.UpdatedAt = updatedAt.Format("2006-01-02")
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("遍历行失败: %v", err)
}
return items, nil
}
// GetAiRecommendations 获取 AI 推荐数据
func (a *App) GetAiRecommendations() ([]AiRecommendation, error) {
dsn := a.GetDatabaseDSN()
if dsn == "" {
return nil, fmt.Errorf("数据库配置未加载")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("连接数据库失败: %v", err)
}
defer db.Close()
// 测试连接
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("数据库连接测试失败: %v", err)
}
// 查询 ai_recommendations 表
query := `SELECT id, title, confidence, createdAt
FROM ai_recommendations
ORDER BY createdAt DESC`
rows, err := db.Query(query)
if err != nil {
return nil, fmt.Errorf("查询失败: %v", err)
}
defer rows.Close()
var items []AiRecommendation
for rows.Next() {
var item AiRecommendation
var createdAt time.Time
err := rows.Scan(
&item.ID,
&item.Title,
&item.Confidence,
&createdAt,
)
if err != nil {
return nil, fmt.Errorf("扫描行失败: %v", err)
}
item.CreatedAt = createdAt.Format("2006-01-02")
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("遍历行失败: %v", err)
}
return items, nil
}
// GetCrawlInfoStats 获取爬虫统计信息
func (a *App) GetCrawlInfoStats() ([]CrawlInfoStat, error) {
dsn := a.GetDatabaseDSN()
if dsn == "" {
return nil, fmt.Errorf("数据库配置未加载")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("连接数据库失败: %v", err)
}
defer db.Close()
// 测试连接
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("数据库连接测试失败: %v", err)
}
// 查询 crawl_info_add 表,按 source 分组获取最新记录
query := `SELECT
c1.source,
c1.count,
c1.createdAt as latestUpdate,
c1.latestPublishDate,
c1.error
FROM crawl_info_add c1
WHERE c1.createdAt = (
SELECT MAX(c2.createdAt)
FROM crawl_info_add c2
WHERE c2.source = c1.source
)
ORDER BY c1.source`
rows, err := db.Query(query)
if err != nil {
return nil, fmt.Errorf("查询失败: %v", err)
}
defer rows.Close()
var stats []CrawlInfoStat
for rows.Next() {
var stat CrawlInfoStat
var latestUpdate, latestPublishDate sql.NullTime
var errorStr sql.NullString
err := rows.Scan(
&stat.Source,
&stat.Count,
&latestUpdate,
&latestPublishDate,
&errorStr,
)
if err != nil {
return nil, fmt.Errorf("扫描行失败: %v", err)
}
if latestUpdate.Valid {
stat.LatestUpdate = latestUpdate.Time.Format("2006-01-02 15:04:05")
} else {
stat.LatestUpdate = ""
}
if latestPublishDate.Valid {
stat.LatestPublishDate = latestPublishDate.Time.Format("2006-01-02 15:04:05")
} else {
stat.LatestPublishDate = ""
}
if errorStr.Valid {
stat.Error = errorStr.String
} else {
stat.Error = ""
}
stats = append(stats, stat)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("遍历行失败: %v", err)
}
return stats, nil
}

View File

@@ -0,0 +1,23 @@
# 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.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar)
## Type Support For `.vue` Imports in TS
Since TypeScript cannot handle type information for `.vue` imports, they are shimmed to be a generic Vue component type
by default. In most cases this is fine if you don't really care about component prop types outside of templates.
However, if you wish to get actual prop types in `.vue` imports (for example to get props validation when using
manual `h(...)` calls), you can enable Volar's Take Over mode by following these steps:
1. Run `Extensions: Show Built-in Extensions` from VS Code's command palette, look
for `TypeScript and JavaScript Language Features`, then right click and select `Disable (Workspace)`. By default,
Take Over mode will enable itself if the default TypeScript extension is disabled.
2. Reload the VS Code window by running `Developer: Reload Window` from the command palette.
You can learn more about Take Over mode [here](https://github.com/johnsoncodehk/volar/discussions/471).

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>looker</title>
</head>
<body>
<div id="app"></div>
<script src="./src/main.ts" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,21 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.2.37"
},
"devDependencies": {
"@vitejs/plugin-vue": "^3.0.3",
"typescript": "^4.6.4",
"vite": "^3.0.7",
"vue-tsc": "^1.8.27",
"@babel/types": "^7.18.10"
}
}

View File

@@ -0,0 +1 @@
bb7ffb87329c9ad4990374471d4ce9a4

View File

@@ -0,0 +1,195 @@
<script lang="ts" setup>
import { ref, onMounted, onUnmounted } from 'vue'
import PinnedBids from './components/PinnedBids.vue'
import AiRecommendations from './components/AiRecommendations.vue'
import WidgetCrawlInfo from './components/WidgetCrawlInfo.vue'
const activeTab = ref('pinned')
const pinnedBidsRef = ref<InstanceType<typeof PinnedBids>>()
const aiRecommendationsRef = ref<InstanceType<typeof AiRecommendations>>()
const widgetCrawlInfoRef = ref<InstanceType<typeof WidgetCrawlInfo>>()
let refreshTimer: number | null = null
const showToast = ref(false)
const toastMessage = ref('')
const tabs = [
{ id: 'pinned', label: '置顶项目' },
{ id: 'ai', label: 'AI 推荐' },
{ id: 'status', label: '状态' }
]
const handleRefresh = () => {
if (activeTab.value === 'pinned' && pinnedBidsRef.value) {
pinnedBidsRef.value.loadPinnedBids()
showToastMessage('置顶项目已刷新')
} else if (activeTab.value === 'ai' && aiRecommendationsRef.value) {
aiRecommendationsRef.value.loadRecommendations()
showToastMessage('AI 推荐已刷新')
} else if (activeTab.value === 'status' && widgetCrawlInfoRef.value) {
widgetCrawlInfoRef.value.loadCrawlStats()
showToastMessage('状态已刷新')
}
}
const showToastMessage = (message: string) => {
toastMessage.value = message
showToast.value = true
setTimeout(() => {
showToast.value = false
}, 2000)
}
const startAutoRefresh = () => {
// 每5分钟300000毫秒自动刷新
refreshTimer = window.setInterval(() => {
handleRefresh()
}, 300000)
}
const stopAutoRefresh = () => {
if (refreshTimer !== null) {
clearInterval(refreshTimer)
refreshTimer = null
}
}
onMounted(() => {
startAutoRefresh()
})
onUnmounted(() => {
stopAutoRefresh()
})
</script>
<template>
<div class="app-container">
<div class="tabs">
<button
v-for="tab in tabs"
:key="tab.id"
:class="['tab-button', { active: activeTab === tab.id }]"
@click="activeTab = tab.id"
>
{{ tab.label }}
</button>
<button class="refresh-button" @click="handleRefresh">
🔄 刷新
</button>
</div>
<div class="tab-content">
<PinnedBids v-if="activeTab === 'pinned'" ref="pinnedBidsRef"/>
<AiRecommendations v-else-if="activeTab === 'ai'" ref="aiRecommendationsRef"/>
<WidgetCrawlInfo v-else-if="activeTab === 'status'" ref="widgetCrawlInfoRef"/>
</div>
<div v-if="showToast" class="toast">
{{ toastMessage }}
</div>
</div>
</template>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #f5f5f5;
font-size: 12px;
}
.app-container {
min-height: 100vh;
background: #f5f5f5;
}
.tabs {
display: flex;
background: #fff;
border-bottom: 1px solid #e0e0e0;
padding: 0 8px;
}
.tab-button {
padding: 8px 12px;
background: none;
border: none;
border-bottom: 2px solid transparent;
font-size: 12px;
font-weight: 500;
color: #666;
cursor: pointer;
transition: all 0.2s ease;
}
.tab-button:hover {
color: #333;
background: #f9f9f9;
}
.tab-button.active {
color: #3498db;
border-bottom-color: #3498db;
}
.tab-content {
padding: 8px;
}
.refresh-button {
margin-left: auto;
padding: 4px 10px;
background: #3498db;
color: #fff;
border: none;
border-radius: 3px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s ease;
}
.refresh-button:hover {
background: #2980b9;
}
.refresh-button:active {
transform: scale(0.98);
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
color: #999;
font-size: 12px;
}
.toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 12px 24px;
border-radius: 4px;
font-size: 12px;
z-index: 1000;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,159 @@
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import { GetAiRecommendations } from '../../wailsjs/go/main/App'
interface AiRecommendation {
id: string
title: string
confidence: number
createdAt: string
}
const recommendations = ref<AiRecommendation[]>([])
const loading = ref(true)
const error = ref('')
const loadRecommendations = async () => {
try {
loading.value = true
error.value = ''
const items = await GetAiRecommendations()
recommendations.value = items
} catch (err) {
error.value = `加载失败: ${err}`
console.error('加载 AI 推荐失败:', err)
} finally {
loading.value = false
}
}
const getConfidenceColor = (confidence: number) => {
if (confidence >= 80) return '#27ae60'
if (confidence >= 60) return '#f39c12'
return '#e74c3c'
}
const getConfidenceLabel = (confidence: number) => {
if (confidence >= 80) return '高'
if (confidence >= 60) return '中'
return '低'
}
onMounted(() => {
loadRecommendations()
})
defineExpose({
loadRecommendations
})
</script>
<template>
<div class="ai-recommendations-container">
<!-- <h2 class="title">AI 推荐项目</h2> -->
<div v-if="loading" class="loading">
加载中...
</div>
<div v-else-if="error" class="error">
{{ error }}
</div>
<div v-else-if="recommendations.length === 0" class="empty">
暂无推荐项目
</div>
<div v-else class="recommendation-list">
<div
v-for="item in recommendations"
:key="item.id"
class="recommendation-item"
>
<div class="recommendation-header">
<span
class="confidence-badge"
:style="{ backgroundColor: getConfidenceColor(item.confidence) }"
>
{{ getConfidenceLabel(item.confidence) }} ({{ item.confidence }}%)
</span>
<span class="date">{{ item.createdAt }}</span>
</div>
<h3 class="recommendation-title">{{ item.title }}</h3>
</div>
</div>
</div>
</template>
<style scoped>
.ai-recommendations-container {
padding: 8px;
}
.title {
font-size: 14px;
font-weight: bold;
margin-bottom: 8px;
color: #333;
}
.loading,
.error,
.empty {
text-align: center;
padding: 20px;
color: #666;
font-size: 12px;
}
.error {
color: #e74c3c;
}
.recommendation-list {
display: grid;
gap: 8px;
}
.recommendation-item {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 8px;
transition: all 0.2s ease;
}
.recommendation-item:hover {
border-color: #3498db;
box-shadow: 0 1px 4px rgba(52, 152, 219, 0.15);
transform: translateY(-1px);
}
.recommendation-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.confidence-badge {
color: #fff;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 500;
}
.date {
font-size: 10px;
color: #999;
}
.recommendation-title {
font-size: 12px;
font-weight: 500;
color: #333;
margin: 0;
line-height: 1.4;
}
</style>

View File

@@ -0,0 +1,168 @@
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import { GetPinnedBidItems } from '../../wailsjs/go/main/App'
interface BidItem {
id: string
title: string
url: string
publishDate: string
source: string
pin: boolean
createdAt: string
updatedAt: string
}
const bidItems = ref<BidItem[]>([])
const loading = ref(true)
const error = ref('')
const loadPinnedBids = async () => {
try {
loading.value = true
error.value = ''
const items = await GetPinnedBidItems()
bidItems.value = items
} catch (err) {
error.value = `加载失败: ${err}`
console.error('加载置顶投标项目失败:', err)
} finally {
loading.value = false
}
}
const openUrl = (url: string) => {
window.open(url, '_blank')
}
onMounted(() => {
loadPinnedBids()
})
defineExpose({
loadPinnedBids
})
</script>
<template>
<div class="pinned-bids-container">
<!-- <h2 class="title">置顶投标项目</h2> -->
<div v-if="loading" class="loading">
加载中...
</div>
<div v-else-if="error" class="error">
{{ error }}
</div>
<div v-else-if="bidItems.length === 0" class="empty">
暂无置顶项目
</div>
<div v-else class="bid-list">
<div
v-for="item in bidItems"
:key="item.id"
class="bid-item"
@click="openUrl(item.url)"
>
<div class="bid-header">
<span class="source">{{ item.source }}</span>
<span class="date">{{ item.publishDate }}</span>
</div>
<h3 class="bid-title">{{ item.title }}</h3>
<!-- <div class="bid-footer">
<span class="pin-badge">📌 已置顶</span>
</div> -->
</div>
</div>
</div>
</template>
<style scoped>
.pinned-bids-container {
padding: 8px;
}
.title {
font-size: 14px;
font-weight: bold;
margin-bottom: 8px;
color: #333;
}
.loading,
.error,
.empty {
text-align: center;
padding: 20px;
color: #666;
font-size: 12px;
}
.error {
color: #e74c3c;
}
.bid-list {
display: grid;
gap: 8px;
}
.bid-item {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.bid-item:hover {
border-color: #3498db;
box-shadow: 0 1px 4px rgba(52, 152, 219, 0.15);
transform: translateY(-1px);
}
.bid-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.source {
background: #f0f0f0;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
color: #666;
}
.date {
font-size: 10px;
color: #999;
}
.bid-title {
font-size: 12px;
font-weight: 500;
color: #333;
margin: 0 0 6px 0;
line-height: 1.4;
}
.bid-footer {
display: flex;
justify-content: flex-end;
}
.pin-badge {
background: #fff3cd;
color: #856404;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
}
</style>

View File

@@ -0,0 +1,155 @@
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import { GetCrawlInfoStats } from '../../wailsjs/go/main/App'
interface CrawlInfoStat {
source: string
count: number
latestUpdate: string
latestPublishDate: string
error: string
}
const crawlStats = ref<CrawlInfoStat[]>([])
const loading = ref(true)
const error = ref('')
const loadCrawlStats = async () => {
try {
loading.value = true
error.value = ''
const stats = await GetCrawlInfoStats()
crawlStats.value = stats
} catch (err) {
error.value = `加载失败: ${err}`
console.error('加载爬虫统计信息失败:', err)
} finally {
loading.value = false
}
}
const getStatusText = (stat: CrawlInfoStat) => {
if (stat.error) {
return '出错'
}
if (stat.count > 0) {
return '正常'
}
return '无数据'
}
const getStatusClass = (stat: CrawlInfoStat) => {
if (stat.error) {
return 'status-error'
}
if (stat.count > 0) {
return 'status-success'
}
return 'status-info'
}
onMounted(() => {
loadCrawlStats()
})
defineExpose({
loadCrawlStats
})
</script>
<template>
<div class="crawl-info-container">
<div v-if="loading" class="loading">
加载中...
</div>
<div v-else-if="error" class="error">
{{ error }}
</div>
<div v-else-if="crawlStats.length === 0" class="empty">
暂无爬虫统计信息
</div>
<div v-else class="crawl-list">
<div
v-for="stat in crawlStats"
:key="stat.source"
class="crawl-item"
>
<span class="source">{{ stat.source }}</span>
<span :class="['status', getStatusClass(stat)]">
{{ getStatusText(stat) }}
</span>
</div>
</div>
</div>
</template>
<style scoped>
.crawl-info-container {
padding: 8px;
}
.loading,
.error,
.empty {
text-align: center;
padding: 20px;
color: #666;
font-size: 12px;
}
.error {
color: #e74c3c;
}
.crawl-list {
display: grid;
gap: 8px;
}
.crawl-item {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 8px 12px;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s ease;
}
.crawl-item:hover {
border-color: #3498db;
box-shadow: 0 1px 4px rgba(52, 152, 219, 0.15);
}
.source {
font-size: 12px;
font-weight: 500;
color: #333;
}
.status {
font-size: 11px;
padding: 2px 8px;
border-radius: 3px;
font-weight: 500;
}
.status-success {
background: #d4edda;
color: #155724;
}
.status-error {
background: #f8d7da;
color: #721c24;
}
.status-info {
background: #e2e3e5;
color: #383d41;
}
</style>

View File

@@ -0,0 +1,5 @@
import {createApp} from 'vue'
import App from './App.vue'
import './style.css';
createApp(App).mount('#app')

View File

@@ -0,0 +1,26 @@
html {
background-color: rgba(27, 38, 54, 1);
text-align: center;
color: white;
}
body {
margin: 0;
color: white;
font-family: "Nunito", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
}
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 400;
src: local(""),
url("assets/fonts/nunito-v16-latin-regular.woff2") format("woff2");
}
#app {
height: 100vh;
text-align: center;
}

View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type {DefineComponent} from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": [
"ESNext",
"DOM"
],
"skipLibCheck": true
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": [
"vite.config.ts"
]
}

View File

@@ -0,0 +1,7 @@
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()]
})

View File

@@ -0,0 +1,15 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {main} from '../models';
export function GetAiRecommendations():Promise<Array<main.AiRecommendation>>;
export function GetCrawlInfoStats():Promise<Array<main.CrawlInfoStat>>;
export function GetDatabaseConfig():Promise<main.DatabaseConfig>;
export function GetDatabaseDSN():Promise<string>;
export function GetPinnedBidItems():Promise<Array<main.BidItem>>;
export function Greet(arg1:string):Promise<string>;

View File

@@ -0,0 +1,27 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function GetAiRecommendations() {
return window['go']['main']['App']['GetAiRecommendations']();
}
export function GetCrawlInfoStats() {
return window['go']['main']['App']['GetCrawlInfoStats']();
}
export function GetDatabaseConfig() {
return window['go']['main']['App']['GetDatabaseConfig']();
}
export function GetDatabaseDSN() {
return window['go']['main']['App']['GetDatabaseDSN']();
}
export function GetPinnedBidItems() {
return window['go']['main']['App']['GetPinnedBidItems']();
}
export function Greet(arg1) {
return window['go']['main']['App']['Greet'](arg1);
}

View File

@@ -0,0 +1,91 @@
export namespace main {
export class AiRecommendation {
id: string;
title: string;
confidence: number;
createdAt: string;
static createFrom(source: any = {}) {
return new AiRecommendation(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.title = source["title"];
this.confidence = source["confidence"];
this.createdAt = source["createdAt"];
}
}
export class BidItem {
id: string;
title: string;
url: string;
publishDate: string;
source: string;
pin: boolean;
createdAt: string;
updatedAt: string;
static createFrom(source: any = {}) {
return new BidItem(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.title = source["title"];
this.url = source["url"];
this.publishDate = source["publishDate"];
this.source = source["source"];
this.pin = source["pin"];
this.createdAt = source["createdAt"];
this.updatedAt = source["updatedAt"];
}
}
export class CrawlInfoStat {
source: string;
count: number;
latestUpdate: string;
latestPublishDate: string;
error: string;
static createFrom(source: any = {}) {
return new CrawlInfoStat(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.source = source["source"];
this.count = source["count"];
this.latestUpdate = source["latestUpdate"];
this.latestPublishDate = source["latestPublishDate"];
this.error = source["error"];
}
}
export class DatabaseConfig {
Type: string;
Host: string;
Port: string;
Username: string;
Password: string;
Name: string;
static createFrom(source: any = {}) {
return new DatabaseConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.Type = source["Type"];
this.Host = source["Host"];
this.Port = source["Port"];
this.Username = source["Username"];
this.Password = source["Password"];
this.Name = source["Name"];
}
}
}

View File

@@ -0,0 +1,24 @@
{
"name": "@wailsapp/runtime",
"version": "2.0.0",
"description": "Wails Javascript runtime library",
"main": "runtime.js",
"types": "runtime.d.ts",
"scripts": {
},
"repository": {
"type": "git",
"url": "git+https://github.com/wailsapp/wails.git"
},
"keywords": [
"Wails",
"Javascript",
"Go"
],
"author": "Lea Anthony <lea.anthony@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/wailsapp/wails/issues"
},
"homepage": "https://github.com/wailsapp/wails#readme"
}

View File

@@ -0,0 +1,249 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export interface Position {
x: number;
y: number;
}
export interface Size {
w: number;
h: number;
}
export interface Screen {
isCurrent: boolean;
isPrimary: boolean;
width : number
height : number
}
// Environment information such as platform, buildtype, ...
export interface EnvironmentInfo {
buildType: string;
platform: string;
arch: string;
}
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
// emits the given event. Optional data may be passed with the event.
// This will trigger any event listeners.
export function EventsEmit(eventName: string, ...data: any): void;
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
// sets up a listener for the given event name, but will only trigger a given number times.
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
// sets up a listener for the given event name, but will only trigger once.
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
// unregisters the listener for the given event name.
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
// unregisters all listeners.
export function EventsOffAll(): void;
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
// logs the given message as a raw message
export function LogPrint(message: string): void;
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
// logs the given message at the `trace` log level.
export function LogTrace(message: string): void;
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
// logs the given message at the `debug` log level.
export function LogDebug(message: string): void;
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
// logs the given message at the `error` log level.
export function LogError(message: string): void;
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
// logs the given message at the `fatal` log level.
// The application will quit after calling this method.
export function LogFatal(message: string): void;
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
// logs the given message at the `info` log level.
export function LogInfo(message: string): void;
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
// logs the given message at the `warning` log level.
export function LogWarning(message: string): void;
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
// Forces a reload by the main application as well as connected browsers.
export function WindowReload(): void;
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
// Reloads the application frontend.
export function WindowReloadApp(): void;
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
// Sets the window AlwaysOnTop or not on top.
export function WindowSetAlwaysOnTop(b: boolean): void;
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
// *Windows only*
// Sets window theme to system default (dark/light).
export function WindowSetSystemDefaultTheme(): void;
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
// *Windows only*
// Sets window to light theme.
export function WindowSetLightTheme(): void;
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
// *Windows only*
// Sets window to dark theme.
export function WindowSetDarkTheme(): void;
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
// Centers the window on the monitor the window is currently on.
export function WindowCenter(): void;
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
// Sets the text in the window title bar.
export function WindowSetTitle(title: string): void;
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
// Makes the window full screen.
export function WindowFullscreen(): void;
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
// Restores the previous window dimensions and position prior to full screen.
export function WindowUnfullscreen(): void;
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
export function WindowIsFullscreen(): Promise<boolean>;
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
// Sets the width and height of the window.
export function WindowSetSize(width: number, height: number): void;
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
// Gets the width and height of the window.
export function WindowGetSize(): Promise<Size>;
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMaxSize(width: number, height: number): void;
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMinSize(width: number, height: number): void;
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
// Sets the window position relative to the monitor the window is currently on.
export function WindowSetPosition(x: number, y: number): void;
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
// Gets the window position relative to the monitor the window is currently on.
export function WindowGetPosition(): Promise<Position>;
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
// Hides the window.
export function WindowHide(): void;
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
// Shows the window, if it is currently hidden.
export function WindowShow(): void;
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
// Maximises the window to fill the screen.
export function WindowMaximise(): void;
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
// Toggles between Maximised and UnMaximised.
export function WindowToggleMaximise(): void;
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
// Restores the window to the dimensions and position prior to maximising.
export function WindowUnmaximise(): void;
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
// Returns the state of the window, i.e. whether the window is maximised or not.
export function WindowIsMaximised(): Promise<boolean>;
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
// Minimises the window.
export function WindowMinimise(): void;
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
// Restores the window to the dimensions and position prior to minimising.
export function WindowUnminimise(): void;
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
// Returns the state of the window, i.e. whether the window is minimised or not.
export function WindowIsMinimised(): Promise<boolean>;
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
// Returns the state of the window, i.e. whether the window is normal or not.
export function WindowIsNormal(): Promise<boolean>;
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
export function ScreenGetAll(): Promise<Screen[]>;
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
// Opens the given URL in the system browser.
export function BrowserOpenURL(url: string): void;
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
// Returns information about the environment
export function Environment(): Promise<EnvironmentInfo>;
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
// Quits the application.
export function Quit(): void;
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
// Hides the application.
export function Hide(): void;
// [Show](https://wails.io/docs/reference/runtime/intro#show)
// Shows the application.
export function Show(): void;
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
// Returns the current text stored on clipboard
export function ClipboardGetText(): Promise<string>;
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
// Sets a text on the clipboard
export function ClipboardSetText(text: string): Promise<boolean>;
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
// OnFileDropOff removes the drag and drop listeners and handlers.
export function OnFileDropOff() :void
// Check if the file path resolver is available
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void

View File

@@ -0,0 +1,242 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export function LogPrint(message) {
window.runtime.LogPrint(message);
}
export function LogTrace(message) {
window.runtime.LogTrace(message);
}
export function LogDebug(message) {
window.runtime.LogDebug(message);
}
export function LogInfo(message) {
window.runtime.LogInfo(message);
}
export function LogWarning(message) {
window.runtime.LogWarning(message);
}
export function LogError(message) {
window.runtime.LogError(message);
}
export function LogFatal(message) {
window.runtime.LogFatal(message);
}
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
}
export function EventsOn(eventName, callback) {
return EventsOnMultiple(eventName, callback, -1);
}
export function EventsOff(eventName, ...additionalEventNames) {
return window.runtime.EventsOff(eventName, ...additionalEventNames);
}
export function EventsOffAll() {
return window.runtime.EventsOffAll();
}
export function EventsOnce(eventName, callback) {
return EventsOnMultiple(eventName, callback, 1);
}
export function EventsEmit(eventName) {
let args = [eventName].slice.call(arguments);
return window.runtime.EventsEmit.apply(null, args);
}
export function WindowReload() {
window.runtime.WindowReload();
}
export function WindowReloadApp() {
window.runtime.WindowReloadApp();
}
export function WindowSetAlwaysOnTop(b) {
window.runtime.WindowSetAlwaysOnTop(b);
}
export function WindowSetSystemDefaultTheme() {
window.runtime.WindowSetSystemDefaultTheme();
}
export function WindowSetLightTheme() {
window.runtime.WindowSetLightTheme();
}
export function WindowSetDarkTheme() {
window.runtime.WindowSetDarkTheme();
}
export function WindowCenter() {
window.runtime.WindowCenter();
}
export function WindowSetTitle(title) {
window.runtime.WindowSetTitle(title);
}
export function WindowFullscreen() {
window.runtime.WindowFullscreen();
}
export function WindowUnfullscreen() {
window.runtime.WindowUnfullscreen();
}
export function WindowIsFullscreen() {
return window.runtime.WindowIsFullscreen();
}
export function WindowGetSize() {
return window.runtime.WindowGetSize();
}
export function WindowSetSize(width, height) {
window.runtime.WindowSetSize(width, height);
}
export function WindowSetMaxSize(width, height) {
window.runtime.WindowSetMaxSize(width, height);
}
export function WindowSetMinSize(width, height) {
window.runtime.WindowSetMinSize(width, height);
}
export function WindowSetPosition(x, y) {
window.runtime.WindowSetPosition(x, y);
}
export function WindowGetPosition() {
return window.runtime.WindowGetPosition();
}
export function WindowHide() {
window.runtime.WindowHide();
}
export function WindowShow() {
window.runtime.WindowShow();
}
export function WindowMaximise() {
window.runtime.WindowMaximise();
}
export function WindowToggleMaximise() {
window.runtime.WindowToggleMaximise();
}
export function WindowUnmaximise() {
window.runtime.WindowUnmaximise();
}
export function WindowIsMaximised() {
return window.runtime.WindowIsMaximised();
}
export function WindowMinimise() {
window.runtime.WindowMinimise();
}
export function WindowUnminimise() {
window.runtime.WindowUnminimise();
}
export function WindowSetBackgroundColour(R, G, B, A) {
window.runtime.WindowSetBackgroundColour(R, G, B, A);
}
export function ScreenGetAll() {
return window.runtime.ScreenGetAll();
}
export function WindowIsMinimised() {
return window.runtime.WindowIsMinimised();
}
export function WindowIsNormal() {
return window.runtime.WindowIsNormal();
}
export function BrowserOpenURL(url) {
window.runtime.BrowserOpenURL(url);
}
export function Environment() {
return window.runtime.Environment();
}
export function Quit() {
window.runtime.Quit();
}
export function Hide() {
window.runtime.Hide();
}
export function Show() {
window.runtime.Show();
}
export function ClipboardGetText() {
return window.runtime.ClipboardGetText();
}
export function ClipboardSetText(text) {
return window.runtime.ClipboardSetText(text);
}
/**
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
*
* @export
* @callback OnFileDropCallback
* @param {number} x - x coordinate of the drop
* @param {number} y - y coordinate of the drop
* @param {string[]} paths - A list of file paths.
*/
/**
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
*
* @export
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
*/
export function OnFileDrop(callback, useDropTarget) {
return window.runtime.OnFileDrop(callback, useDropTarget);
}
/**
* OnFileDropOff removes the drag and drop listeners and handlers.
*/
export function OnFileDropOff() {
return window.runtime.OnFileDropOff();
}
export function CanResolveFilePaths() {
return window.runtime.CanResolveFilePaths();
}
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}

42
widget/looker/go.mod Normal file
View File

@@ -0,0 +1,42 @@
module looker
go 1.23
require (
github.com/go-sql-driver/mysql v1.9.3
github.com/joho/godotenv v1.5.1
github.com/wailsapp/wails/v2 v2.11.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
)
// replace github.com/wailsapp/wails/v2 v2.11.0 => C:\Users\3040\go\pkg\mod

87
widget/looker/go.sum Normal file
View File

@@ -0,0 +1,87 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

36
widget/looker/main.go Normal file
View File

@@ -0,0 +1,36 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
)
//go:embed all:frontend/dist
var assets embed.FS
func main() {
// Create an instance of the app structure
app := NewApp()
// Create application with options
err := wails.Run(&options.App{
Title: "looker",
Width: 400,
Height: 500,
AssetServer: &assetserver.Options{
Assets: assets,
},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.startup,
Bind: []interface{}{
app,
},
})
if err != nil {
println("Error:", err.Error())
}
}

View File

@@ -0,0 +1,194 @@
package main
import (
"bufio"
"fmt"
"io"
"os"
"os/signal"
"syscall"
"github.com/getlantern/systray"
)
var (
sendPipe *os.File
receivePipe *os.File
logFile *os.File
)
func initLog() {
var err error
logFile, err = os.OpenFile("systray_debug.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
// failsafe
return
}
}
func logMsg(format string, args ...interface{}) {
if logFile != nil {
fmt.Fprintf(logFile, format+"\n", args...)
}
}
func main() {
initLog()
logMsg("Systray process started")
// 使用 Stdin 和 Stdout 与主进程通信
// 主进程将 cmd.Stdin 连接到 pipes将 cmd.Stdout 连接到 pipes
// 所以这里我们直接使用 os.Stdin 读取os.Stdout 发送
sendPipe = os.Stdout
receivePipe = os.Stdin
// 启动系统托盘
logMsg("Calling systray.Run")
systray.Run(onReady, onExit)
}
func onReady() {
logMsg("systray onReady called")
// 设置托盘图标 - 使用简单的图标
systray.SetIcon(getIcon())
systray.SetTitle("Bidding Looker")
systray.SetTooltip("Bidding Looker - 招标信息查看器")
// 显示窗口菜单项
mShow := systray.AddMenuItem("显示窗口", "显示主窗口")
mQuit := systray.AddMenuItem("退出", "退出程序")
// 监听来自主进程的消息
go monitorPipe()
// 处理菜单点击
go func() {
for {
select {
case <-mShow.ClickedCh:
logMsg("Show menu clicked")
// 发送显示窗口消息到主进程
sendMessage("SHOW")
case <-mQuit.ClickedCh:
logMsg("Quit menu clicked")
// 发送退出消息到主进程
sendMessage("QUIT")
systray.Quit()
}
}
}()
// 监听系统信号
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
logMsg("Received signal, quitting")
systray.Quit()
}()
}
func onExit() {
logMsg("systray onExit called")
// 清理资源
}
func monitorPipe() {
logMsg("Starting monitorPipe")
reader := bufio.NewReader(receivePipe)
for {
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
logMsg("Pipe EOF, quitting")
// 主进程关闭了管道(可能退出了),我们也退出
systray.Quit()
return
}
logMsg("Pipe read error: %v", err)
fmt.Fprintf(os.Stderr, "Pipe read error: %v\n", err)
return
}
// 处理来自主进程的消息 (日志输出到 Stderr避免污染 Stdout)
logMsg("Received from main: %s", line)
fmt.Fprintf(os.Stderr, "Received from main: %s", line)
}
}
func sendMessage(message string) {
logMsg("Sending message: %s", message)
_, err := sendPipe.WriteString(message + "\n")
if err != nil {
logMsg("Failed to send message: %v", err)
fmt.Fprintf(os.Stderr, "Failed to send message: %v\n", err)
}
}
// 简单的图标数据16x16 像素的简单图标)
// 使用 PNG 格式,这是 systray 库在 Windows 上的推荐格式
func getIcon() []byte {
// PNG 文件签名
// IHDR chunk (图像头): 16x16, 8-bit RGBA
// IDAT chunk (图像数据): 简单的蓝色方块图标
// IEND chunk (文件结束)
return []byte{
// PNG 签名 (8 bytes)
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
// IHDR chunk
0x00, 0x00, 0x00, 0x0D, // Length: 13 bytes
0x49, 0x48, 0x44, 0x52, // Type: IHDR
0x00, 0x00, 0x00, 0x10, // Width: 16
0x00, 0x00, 0x00, 0x10, // Height: 16
0x08, // Bit depth: 8
0x06, // Color type: RGBA
0x00, 0x00, 0x00, // Compression, Filter, Interlace
0x5D, 0x8A, 0x7F, 0xD4, // CRC
// IDAT chunk (使用最简单的 PNG 编码)
0x00, 0x00, 0x01, 0x6D, // Length: 365 bytes
0x49, 0x44, 0x41, 0x54, // Type: IDAT
// 压缩数据 (zlib 格式)
0x78, 0x9C, // zlib header (default compression)
// Deflate 压缩块
0x63, 0x18, 0x19, 0x60, 0x28, 0x55, 0xF6, 0x7F, 0x00, 0x00, 0x00,
0x00, 0x05, 0x00, 0x01, 0x0A, 0x8C, 0x0A, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
0x52, 0x4B, 0x8C, 0x36, // CRC
// IEND chunk
0x00, 0x00, 0x00, 0x00, // Length: 0 bytes
0x49, 0x45, 0x4E, 0x44, // Type: IEND
0xAE, 0x42, 0x60, 0x82, // CRC
}
}

13
widget/looker/wails.json Normal file
View File

@@ -0,0 +1,13 @@
{
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "looker",
"outputfilename": "looker",
"frontend:install": "npm install",
"frontend:build": "npm run build",
"frontend:dev:watcher": "npm run dev",
"frontend:dev:serverUrl": "auto",
"author": {
"name": "dmy",
"email": "dmy@dmy.com"
}
}