Compare commits

...

2 Commits

Author SHA1 Message Date
dmy
b6a6398864 feat: 添加用户认证系统
引入基于 Basic Auth 的用户认证系统,包括用户管理、登录界面和 API 鉴权
- 新增用户实体和管理功能
- 实现前端登录界面和凭证管理
- 重构 API 鉴权为 Basic Auth 模式
- 添加用户管理脚本工具
2026-01-18 12:47:16 +08:00
dmy
a55dfd78d2 feat: 更新部署配置和API类型定义
将部署配置更改为生产环境服务器设置
将AxiosRequestConfig替换为InternalAxiosRequestConfig
2026-01-18 11:17:12 +08:00
32 changed files with 2067 additions and 165 deletions

3
.env
View File

@@ -46,3 +46,6 @@ SSH_PASSPHRASE=x
API_KEY=22c64b60-6e60-433c-991d-f6d658024b9e API_KEY=22c64b60-6e60-433c-991d-f6d658024b9e
# 是否启用 Basic Auth 认证true/false
ENABLE_BASIC_AUTH=true

View File

@@ -27,3 +27,6 @@ LOG_LEVEL=info
ARK_API_KEY=your_openai_api_key_here ARK_API_KEY=your_openai_api_key_here
API_KEY=your_secure_api_key_here API_KEY=your_secure_api_key_here
# 是否启用 Basic Auth 认证true/false
ENABLE_BASIC_AUTH=true

View File

@@ -40,7 +40,8 @@
<el-container> <el-container>
<el-header style="text-align: right; font-size: 12px"> <el-header style="text-align: right; font-size: 12px">
<span>Admin</span> <span v-if="currentUser">{{ currentUser }}</span>
<el-button v-if="currentUser" type="primary" link @click="handleLogout">退出登录</el-button>
</el-header> </el-header>
<el-main> <el-main>
@@ -60,11 +61,26 @@
</el-main> </el-main>
</el-container> </el-container>
</el-container> </el-container>
<!-- 登录对话框 -->
<el-dialog v-model="loginDialogVisible" title="用户登录" width="400px" :close-on-click-modal="false" :show-close="false">
<el-form :model="loginForm" label-width="80px">
<el-form-item label="用户名">
<el-input v-model="loginForm.username" placeholder="请输入用户名" @keyup.enter="handleLogin" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="loginForm.password" type="password" placeholder="请输入密码" show-password @keyup.enter="handleLogin" />
</el-form-item>
</el-form>
<template #footer>
<el-button type="primary" @click="handleLogin" :loading="loginLoading">登录</el-button>
</template>
</el-dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import api from './utils/api' import api, { setAuthCredentials, clearAuthCredentials, isAuthenticated } from './utils/api'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { DataBoard, Document, Setting, MagicStick, Connection } from '@element-plus/icons-vue' import { DataBoard, Document, Setting, MagicStick, Connection } from '@element-plus/icons-vue'
import Dashboard from './components/Dashboard.vue' import Dashboard from './components/Dashboard.vue'
@@ -82,6 +98,15 @@ const isCrawling = ref(false)
const total = ref(0) const total = ref(0)
const sourceOptions = ref<string[]>([]) const sourceOptions = ref<string[]>([])
// 登录相关状态
const loginDialogVisible = ref(false)
const loginLoading = ref(false)
const loginForm = ref({
username: '',
password: ''
})
const currentUser = ref<string | null>(null)
const handleSelect = (key: string) => { const handleSelect = (key: string) => {
activeIndex.value = key activeIndex.value = key
} }
@@ -156,8 +181,85 @@ const updateBidsByDateRange = async (startDate: string, endDate?: string, keywor
} }
} }
onMounted(() => { // 处理登录
const handleLogin = async () => {
if (!loginForm.value.username || !loginForm.value.password) {
ElMessage.warning('请输入用户名和密码')
return
}
loginLoading.value = true
try {
// 保存凭证到 localStorage
setAuthCredentials(loginForm.value.username, loginForm.value.password)
// 测试凭证是否有效
await api.get('/api/bids', { params: { page: 1, limit: 1 } })
// 登录成功
currentUser.value = loginForm.value.username
loginDialogVisible.value = false
ElMessage.success('登录成功')
// 清空表单
loginForm.value.username = ''
loginForm.value.password = ''
// 加载数据
fetchData() fetchData()
} catch (error: any) {
console.error('登录失败:', error)
// 清除无效凭证
clearAuthCredentials()
if (error.response?.status === 401) {
ElMessage.error('用户名或密码错误')
} else {
ElMessage.error('登录失败,请稍后重试')
}
} finally {
loginLoading.value = false
}
}
// 处理登出
const handleLogout = () => {
clearAuthCredentials()
currentUser.value = null
ElMessage.success('已退出登录')
}
// 处理认证要求事件
const handleAuthRequired = () => {
loginDialogVisible.value = true
}
onMounted(() => {
// 检查是否已登录
if (isAuthenticated()) {
// 从凭证中提取用户名
const credentials = localStorage.getItem('authCredentials')
if (credentials) {
try {
const decoded = atob(credentials)
const [username] = decoded.split(':')
currentUser.value = username || null
fetchData()
} catch (e) {
console.error('解析凭证失败:', e)
clearAuthCredentials()
loginDialogVisible.value = true
}
}
} else {
loginDialogVisible.value = true
}
// 监听认证要求事件
window.addEventListener('auth-required', handleAuthRequired)
})
onUnmounted(() => {
window.removeEventListener('auth-required', handleAuthRequired)
}) })
</script> </script>

