feat: 添加用户认证系统

引入基于 Basic Auth 的用户认证系统,包括用户管理、登录界面和 API 鉴权
- 新增用户实体和管理功能
- 实现前端登录界面和凭证管理
- 重构 API 鉴权为 Basic Auth 模式
- 添加用户管理脚本工具
This commit is contained in:
dmy
2026-01-18 12:47:16 +08:00
parent a55dfd78d2
commit b6a6398864
30 changed files with 2042 additions and 82 deletions

View 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)

View 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()

View 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 }>
}

View 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
}
}

View 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'

View 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()
}
}

View File

@@ -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>

View 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>

View 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>

View 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'

View 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
}

View 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"
}
}