feat: 添加用户认证系统
引入基于 Basic Auth 的用户认证系统,包括用户管理、登录界面和 API 鉴权 - 新增用户实体和管理功能 - 实现前端登录界面和凭证管理 - 重构 API 鉴权为 Basic Auth 模式 - 添加用户管理脚本工具
This commit is contained in:
242
widget/looker/frontend/src/shared/README.md
Normal file
242
widget/looker/frontend/src/shared/README.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# 共享代码使用说明
|
||||
|
||||
## 概述
|
||||
|
||||
本共享包提供了HTTP和IPC框架兼容的统一代码实现,包括数据模型、API适配器和共享组件。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
shared/
|
||||
├── models/ # 数据模型定义
|
||||
│ └── bid-item.ts
|
||||
├── api/ # API适配器层
|
||||
│ ├── bid-api.ts # 统一API接口
|
||||
│ ├── http-adapter.ts # HTTP适配器实现
|
||||
│ ├── ipc-adapter.ts # IPC适配器实现
|
||||
│ ├── api-factory.ts # API工厂(环境检测)
|
||||
│ └── index.ts # 导出文件
|
||||
├── components/ # 共享Vue组件
|
||||
│ ├── BaseBidList.vue
|
||||
│ ├── BaseAiRecommendations.vue
|
||||
│ ├── BaseCrawlInfo.vue
|
||||
│ └── index.ts
|
||||
├── package.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 构建共享包
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```bash
|
||||
npm run build:shared
|
||||
```
|
||||
|
||||
这将把shared目录的内容复制到两个前端项目中:
|
||||
- `frontend/src/shared/`
|
||||
- `widget/looker/frontend/src/shared/`
|
||||
|
||||
### 2. 在HTTP前端中使用
|
||||
|
||||
```typescript
|
||||
import { getAPI } from './shared/api'
|
||||
|
||||
const api = getAPI()
|
||||
|
||||
// 使用API
|
||||
const bids = await api.getPinnedBids()
|
||||
```
|
||||
|
||||
### 3. 在IPC前端中使用
|
||||
|
||||
```typescript
|
||||
import { getAPI } from './shared/api'
|
||||
|
||||
const api = getAPI()
|
||||
|
||||
// 使用API(自动检测到Wails环境)
|
||||
const bids = await api.getPinnedBids()
|
||||
```
|
||||
|
||||
### 4. 使用共享组件
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { getAPI } from './shared/api'
|
||||
import BaseBidList from './shared/components/BaseBidList.vue'
|
||||
|
||||
const api = getAPI()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBidList :api="api" title="置顶项目" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## API接口
|
||||
|
||||
### BidAPI接口
|
||||
|
||||
```typescript
|
||||
interface BidAPI {
|
||||
// 投标相关
|
||||
getPinnedBids(): Promise<BidItem[]>
|
||||
getRecentBids(limit?: number): Promise<BidItem[]>
|
||||
getBidsByDateRange(startDate: string, endDate: string): Promise<BidItem[]>
|
||||
getBidsByParams(params: BidQueryParams): Promise<PaginatedResponse<BidItem>>
|
||||
updatePinStatus(title: string, pin: boolean): Promise<void>
|
||||
getSources(): Promise<string[]>
|
||||
|
||||
// AI推荐相关
|
||||
getAiRecommendations(): Promise<AiRecommendation[]>
|
||||
saveAiRecommendations(recommendations: AiRecommendation[]): Promise<void>
|
||||
getLatestRecommendations(): Promise<AiRecommendation[]>
|
||||
|
||||
// 关键词相关
|
||||
getKeywords(): Promise<Keyword[]>
|
||||
addKeyword(word: string, weight?: number): Promise<Keyword>
|
||||
deleteKeyword(id: string): Promise<void>
|
||||
|
||||
// 爬虫相关
|
||||
getCrawlStats(): Promise<CrawlInfoStat[]>
|
||||
runCrawler(): Promise<void>
|
||||
runCrawlerBySource(sourceName: string): Promise<void>
|
||||
getCrawlerStatus(): Promise<{ status: string; lastRun: string }>
|
||||
}
|
||||
```
|
||||
|
||||
## 数据模型
|
||||
|
||||
### BidItem
|
||||
|
||||
```typescript
|
||||
interface BidItem {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
publishDate: string
|
||||
source: string
|
||||
pin: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
```
|
||||
|
||||
### AiRecommendation
|
||||
|
||||
```typescript
|
||||
interface AiRecommendation {
|
||||
id: string
|
||||
title: string
|
||||
confidence: number
|
||||
createdAt: string
|
||||
}
|
||||
```
|
||||
|
||||
### CrawlInfoStat
|
||||
|
||||
```typescript
|
||||
interface CrawlInfoStat {
|
||||
source: string
|
||||
count: number
|
||||
latestUpdate: string
|
||||
latestPublishDate: string
|
||||
error: string
|
||||
}
|
||||
```
|
||||
|
||||
## 环境检测
|
||||
|
||||
API工厂会自动检测运行环境:
|
||||
|
||||
- **HTTP环境**: 浏览器环境,使用HTTP适配器
|
||||
- **IPC环境**: Wails桌面应用环境,使用IPC适配器
|
||||
|
||||
可以通过以下方式手动检测:
|
||||
|
||||
```typescript
|
||||
import { getEnvironment } from './shared/api'
|
||||
|
||||
const env = getEnvironment() // 'http' | 'ipc'
|
||||
```
|
||||
|
||||
## 开发命令
|
||||
|
||||
### 构建共享包
|
||||
|
||||
```bash
|
||||
npm run build:shared
|
||||
```
|
||||
|
||||
### 开发HTTP前端
|
||||
|
||||
```bash
|
||||
npm run dev:frontend
|
||||
```
|
||||
|
||||
### 开发IPC前端
|
||||
|
||||
```bash
|
||||
npm run dev:widget
|
||||
```
|
||||
|
||||
### 构建所有
|
||||
|
||||
```bash
|
||||
npm run build:all
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **环境变量**: HTTP前端需要配置`VITE_API_BASE_URL`环境变量
|
||||
2. **数据库配置**: IPC前端需要确保`.env`文件配置正确
|
||||
3. **类型定义**: 确保TypeScript配置正确引用共享包
|
||||
4. **组件导入**: 使用相对路径导入共享组件
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 问题:API调用失败
|
||||
|
||||
**解决方案**:
|
||||
1. 检查环境变量配置
|
||||
2. 确认后端服务运行状态
|
||||
3. 查看浏览器控制台错误信息
|
||||
|
||||
### 问题:组件样式不生效
|
||||
|
||||
**解决方案**:
|
||||
1. 确认组件作用域样式正确
|
||||
2. 检查CSS导入顺序
|
||||
3. 清除浏览器缓存
|
||||
|
||||
### 问题:环境检测错误
|
||||
|
||||
**解决方案**:
|
||||
1. 检查`window.go`对象是否存在
|
||||
2. 确认Wails运行时环境
|
||||
3. 使用`getEnvironment()`函数调试
|
||||
|
||||
## 扩展指南
|
||||
|
||||
### 添加新的API方法
|
||||
|
||||
1. 在`shared/api/bid-api.ts`中添加接口定义
|
||||
2. 在`shared/api/http-adapter.ts`中实现HTTP版本
|
||||
3. 在`shared/api/ipc-adapter.ts`中实现IPC版本
|
||||
4. 在`widget/looker/app.go`中添加Go后端方法(如需要)
|
||||
|
||||
### 添加新的共享组件
|
||||
|
||||
1. 在`shared/components/`目录创建新组件
|
||||
2. 使用`api` prop接收API实例
|
||||
3. 在`shared/components/index.ts`中导出组件
|
||||
4. 在前端项目中导入使用
|
||||
|
||||
## 技术支持
|
||||
|
||||
如有问题,请参考:
|
||||
- 技术方案文档: [`../plans/http-ipc-compatibility-plan.md`](../plans/http-ipc-compatibility-plan.md)
|
||||
- 项目README: [`../README.md`](../README.md)
|
||||
65
widget/looker/frontend/src/shared/api/api-factory.ts
Normal file
65
widget/looker/frontend/src/shared/api/api-factory.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* API工厂
|
||||
* 根据运行环境自动选择合适的API适配器
|
||||
*/
|
||||
import { BidAPI } from './bid-api'
|
||||
import { HTTPBidAPI } from './http-adapter'
|
||||
import { IPCBidAPI } from './ipc-adapter'
|
||||
|
||||
export class APIFactory {
|
||||
private static instance: BidAPI | null = null
|
||||
|
||||
/**
|
||||
* 获取API实例(单例模式)
|
||||
* 自动检测环境并返回对应的适配器
|
||||
*/
|
||||
static getInstance(): BidAPI {
|
||||
if (this.instance) {
|
||||
return this.instance
|
||||
}
|
||||
|
||||
// 检测环境
|
||||
if (typeof window !== 'undefined' && window.go) {
|
||||
// Wails环境(IPC)
|
||||
console.log('检测到Wails环境,使用IPC适配器')
|
||||
this.instance = new IPCBidAPI()
|
||||
} else {
|
||||
// HTTP环境
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
|
||||
console.log(`检测到HTTP环境,使用HTTP适配器: ${baseURL}`)
|
||||
this.instance = new HTTPBidAPI(baseURL)
|
||||
}
|
||||
|
||||
return this.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置API实例
|
||||
* 用于测试或切换环境
|
||||
*/
|
||||
static reset(): void {
|
||||
this.instance = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动设置API实例
|
||||
* 用于测试或特殊场景
|
||||
*/
|
||||
static setInstance(api: BidAPI): void {
|
||||
this.instance = api
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测当前环境类型
|
||||
*/
|
||||
static getEnvironment(): 'http' | 'ipc' {
|
||||
if (typeof window !== 'undefined' && window.go) {
|
||||
return 'ipc'
|
||||
}
|
||||
return 'http'
|
||||
}
|
||||
}
|
||||
|
||||
// 导出便捷函数
|
||||
export const getAPI = () => APIFactory.getInstance()
|
||||
export const getEnvironment = () => APIFactory.getEnvironment()
|
||||
35
widget/looker/frontend/src/shared/api/bid-api.ts
Normal file
35
widget/looker/frontend/src/shared/api/bid-api.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 统一API接口定义
|
||||
* 定义HTTP和IPC适配器需要实现的统一接口
|
||||
*/
|
||||
import type { BidItem, AiRecommendation, CrawlInfoStat, Keyword, BidQueryParams, PaginatedResponse } from '../models/bid-item'
|
||||
|
||||
/**
|
||||
* 投标API接口
|
||||
* 定义所有与投标相关的操作
|
||||
*/
|
||||
export interface BidAPI {
|
||||
// 投标相关
|
||||
getPinnedBids(): Promise<BidItem[]>
|
||||
getRecentBids(limit?: number): Promise<BidItem[]>
|
||||
getBidsByDateRange(startDate: string, endDate: string): Promise<BidItem[]>
|
||||
getBidsByParams(params: BidQueryParams): Promise<PaginatedResponse<BidItem>>
|
||||
updatePinStatus(title: string, pin: boolean): Promise<void>
|
||||
getSources(): Promise<string[]>
|
||||
|
||||
// AI推荐相关
|
||||
getAiRecommendations(): Promise<AiRecommendation[]>
|
||||
saveAiRecommendations(recommendations: AiRecommendation[]): Promise<void>
|
||||
getLatestRecommendations(): Promise<AiRecommendation[]>
|
||||
|
||||
// 关键词相关
|
||||
getKeywords(): Promise<Keyword[]>
|
||||
addKeyword(word: string, weight?: number): Promise<Keyword>
|
||||
deleteKeyword(id: string): Promise<void>
|
||||
|
||||
// 爬虫相关
|
||||
getCrawlStats(): Promise<CrawlInfoStat[]>
|
||||
runCrawler(): Promise<void>
|
||||
runCrawlerBySource(sourceName: string): Promise<void>
|
||||
getCrawlerStatus(): Promise<{ status: string; lastRun: string }>
|
||||
}
|
||||
125
widget/looker/frontend/src/shared/api/http-adapter.ts
Normal file
125
widget/looker/frontend/src/shared/api/http-adapter.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* HTTP适配器实现
|
||||
* 通过HTTP/REST API与NestJS后端通信
|
||||
*/
|
||||
import axios, { AxiosInstance, type AxiosRequestConfig } from 'axios'
|
||||
import type { BidAPI, BidItem, AiRecommendation, CrawlInfoStat, Keyword, BidQueryParams, PaginatedResponse } from './bid-api'
|
||||
|
||||
export class HTTPBidAPI implements BidAPI {
|
||||
private api: AxiosInstance
|
||||
private baseURL: string
|
||||
|
||||
constructor(baseURL: string = 'http://localhost:3000') {
|
||||
this.baseURL = baseURL
|
||||
this.api = axios.create({
|
||||
baseURL,
|
||||
timeout: 120000,
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
this.api.interceptors.request.use((config: AxiosRequestConfig) => {
|
||||
const isLocalhost = baseURL.includes('localhost') || baseURL.includes('127.0.0.1')
|
||||
if (!isLocalhost) {
|
||||
const apiKey = import.meta.env.VITE_API_KEY || localStorage.getItem('apiKey')
|
||||
if (apiKey && config.headers) {
|
||||
config.headers['X-API-Key'] = apiKey
|
||||
}
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// 响应拦截器
|
||||
this.api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
console.error('HTTP请求错误:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 投标相关
|
||||
async getPinnedBids(): Promise<BidItem[]> {
|
||||
const response = await this.api.get<BidItem[]>('/api/bids/pinned')
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getRecentBids(limit?: number): Promise<BidItem[]> {
|
||||
const response = await this.api.get<BidItem[]>('/api/bids/recent', {
|
||||
params: { limit }
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getBidsByDateRange(startDate: string, endDate: string): Promise<BidItem[]> {
|
||||
const response = await this.api.get<BidItem[]>('/api/bids/by-date-range', {
|
||||
params: { startDate, endDate }
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getBidsByParams(params: BidQueryParams): Promise<PaginatedResponse<BidItem>> {
|
||||
const response = await this.api.get<PaginatedResponse<BidItem>>('/api/bids', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async updatePinStatus(title: string, pin: boolean): Promise<void> {
|
||||
await this.api.patch(`/api/bids/${encodeURIComponent(title)}/pin`, { pin })
|
||||
}
|
||||
|
||||
async getSources(): Promise<string[]> {
|
||||
const response = await this.api.get<string[]>('/api/bids/sources')
|
||||
return response.data
|
||||
}
|
||||
|
||||
// AI推荐相关
|
||||
async getAiRecommendations(): Promise<AiRecommendation[]> {
|
||||
const response = await this.api.get<AiRecommendation[]>('/api/ai/latest-recommendations')
|
||||
return response.data
|
||||
}
|
||||
|
||||
async saveAiRecommendations(recommendations: AiRecommendation[]): Promise<void> {
|
||||
await this.api.post('/api/ai/save-recommendations', { recommendations })
|
||||
}
|
||||
|
||||
async getLatestRecommendations(): Promise<AiRecommendation[]> {
|
||||
const response = await this.api.get<AiRecommendation[]>('/api/ai/latest-recommendations')
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 关键词相关
|
||||
async getKeywords(): Promise<Keyword[]> {
|
||||
const response = await this.api.get<Keyword[]>('/api/keywords')
|
||||
return response.data
|
||||
}
|
||||
|
||||
async addKeyword(word: string, weight?: number): Promise<Keyword> {
|
||||
const response = await this.api.post<Keyword>('/api/keywords', { word, weight })
|
||||
return response.data
|
||||
}
|
||||
|
||||
async deleteKeyword(id: string): Promise<void> {
|
||||
await this.api.delete(`/api/keywords/${id}`)
|
||||
}
|
||||
|
||||
// 爬虫相关
|
||||
async getCrawlStats(): Promise<CrawlInfoStat[]> {
|
||||
const response = await this.api.get<CrawlInfoStat[]>('/api/bids/crawl-info-stats')
|
||||
return response.data
|
||||
}
|
||||
|
||||
async runCrawler(): Promise<void> {
|
||||
await this.api.post('/api/crawler/run')
|
||||
}
|
||||
|
||||
async runCrawlerBySource(sourceName: string): Promise<void> {
|
||||
await this.api.post(`/api/crawler/crawl/${sourceName}`)
|
||||
}
|
||||
|
||||
async getCrawlerStatus(): Promise<{ status: string; lastRun: string }> {
|
||||
const response = await this.api.get<{ status: string; lastRun: string }>('/api/crawler/status')
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
8
widget/looker/frontend/src/shared/api/index.ts
Normal file
8
widget/looker/frontend/src/shared/api/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* API层导出
|
||||
* 统一导出所有API相关的类型和类
|
||||
*/
|
||||
export { BidAPI } from './bid-api'
|
||||
export { HTTPBidAPI } from './http-adapter'
|
||||
export { IPCBidAPI } from './ipc-adapter'
|
||||
export { APIFactory, getAPI, getEnvironment } from './api-factory'
|
||||
146
widget/looker/frontend/src/shared/api/ipc-adapter.ts
Normal file
146
widget/looker/frontend/src/shared/api/ipc-adapter.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* IPC适配器实现
|
||||
* 通过Wails IPC与Go后端通信
|
||||
*/
|
||||
import type { BidAPI, BidItem, AiRecommendation, CrawlInfoStat, Keyword, BidQueryParams, PaginatedResponse } from './bid-api'
|
||||
|
||||
// Wails绑定类型定义
|
||||
declare global {
|
||||
interface Window {
|
||||
go: {
|
||||
main: {
|
||||
App: {
|
||||
GetPinnedBidItems(): Promise<BidItem[]>
|
||||
GetAiRecommendations(): Promise<AiRecommendation[]>
|
||||
GetCrawlInfoStats(): Promise<CrawlInfoStat[]>
|
||||
GetAllBids(): Promise<BidItem[]>
|
||||
UpdatePinStatus(title: string, pin: boolean): Promise<void>
|
||||
SaveAiRecommendations(recommendations: AiRecommendation[]): Promise<void>
|
||||
GetKeywords(): Promise<Keyword[]>
|
||||
AddKeyword(word: string, weight?: number): Promise<Keyword>
|
||||
DeleteKeyword(id: string): Promise<void>
|
||||
GetSources(): Promise<string[]>
|
||||
RunCrawler(): Promise<void>
|
||||
RunCrawlerBySource(sourceName: string): Promise<void>
|
||||
GetCrawlerStatus(): Promise<{ status: string; lastRun: string }>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class IPCBidAPI implements BidAPI {
|
||||
private app: any
|
||||
|
||||
constructor() {
|
||||
if (typeof window === 'undefined' || !window.go) {
|
||||
throw new Error('Wails environment not detected')
|
||||
}
|
||||
this.app = window.go.main.App
|
||||
}
|
||||
|
||||
// 投标相关
|
||||
async getPinnedBids(): Promise<BidItem[]> {
|
||||
return await this.app.GetPinnedBidItems()
|
||||
}
|
||||
|
||||
async getRecentBids(limit?: number): Promise<BidItem[]> {
|
||||
const allBids = await this.app.GetAllBids()
|
||||
return allBids.slice(0, limit || 20)
|
||||
}
|
||||
|
||||
async getBidsByDateRange(startDate: string, endDate: string): Promise<BidItem[]> {
|
||||
const allBids = await this.app.GetAllBids()
|
||||
return allBids.filter(bid => {
|
||||
const bidDate = new Date(bid.publishDate)
|
||||
return bidDate >= new Date(startDate) && bidDate <= new Date(endDate)
|
||||
})
|
||||
}
|
||||
|
||||
async getBidsByParams(params: BidQueryParams): Promise<PaginatedResponse<BidItem>> {
|
||||
let allBids = await this.app.GetAllBids()
|
||||
|
||||
// 应用筛选条件
|
||||
if (params.source) {
|
||||
allBids = allBids.filter(bid => bid.source === params.source)
|
||||
}
|
||||
|
||||
if (params.keyword) {
|
||||
const keyword = params.keyword.toLowerCase()
|
||||
allBids = allBids.filter(bid =>
|
||||
bid.title.toLowerCase().includes(keyword)
|
||||
)
|
||||
}
|
||||
|
||||
if (params.startDate && params.endDate) {
|
||||
allBids = allBids.filter(bid => {
|
||||
const bidDate = new Date(bid.publishDate)
|
||||
return bidDate >= new Date(params.startDate!) && bidDate <= new Date(params.endDate!)
|
||||
})
|
||||
}
|
||||
|
||||
// 分页处理
|
||||
const offset = params.offset || 0
|
||||
const limit = params.limit || 20
|
||||
const total = allBids.length
|
||||
const data = allBids.slice(offset, offset + limit)
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page: Math.floor(offset / limit) + 1,
|
||||
pageSize: limit
|
||||
}
|
||||
}
|
||||
|
||||
async updatePinStatus(title: string, pin: boolean): Promise<void> {
|
||||
await this.app.UpdatePinStatus(title, pin)
|
||||
}
|
||||
|
||||
async getSources(): Promise<string[]> {
|
||||
return await this.app.GetSources()
|
||||
}
|
||||
|
||||
// AI推荐相关
|
||||
async getAiRecommendations(): Promise<AiRecommendation[]> {
|
||||
return await this.app.GetAiRecommendations()
|
||||
}
|
||||
|
||||
async saveAiRecommendations(recommendations: AiRecommendation[]): Promise<void> {
|
||||
await this.app.SaveAiRecommendations(recommendations)
|
||||
}
|
||||
|
||||
async getLatestRecommendations(): Promise<AiRecommendation[]> {
|
||||
return await this.app.GetAiRecommendations()
|
||||
}
|
||||
|
||||
// 关键词相关
|
||||
async getKeywords(): Promise<Keyword[]> {
|
||||
return await this.app.GetKeywords()
|
||||
}
|
||||
|
||||
async addKeyword(word: string, weight?: number): Promise<Keyword> {
|
||||
return await this.app.AddKeyword(word, weight)
|
||||
}
|
||||
|
||||
async deleteKeyword(id: string): Promise<void> {
|
||||
await this.app.DeleteKeyword(id)
|
||||
}
|
||||
|
||||
// 爬虫相关
|
||||
async getCrawlStats(): Promise<CrawlInfoStat[]> {
|
||||
return await this.app.GetCrawlInfoStats()
|
||||
}
|
||||
|
||||
async runCrawler(): Promise<void> {
|
||||
await this.app.RunCrawler()
|
||||
}
|
||||
|
||||
async runCrawlerBySource(sourceName: string): Promise<void> {
|
||||
await this.app.RunCrawlerBySource(sourceName)
|
||||
}
|
||||
|
||||
async getCrawlerStatus(): Promise<{ status: string; lastRun: string }> {
|
||||
return await this.app.GetCrawlerStatus()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import type { AiRecommendation } from '../models/bid-item'
|
||||
|
||||
const props = defineProps<{
|
||||
api: any
|
||||
}>()
|
||||
|
||||
const recommendations = ref<AiRecommendation[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const loadRecommendations = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
recommendations.value = await props.api.getAiRecommendations()
|
||||
} 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">
|
||||
<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;
|
||||
}
|
||||
|
||||
.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>
|
||||
158
widget/looker/frontend/src/shared/components/BaseBidList.vue
Normal file
158
widget/looker/frontend/src/shared/components/BaseBidList.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import type { BidItem } from '../models/bid-item'
|
||||
|
||||
const props = defineProps<{
|
||||
api: any
|
||||
title?: string
|
||||
limit?: number
|
||||
showPin?: boolean
|
||||
}>()
|
||||
|
||||
const bidItems = ref<BidItem[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
if (props.limit) {
|
||||
bidItems.value = await props.api.getRecentBids(props.limit)
|
||||
} else {
|
||||
bidItems.value = await props.api.getPinnedBids()
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = `加载失败: ${err}`
|
||||
console.error('加载数据失败:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openUrl = (url: string) => {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
loadData
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bid-list-container">
|
||||
<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 v-if="showPin && item.pin" class="pin-badge">
|
||||
📌 已置顶
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bid-list-container {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.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;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.pin-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
}
|
||||
</style>
|
||||
142
widget/looker/frontend/src/shared/components/BaseCrawlInfo.vue
Normal file
142
widget/looker/frontend/src/shared/components/BaseCrawlInfo.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import type { CrawlInfoStat } from '../models/bid-item'
|
||||
|
||||
const props = defineProps<{
|
||||
api: any
|
||||
}>()
|
||||
|
||||
const crawlStats = ref<CrawlInfoStat[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const loadCrawlStats = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
crawlStats.value = await props.api.getCrawlStats()
|
||||
} 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>
|
||||
7
widget/looker/frontend/src/shared/components/index.ts
Normal file
7
widget/looker/frontend/src/shared/components/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 组件层导出
|
||||
* 统一导出所有共享组件
|
||||
*/
|
||||
export { default as BaseBidList } from './BaseBidList.vue'
|
||||
export { default as BaseAiRecommendations } from './BaseAiRecommendations.vue'
|
||||
export { default as BaseCrawlInfo } from './BaseCrawlInfo.vue'
|
||||
72
widget/looker/frontend/src/shared/models/bid-item.ts
Normal file
72
widget/looker/frontend/src/shared/models/bid-item.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 共享数据模型
|
||||
* 用于HTTP和IPC框架的统一数据结构定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* 投标项目数据模型
|
||||
*/
|
||||
export interface BidItem {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
publishDate: string
|
||||
source: string
|
||||
pin: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* AI推荐数据模型
|
||||
*/
|
||||
export interface AiRecommendation {
|
||||
id: string
|
||||
title: string
|
||||
confidence: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 爬虫统计信息数据模型
|
||||
*/
|
||||
export interface CrawlInfoStat {
|
||||
source: string
|
||||
count: number
|
||||
latestUpdate: string
|
||||
latestPublishDate: string
|
||||
error: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 关键词数据模型
|
||||
*/
|
||||
export interface Keyword {
|
||||
id: string
|
||||
word: string
|
||||
weight: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 投标查询参数
|
||||
*/
|
||||
export interface BidQueryParams {
|
||||
limit?: number
|
||||
offset?: number
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
source?: string
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页响应
|
||||
*/
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
19
widget/looker/frontend/src/shared/package.json
Normal file
19
widget/looker/frontend/src/shared/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@bidding/shared",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"exports": {
|
||||
"./models": "./models/bid-item.ts",
|
||||
"./api": "./api/index.ts",
|
||||
"./components": "./components/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.2",
|
||||
"vue": "^3.5.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user