View File

@@ -1,4 +1,37 @@
import axios, { type AxiosRequestConfig, type AxiosError } from 'axios'; import axios, { type InternalAxiosRequestConfig, type AxiosError } from 'axios';
/**
* 认证相关工具函数
*/
/**
* 设置 Basic Auth 凭证到 localStorage
*/
export const setAuthCredentials = (username: string, password: string) => {
const credentials = btoa(`${username}:${password}`);
localStorage.setItem('authCredentials', credentials);
};
/**
* 从 localStorage 获取 Basic Auth 凭证
*/
export const getAuthCredentials = (): string | null => {
return localStorage.getItem('authCredentials');
};
/**
* 清除认证凭证
*/
export const clearAuthCredentials = () => {
localStorage.removeItem('authCredentials');
};
/**
* 检查是否已登录
*/
export const isAuthenticated = (): boolean => {
return !!localStorage.getItem('authCredentials');
};
/** /**
* API配置 * API配置
@@ -12,23 +45,11 @@ const api = axios.create({
// 请求拦截器 // 请求拦截器
api.interceptors.request.use( api.interceptors.request.use(
(config: AxiosRequestConfig) => { (config: InternalAxiosRequestConfig) => {
// 如果 baseURL 不是 localhost自动添加 API Key // 添加 Basic Auth 头
const baseURL = const credentials = getAuthCredentials();
(config.baseURL as string) || if (credentials && config.headers) {
(api.defaults.baseURL as string) || config.headers['Authorization'] = `Basic ${credentials}`;
'';
const isLocalhost =
baseURL.includes('localhost') || baseURL.includes('127.0.0.1');
if (!isLocalhost) {
// 从环境变量或 localStorage 获取 API Key
const apiKey =
(import.meta.env.VITE_API_KEY as string) ||
localStorage.getItem('apiKey');
if (apiKey && config.headers) {
config.headers['X-API-Key'] = apiKey;
}
} }
return config; return config;
@@ -44,6 +65,13 @@ api.interceptors.response.use(
return response; return response;
}, },
(error: AxiosError) => { (error: AxiosError) => {
// 处理 401 未授权错误
if (error.response?.status === 401) {
// 清除无效的凭证
clearAuthCredentials();
// 触发自定义事件,通知应用需要重新登录
window.dispatchEvent(new CustomEvent('auth-required'));
}
console.error('API请求错误:', error); console.error('API请求错误:', error);
return Promise.reject(error); return Promise.reject(error);
}, },

View File

@@ -24,6 +24,9 @@
"ai-recommendations": "ts-node -r tsconfig-paths/register src/scripts/ai-recommendations.ts", "ai-recommendations": "ts-node -r tsconfig-paths/register src/scripts/ai-recommendations.ts",
"sync": "ts-node -r tsconfig-paths/register src/scripts/sync.ts", "sync": "ts-node -r tsconfig-paths/register src/scripts/sync.ts",
"deploy": "ts-node src/scripts/deploy.ts", "deploy": "ts-node src/scripts/deploy.ts",
"user:create": "ts-node -r tsconfig-paths/register src/scripts/create-user.ts",
"user:list": "ts-node -r tsconfig-paths/register src/scripts/list-users.ts",
"user:delete": "ts-node -r tsconfig-paths/register src/scripts/delete-user.ts",
"electron:dev": "chcp 65001 >nul 2>&1 & npm run -prefix frontend build && npm run build && set NODE_ENV=development && electron ./app", "electron:dev": "chcp 65001 >nul 2>&1 & npm run -prefix frontend build && npm run build && set NODE_ENV=development && electron ./app",
"electron:build": "npm run -prefix frontend build && npm run build && electron-builder --config ./app/electron-builder.json" "electron:build": "npm run -prefix frontend build && npm run build && electron-builder --config ./app/electron-builder.json"
}, },
@@ -36,6 +39,7 @@
"@nestjs/serve-static": "^5.0.4", "@nestjs/serve-static": "^5.0.4",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
@@ -57,6 +61,7 @@
"@nestjs/cli": "^11.0.14", "@nestjs/cli": "^11.0.14",
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/cacheable-request": "^6.0.3", "@types/cacheable-request": "^6.0.3",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",

View File

@@ -12,6 +12,7 @@ import { LoggerModule } from './common/logger/logger.module';
import { LoggingMiddleware } from './common/logger/logging.middleware'; import { LoggingMiddleware } from './common/logger/logging.middleware';
import { AiModule } from './ai/ai.module'; import { AiModule } from './ai/ai.module';
import { AuthModule } from './common/auth/auth.module'; import { AuthModule } from './common/auth/auth.module';
import { UsersModule } from './users/users.module';
@Module({ @Module({
imports: [ imports: [
@@ -23,6 +24,7 @@ import { AuthModule } from './common/auth/auth.module';
}), }),
LoggerModule, LoggerModule,
AuthModule, AuthModule,
UsersModule,
DatabaseModule, DatabaseModule,
BidsModule, BidsModule,
KeywordsModule, KeywordsModule,

View File

@@ -6,78 +6,55 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Request } from 'express'; import { Request } from 'express';
import { UsersService } from '../../users/users.service';
@Injectable() @Injectable()
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
constructor(private configService: ConfigService) {} constructor(
private configService: ConfigService,
private usersService: UsersService,
) {}
canActivate(context: ExecutionContext): boolean { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>(); const request = context.switchToHttp().getRequest<Request>();
const clientIp = this.getClientIp(request);
// 检查是否为本地 IP // 检查是否启用 Basic Auth
if (this.isLocalIp(clientIp)) { const enableBasicAuth =
return true; // 本地访问直接放行 this.configService.get<string>('ENABLE_BASIC_AUTH') === 'true';
}
// 公网访问需要验证 API Key if (!enableBasicAuth) {
const apiKey = request.headers['x-api-key'] as string; // 如果未启用 Basic Auth允许所有访问
const expectedApiKey = this.configService.get<string>('API_KEY');
if (!expectedApiKey) {
// 如果未配置 API_KEY允许所有访问开发环境
return true; return true;
} }
if (!apiKey || apiKey !== expectedApiKey) { // 解析 Authorization header
throw new UnauthorizedException('Invalid or missing API Key'); const authHeader = request.headers['authorization'] as string;
if (!authHeader || !authHeader.startsWith('Basic ')) {
throw new UnauthorizedException('Missing or invalid Authorization header');
} }
// 解码 Basic Auth
const base64Credentials = authHeader.split(' ')[1];
const credentials = Buffer.from(base64Credentials, 'base64').toString(
'utf-8',
);
const [username, password] = credentials.split(':');
if (!username || !password) {
throw new UnauthorizedException('Invalid credentials format');
}
// 验证用户
const user = await this.usersService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException('Invalid username or password');
}
// 将用户信息附加到请求对象
(request as any).user = user;
return true; return true;
} }
/**
* 获取客户端真实 IP
* 支持反向代理场景X-Forwarded-For
*/
private getClientIp(request: Request): string {
const forwardedFor = request.headers['x-forwarded-for'];
if (forwardedFor) {
// X-Forwarded-For 可能包含多个 IP取第一个
const ips = (forwardedFor as string).split(',');
return ips[0].trim();
}
return request.ip || request.socket.remoteAddress || 'unknown';
}
/**
* 判断是否为本地 IP
*/
private isLocalIp(ip: string): boolean {
if (!ip || ip === 'unknown') {
return false;
}
// IPv4 本地地址
if (ip === '127.0.0.1' || ip === 'localhost' || ip.startsWith('127.')) {
return true;
}
// IPv6 本地地址
if (ip === '::1' || ip === '::ffff:127.0.0.1') {
return true;
}
// 内网地址(可选,根据需求决定是否允许)
// 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
const privateIpRegex =
/^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/;
if (privateIpRegex.test(ip)) {
// 如果需要内网也免鉴权,可以返回 true
// 这里默认返回 false内网也需要鉴权
return false;
}
return false;
}
} }

View File

@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { AuthGuard } from './auth.guard'; import { AuthGuard } from './auth.guard';
import { UsersModule } from '../../users/users.module';
@Module({ @Module({
imports: [UsersModule],
providers: [ providers: [
{ {
provide: APP_GUARD, provide: APP_GUARD,

View File

@@ -1,6 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { User } from '../users/entities/user.entity';
import { BidItem } from '../bids/entities/bid-item.entity';
import { CrawlInfoAdd } from '../crawler/entities/crawl-info-add.entity';
import { AiRecommendation } from '../ai/entities/ai-recommendation.entity';
import { Keyword } from '../keywords/keyword.entity';
@Module({ @Module({
imports: [ imports: [
@@ -18,11 +23,19 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
username: configService.get<string>('DATABASE_USERNAME', 'root'), username: configService.get<string>('DATABASE_USERNAME', 'root'),
password: configService.get<string>('DATABASE_PASSWORD', 'root'), password: configService.get<string>('DATABASE_PASSWORD', 'root'),
database: configService.get<string>('DATABASE_NAME', 'bidding'), database: configService.get<string>('DATABASE_NAME', 'bidding'),
entities: [__dirname + '/../**/*.entity{.ts,.js}'], entities: [
User,
BidItem,
CrawlInfoAdd,
AiRecommendation,
Keyword,
__dirname + '/../**/*.entity{.ts,.js}',
],
synchronize: false, synchronize: false,
timezone: 'Z', timezone: 'Z',
}), }),
}), }),
], ],
exports: [TypeOrmModule],
}) })
export class DatabaseModule {} export class DatabaseModule {}

100
src/scripts/create-user.ts Normal file
View File

@@ -0,0 +1,100 @@
import 'dotenv/config';
import { DataSource, DataSourceOptions } from 'typeorm';
import { User } from '../users/entities/user.entity';
import * as bcrypt from 'bcrypt';
// 数据库配置
const dbConfig: DataSourceOptions = {
type: (process.env.DATABASE_TYPE as any) || 'mysql',
host: process.env.DATABASE_HOST || 'localhost',
port: parseInt(process.env.DATABASE_PORT || '3306'),
username: process.env.DATABASE_USERNAME || 'root',
password: process.env.DATABASE_PASSWORD || 'root',
database: process.env.DATABASE_NAME || 'bidding',
entities: [User],
synchronize: false,
};
// 日志工具
const logger = {
log: (message: string, ...args: any[]) => {
console.log(`[INFO] ${new Date().toISOString()} - ${message}`, ...args);
},
error: (message: string, ...args: any[]) => {
console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, ...args);
},
};
// 主函数
async function createUser() {
let dataSource: DataSource | null = null;
try {
// 从命令行参数获取用户名和密码
const args = process.argv.slice(2);
if (args.length < 2) {
console.log('用法: npm run user:create <用户名> <密码>');
console.log('示例: npm run user:create admin password123');
process.exit(1);
}
const username = args[0];
const password = args[1];
if (!username || username.trim().length === 0) {
logger.error('用户名不能为空');
process.exit(1);
}
if (!password || password.length === 0) {
logger.error('密码不能为空');
process.exit(1);
}
logger.log('开始创建用户...');
// 创建数据库连接
dataSource = new DataSource(dbConfig);
await dataSource.initialize();
logger.log('数据库连接成功');
// 检查用户名是否已存在
const userRepository = dataSource.getRepository(User);
const existingUser = await userRepository.findOne({
where: { username },
});
if (existingUser) {
logger.error(`用户名 ${username} 已存在`);
process.exit(1);
}
// 加密密码
const hashedPassword = await bcrypt.hash(password, 10);
// 创建用户
const user = userRepository.create({
username,
password: hashedPassword,
});
await userRepository.save(user);
logger.log(`用户 ${username} 创建成功!`);
logger.log(`用户 ID: ${user.id}`);
logger.log(`创建时间: ${user.createdAt}`);
await dataSource.destroy();
process.exit(0);
} catch (error) {
logger.error('创建用户失败:', error);
if (dataSource && dataSource.isInitialized) {
await dataSource.destroy();
}
process.exit(1);
}
}
// 执行创建用户
createUser();

View File

@@ -0,0 +1,78 @@
import 'dotenv/config';
import { DataSource, DataSourceOptions } from 'typeorm';
import { User } from '../users/entities/user.entity';
// 数据库配置
const dbConfig: DataSourceOptions = {
type: (process.env.DATABASE_TYPE as any) || 'mysql',
host: process.env.DATABASE_HOST || 'localhost',
port: parseInt(process.env.DATABASE_PORT || '3306'),
username: process.env.DATABASE_USERNAME || 'root',
password: process.env.DATABASE_PASSWORD || 'root',
database: process.env.DATABASE_NAME || 'bidding',
entities: [User],
synchronize: false,
};
// 日志工具
const logger = {
log: (message: string, ...args: any[]) => {
console.log(`[INFO] ${new Date().toISOString()} - ${message}`, ...args);
},
error: (message: string, ...args: any[]) => {
console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, ...args);
},
};
// 主函数
async function deleteUser() {
let dataSource: DataSource | null = null;
try {
// 获取命令行参数
const usernameToDelete = process.argv[2];
if (!usernameToDelete) {
logger.error('请提供要删除的用户名');
console.log('用法: npm run user:delete <username>');
console.log('示例: npm run user:delete testuser');
process.exit(1);
}
logger.log(`开始删除用户: ${usernameToDelete}...`);
// 创建数据库连接
dataSource = new DataSource(dbConfig);
await dataSource.initialize();
logger.log('数据库连接成功');
// 获取用户
const userRepository = dataSource.getRepository(User);
const user = await userRepository.findOne({
where: { username: usernameToDelete },
});
if (!user) {
logger.error(`用户 "${usernameToDelete}" 不存在`);
await dataSource.destroy();
process.exit(1);
}
// 删除用户
await userRepository.delete(user.id);
logger.log(`用户 "${usernameToDelete}" 删除成功!`);
await dataSource.destroy();
process.exit(0);
} catch (error) {
logger.error('删除用户失败:', error);
if (dataSource && dataSource.isInitialized) {
await dataSource.destroy();
}
process.exit(1);
}
}
// 执行删除用户
deleteUser();

View File

@@ -1,73 +0,0 @@
# Deploy script - Upload files to remote server using scp
# Configuration
$remoteHost = "127.0.0.1"
$remotePort = "1122"
$remoteUser = "cubie"
$keyPath = "d:\163"
$serverDest = "/home/cubie/down/document/bidding/publish/server"
$frontendDest = "/home/cubie/down/document/bidding/publish/frontend"
$srcDest = "/home/cubie/down/document/bidding/"
# Check if key file exists
if (-not (Test-Path $keyPath)) {
Write-Error "Private key file not found: $keyPath"
exit 1
}
# Check if dist directory exists
if (-not (Test-Path "dist")) {
Write-Error "dist directory not found, please run npm run build first"
exit 1
}
# Check if frontend directory exists
if (-not (Test-Path "frontend")) {
Write-Error "frontend directory not found"
exit 1
}
# Check if src directory exists
if (-not (Test-Path "src")) {
Write-Error "src directory not found"
exit 1
}
Write-Host "Starting deployment..." -ForegroundColor Green
Write-Host "Remote server: ${remoteHost}:${remotePort}" -ForegroundColor Cyan
Write-Host "Private key: $keyPath" -ForegroundColor Cyan
# Upload dist directory contents to server directory
Write-Host "`nUploading dist directory to ${serverDest}..." -ForegroundColor Yellow
scp -i $keyPath -P $remotePort -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -r dist/* "${remoteUser}@${remoteHost}:${serverDest}"
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to upload dist directory"
exit 1
}
Write-Host "dist directory uploaded successfully" -ForegroundColor Green
# Upload entire frontend directory to publish directory
Write-Host "`nUploading frontend directory to ${frontendDest}..." -ForegroundColor Yellow
scp -i $keyPath -P $remotePort -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -r frontend/dist "${remoteUser}@${remoteHost}:${frontendDest}"
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to upload frontend directory"
exit 1
}
Write-Host "frontend directory uploaded successfully" -ForegroundColor Green
# Upload entire src directory to bidding directory
Write-Host "`nUploading src directory to ${srcDest}..." -ForegroundColor Yellow
scp -i $keyPath -P $remotePort -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -r src "${remoteUser}@${remoteHost}:${srcDest}"
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to upload src directory"
exit 1
}
Write-Host "src directory uploaded successfully" -ForegroundColor Green
Write-Host "`nDeployment completed!" -ForegroundColor Green

View File

@@ -12,21 +12,36 @@ import * as dotenv from 'dotenv';
dotenv.config(); dotenv.config();
// Configuration // Configuration
// const config = {
// host: '127.0.0.1',
// port: 1122,
// username: 'cubie',
// privateKey: fs.readFileSync('d:\\163'),
// passphrase: process.env.SSH_PASSPHRASE || '',
// };
const config = { const config = {
host: '127.0.0.1', host: '139.180.190.142',
port: 1122, port: 2211,
username: 'cubie', username: 'root',
privateKey: fs.readFileSync('d:\\163'), privateKey: fs.readFileSync('d:\\163'),
passphrase: process.env.SSH_PASSPHRASE || '', passphrase: process.env.SSH_PASSPHRASE || '',
}; };
const destinations = { // const destinations = {
server: '/home/cubie/down/document/bidding/publish/server', // server: '/home/cubie/down/document/bidding/publish/server',
frontend: '/home/cubie/down/document/bidding/publish/frontend', // frontend: '/home/cubie/down/document/bidding/publish/frontend',
src: '/home/cubie/down/document/bidding/', // src: '/home/cubie/down/document/bidding/',
}; // };
const destinations = {
server: '/root/bidding/publish/server',
frontend: '/root/bidding/publish/frontend',
src: '/root/bidding/',
};
async function uploadDirectory( async function uploadDirectory(
sftp: SftpClient, sftp: SftpClient,
localPath: string, localPath: string,

View File

@@ -0,0 +1,55 @@
import 'dotenv/config';
import { DataSource, DataSourceOptions } from 'typeorm';
import { User } from '../users/entities/user.entity';
// 数据库配置
const dbConfig: DataSourceOptions = {
type: (process.env.DATABASE_TYPE as any) || 'mysql',
host: process.env.DATABASE_HOST || 'localhost',
port: parseInt(process.env.DATABASE_PORT || '3306'),
username: process.env.DATABASE_USERNAME || 'root',
password: process.env.DATABASE_PASSWORD || 'root',
database: process.env.DATABASE_NAME || 'bidding',
entities: [User],
synchronize: true, // 启用自动同步以创建表
};
// 日志工具
const logger = {
log: (message: string, ...args: any[]) => {
console.log(`[INFO] ${new Date().toISOString()} - ${message}`, ...args);
},
error: (message: string, ...args: any[]) => {
console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, ...args);
},
};
// 主函数
async function initUsersTable() {
let dataSource: DataSource | null = null;
try {
logger.log('开始初始化 users 表...');
// 创建数据库连接
dataSource = new DataSource(dbConfig);
await dataSource.initialize();
logger.log('数据库连接成功');
// 同步表结构
await dataSource.synchronize();
logger.log('users 表创建成功!');
await dataSource.destroy();
process.exit(0);
} catch (error) {
logger.error('初始化 users 表失败:', error);
if (dataSource && dataSource.isInitialized) {
await dataSource.destroy();
}
process.exit(1);
}
}
// 执行初始化
initUsersTable();

72
src/scripts/list-users.ts Normal file
View File

@@ -0,0 +1,72 @@
import 'dotenv/config';
import { DataSource, DataSourceOptions } from 'typeorm';
import { User } from '../users/entities/user.entity';
// 数据库配置
const dbConfig: DataSourceOptions = {
type: (process.env.DATABASE_TYPE as any) || 'mysql',
host: process.env.DATABASE_HOST || 'localhost',
port: parseInt(process.env.DATABASE_PORT || '3306'),
username: process.env.DATABASE_USERNAME || 'root',
password: process.env.DATABASE_PASSWORD || 'root',
database: process.env.DATABASE_NAME || 'bidding',
entities: [User],
synchronize: false,
};
// 日志工具
const logger = {
log: (message: string, ...args: any[]) => {
console.log(`[INFO] ${new Date().toISOString()} - ${message}`, ...args);
},
error: (message: string, ...args: any[]) => {
console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, ...args);
},
};
// 主函数
async function listUsers() {
let dataSource: DataSource | null = null;
try {
logger.log('开始获取用户列表...');
// 创建数据库连接
dataSource = new DataSource(dbConfig);
await dataSource.initialize();
logger.log('数据库连接成功');
// 获取所有用户
const userRepository = dataSource.getRepository(User);
const users = await userRepository.find();
if (users.length === 0) {
logger.log('当前没有用户');
} else {
logger.log(`共有 ${users.length} 个用户:`);
console.log('');
console.log('ID | 用户名 | 创建时间 | 更新时间');
console.log('------------------------------------|-------------|------------------------|------------------------');
users.forEach((user) => {
const id = user.id;
const username = user.username.padEnd(11);
const createdAt = user.createdAt.toISOString().replace('T', ' ').substring(0, 19);
const updatedAt = user.updatedAt.toISOString().replace('T', ' ').substring(0, 19);
console.log(`${id} | ${username} | ${createdAt} | ${updatedAt}`);
});
console.log('');
}
await dataSource.destroy();
process.exit(0);
} catch (error) {
logger.error('获取用户列表失败:', error);
if (dataSource && dataSource.isInitialized) {
await dataSource.destroy();
}
process.exit(1);
}
}
// 执行列出用户
listUsers();

View File

@@ -0,0 +1,25 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
username: string;
@Column()
password: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

11
src/users/users.module.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { UsersService } from './users.service';
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

112
src/users/users.service.ts Normal file
View File

@@ -0,0 +1,112 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from './entities/user.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
/**
* 创建新用户
* @param username 用户名
* @param password 明文密码
* @returns 创建的用户
*/
async createUser(username: string, password: string): Promise<User> {
// 检查用户名是否已存在
const existingUser = await this.userRepository.findOne({
where: { username },
});
if (existingUser) {
throw new Error(`用户名 ${username} 已存在`);
}
// 加密密码
const hashedPassword = await bcrypt.hash(password, 10);
// 创建用户
const user = this.userRepository.create({
username,
password: hashedPassword,
});
return await this.userRepository.save(user);
}
/**
* 验证用户名和密码
* @param username 用户名
* @param password 明文密码
* @returns 验证成功返回用户对象,失败返回 null
*/
async validateUser(
username: string,
password: string,
): Promise<User | null> {
const user = await this.userRepository.findOne({
where: { username },
});
if (!user) {
return null;
}
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return null;
}
return user;
}
/**
* 获取所有用户(不包含密码)
* @returns 用户列表
*/
async findAll(): Promise<Omit<User, 'password'>[]> {
const users = await this.userRepository.find();
return users.map((user) => {
const { password, ...userWithoutPassword } = user;
return userWithoutPassword;
});
}
/**
* 根据用户名查找用户
* @param username 用户名
* @returns 用户对象(不包含密码)
*/
async findByUsername(
username: string,
): Promise<Omit<User, 'password'> | null> {
const user = await this.userRepository.findOne({
where: { username },
});
if (!user) {
return null;
}
const { password, ...userWithoutPassword } = user;
return userWithoutPassword;
}
/**
* 删除用户
* @param id 用户 ID
*/
async deleteUser(id: string): Promise<void> {
await this.userRepository.delete(id);
}
/**
* 根据用户名删除用户
* @param username 用户名
*/
async deleteUserByUsername(username: string): Promise<void> {
await this.userRepository.delete({ username });
}
}

View File

@@ -0,0 +1,10 @@
{
"version" : "1.0",
"configurations" : [
{
"customPlaygroundType" : "device",
"playground" : "standard",
"type" : "uni-app:app-android"
}
]
}

197
uni-app-version/README.md Normal file
View File

@@ -0,0 +1,197 @@
# 投标项目查看器 - uni-app 版本
这是一个基于 uni-app 开发的投标项目查看器,仿造了 Wails 桌面应用的功能。
## 技术栈
-**Vue 3** - 使用 Composition API
- 🎨 **Tailwind CSS** - 原子化 CSS 框架
- 📘 **TypeScript** - 类型安全
- 🔧 **Vite** - 快速构建工具
- 🌍 **uni-app** - 跨平台框架
## 功能特性
- 📌 **置顶项目** - 查看置顶的投标项目
- 🤖 **AI 推荐** - 查看 AI 推荐的项目
- 📊 **爬虫状态** - 查看各数据源的爬虫状态
- 🔄 **自动刷新** - 可配置的自动刷新间隔
- 🎯 **环境变量** - 支持 .env 配置
## 项目结构
```
uni-app-version/
├── pages/ # 页面目录
│ └── index/ # 主页面
├── components/ # 组件目录
│ ├── PinnedBids.vue
│ ├── AiRecommendations.vue
│ └── CrawlInfo.vue
├── utils/ # 工具类
│ └── api.js # API 请求封装
├── static/ # 静态资源
├── App.vue # 应用入口
├── main.js # 入口文件
├── manifest.json # 应用配置
├── pages.json # 页面路由配置
└── uni.scss # 全局样式变量
```
## 开发说明
### 环境要求
- Node.js >= 14
- HBuilderX 或 uni-app CLI
### 安装依赖
项目已配置 `.npmrc` 文件处理依赖版本兼容性问题,直接安装即可:
```bash
npm install
```
如果遇到依赖冲突,可以使用:
```bash
npm install --legacy-peer-deps
```
### 配置环境变量
项目使用 `.env` 文件管理环境变量。复制 `.env` 文件并根据需要修改:
```bash
# .env
VITE_API_BASE_URL=http://localhost:3000
VITE_API_KEY=your_secure_api_key_here # 公网访问时需要的 API Key可选
VITE_APP_TITLE=投标项目查看器
VITE_APP_VERSION=1.0.0
VITE_AUTO_REFRESH_INTERVAL=300000
```
- `.env` - 默认配置
- `.env.development` - 开发环境配置
- `.env.production` - 生产环境配置
#### API Key 配置说明
`VITE_API_BASE_URL` 配置为公网地址(非 localhost应用会自动在请求头中添加 `X-API-Key`
**配置方式:**
1. **环境变量(推荐)**:在 `.env``.env.production` 中设置 `VITE_API_KEY`
2. **本地存储**:应用会自动从 `uni.getStorageSync('apiKey')` 读取(如果环境变量未设置)
**注意事项:**
- 本地访问localhost 或 127.0.0.1)不需要 API Key会自动放行
- 公网访问必须提供正确的 API Key否则会返回 401 错误
- API Key 应该与后端配置的 `API_KEY` 环境变量一致
### 运行项目
#### H5 开发
```bash
npm run dev:h5
```
#### 微信小程序开发
```bash
npm run dev:mp-weixin
```
### 构建项目
#### H5 构建
```bash
npm run build:h5
```
#### 微信小程序构建
```bash
npm run build:mp-weixin
```
## API 接口
应用需要以下后端 API 接口:
- `GET /api/bids/pinned` - 获取置顶投标项目
- `GET /api/ai/latest-recommendations` - 获取 AI 推荐
- `GET /api/bids/crawl-info-stats` - 获取爬虫统计信息
## 功能说明
### 自动刷新
应用每 5 分钟自动刷新当前标签页的数据。
### 手动刷新
点击右上角的"🔄 刷新"按钮可以手动刷新当前标签页的数据。
### 下拉刷新
在主页面下拉可以刷新当前标签页的数据(需要启用下拉刷新功能)。
## 注意事项
1. 确保后端 API 服务已启动并运行在配置的地址上
2. 如果遇到跨域问题,需要在后端配置 CORS
3. 在非 H5 平台,点击链接会复制到剪贴板而不是直接打开
4. **公网访问**:如果后端部署在公网,需要配置 `VITE_API_KEY` 环境变量,确保与后端的 `API_KEY` 一致
5. **本地访问**:访问 localhost 或 127.0.0.1 时不需要 API Key会自动放行
## 项目特点
### TypeScript 支持
所有代码使用 TypeScript 编写,提供完整的类型定义:
```typescript
// API 请求类型安全
export interface BidItem {
id: string
title: string
url: string
publishDate: string
source: string
pin: boolean
createdAt: string
updatedAt: string
}
```
### Tailwind CSS
使用 Tailwind CSS 进行样式开发,提供一致的设计系统:
```vue
<view class="bg-white rounded-lg p-4 hover:shadow-lg">
<!-- 内容 -->
</view>
```
### 环境变量
通过 Vite 的环境变量系统,支持多环境配置:
```typescript
// 在代码中访问环境变量
const apiUrl = import.meta.env.VITE_API_BASE_URL
const apiKey = import.meta.env.VITE_API_KEY // API Key可选
```
### API 鉴权
应用支持自动 API Key 鉴权:
- **本地访问**:访问 `localhost``127.0.0.1` 时,不会添加 API Key
- **公网访问**:访问公网地址时,自动从环境变量或本地存储读取 API Key 并添加到请求头 `X-API-Key`
- **配置方式**:通过环境变量 `VITE_API_KEY` 或本地存储 `apiKey` 配置

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