Compare commits
2 Commits
810a420a46
...
b6a6398864
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6a6398864 | ||
|
|
a55dfd78d2 |
3
.env
3
.env
@@ -46,3 +46,6 @@ SSH_PASSPHRASE=x
|
||||
|
||||
API_KEY=22c64b60-6e60-433c-991d-f6d658024b9e
|
||||
|
||||
# 是否启用 Basic Auth 认证(true/false)
|
||||
ENABLE_BASIC_AUTH=true
|
||||
|
||||
|
||||
@@ -27,3 +27,6 @@ LOG_LEVEL=info
|
||||
ARK_API_KEY=your_openai_api_key_here
|
||||
|
||||
API_KEY=your_secure_api_key_here
|
||||
|
||||
# 是否启用 Basic Auth 认证(true/false)
|
||||
ENABLE_BASIC_AUTH=true
|
||||
@@ -40,7 +40,8 @@
|
||||
|
||||
<el-container>
|
||||
<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-main>
|
||||
@@ -60,11 +61,26 @@
|
||||
</el-main>
|
||||
</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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from './utils/api'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import api, { setAuthCredentials, clearAuthCredentials, isAuthenticated } from './utils/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { DataBoard, Document, Setting, MagicStick, Connection } from '@element-plus/icons-vue'
|
||||
import Dashboard from './components/Dashboard.vue'
|
||||
@@ -82,6 +98,15 @@ const isCrawling = ref(false)
|
||||
const total = ref(0)
|
||||
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) => {
|
||||
activeIndex.value = key
|
||||
}
|
||||
@@ -156,8 +181,85 @@ const updateBidsByDateRange = async (startDate: string, endDate?: string, keywor
|
||||
}
|
||||
}
|
||||
|
||||
// 处理登录
|
||||
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()
|
||||
} 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(() => {
|
||||
fetchData()
|
||||
// 检查是否已登录
|
||||
if (isAuthenticated()) {
|
||||
// 从凭证中提取用户名
|
||||
const credentials = localStorage.getItem('authCredentials')
|
||||
if (credentials) {
|
||||
try {
|
||||
const decoded = atob(credentials)
|
||||
const [username] = decoded.split(':')
|
||||
currentUser.value = username || null
|
||||
fetchData()
|
||||
} catch (e) {
|
||||
console.error('解析凭证失败:', e)
|
||||
clearAuthCredentials()
|
||||
loginDialogVisible.value = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
loginDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 监听认证要求事件
|
||||
window.addEventListener('auth-required', handleAuthRequired)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('auth-required', handleAuthRequired)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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配置
|
||||
@@ -12,23 +45,11 @@ const api = axios.create({
|
||||
|
||||
// 请求拦截器
|
||||
api.interceptors.request.use(
|
||||
(config: AxiosRequestConfig) => {
|
||||
// 如果 baseURL 不是 localhost,自动添加 API Key
|
||||
const baseURL =
|
||||
(config.baseURL as string) ||
|
||||
(api.defaults.baseURL as string) ||
|
||||
'';
|
||||
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;
|
||||
}
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// 添加 Basic Auth 头
|
||||
const credentials = getAuthCredentials();
|
||||
if (credentials && config.headers) {
|
||||
config.headers['Authorization'] = `Basic ${credentials}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
@@ -44,6 +65,13 @@ api.interceptors.response.use(
|
||||
return response;
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
// 处理 401 未授权错误
|
||||
if (error.response?.status === 401) {
|
||||
// 清除无效的凭证
|
||||
clearAuthCredentials();
|
||||
// 触发自定义事件,通知应用需要重新登录
|
||||
window.dispatchEvent(new CustomEvent('auth-required'));
|
||||
}
|
||||
console.error('API请求错误:', error);
|
||||
return Promise.reject(error);
|
||||
},
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
"ai-recommendations": "ts-node -r tsconfig-paths/register src/scripts/ai-recommendations.ts",
|
||||
"sync": "ts-node -r tsconfig-paths/register src/scripts/sync.ts",
|
||||
"deploy": "ts-node src/scripts/deploy.ts",
|
||||
"user:create": "ts-node -r tsconfig-paths/register src/scripts/create-user.ts",
|
||||
"user:list": "ts-node -r tsconfig-paths/register src/scripts/list-users.ts",
|
||||
"user:delete": "ts-node -r tsconfig-paths/register src/scripts/delete-user.ts",
|
||||
"electron:dev": "chcp 65001 >nul 2>&1 & npm run -prefix frontend build && npm run build && set NODE_ENV=development && electron ./app",
|
||||
"electron:build": "npm run -prefix frontend build && npm run build && electron-builder --config ./app/electron-builder.json"
|
||||
},
|
||||
@@ -36,6 +39,7 @@
|
||||
"@nestjs/serve-static": "^5.0.4",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"axios": "^1.13.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"dotenv": "^16.4.7",
|
||||
@@ -57,6 +61,7 @@
|
||||
"@nestjs/cli": "^11.0.14",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/cacheable-request": "^6.0.3",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
|
||||
@@ -12,6 +12,7 @@ import { LoggerModule } from './common/logger/logger.module';
|
||||
import { LoggingMiddleware } from './common/logger/logging.middleware';
|
||||
import { AiModule } from './ai/ai.module';
|
||||
import { AuthModule } from './common/auth/auth.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -23,6 +24,7 @@ import { AuthModule } from './common/auth/auth.module';
|
||||
}),
|
||||
LoggerModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
DatabaseModule,
|
||||
BidsModule,
|
||||
KeywordsModule,
|
||||
|
||||
@@ -6,78 +6,55 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request } from 'express';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
|
||||
@Injectable()
|
||||
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 clientIp = this.getClientIp(request);
|
||||
|
||||
// 检查是否为本地 IP
|
||||
if (this.isLocalIp(clientIp)) {
|
||||
return true; // 本地访问直接放行
|
||||
}
|
||||
// 检查是否启用 Basic Auth
|
||||
const enableBasicAuth =
|
||||
this.configService.get<string>('ENABLE_BASIC_AUTH') === 'true';
|
||||
|
||||
// 公网访问需要验证 API Key
|
||||
const apiKey = request.headers['x-api-key'] as string;
|
||||
const expectedApiKey = this.configService.get<string>('API_KEY');
|
||||
|
||||
if (!expectedApiKey) {
|
||||
// 如果未配置 API_KEY,允许所有访问(开发环境)
|
||||
if (!enableBasicAuth) {
|
||||
// 如果未启用 Basic Auth,允许所有访问
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!apiKey || apiKey !== expectedApiKey) {
|
||||
throw new UnauthorizedException('Invalid or missing API Key');
|
||||
// 解析 Authorization header
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客户端真实 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AuthGuard } from './auth.guard';
|
||||
import { UsersModule } from '../../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
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({
|
||||
imports: [
|
||||
@@ -18,11 +23,19 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
username: configService.get<string>('DATABASE_USERNAME', 'root'),
|
||||
password: configService.get<string>('DATABASE_PASSWORD', 'root'),
|
||||
database: configService.get<string>('DATABASE_NAME', 'bidding'),
|
||||
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
|
||||
entities: [
|
||||
User,
|
||||
BidItem,
|
||||
CrawlInfoAdd,
|
||||
AiRecommendation,
|
||||
Keyword,
|
||||
__dirname + '/../**/*.entity{.ts,.js}',
|
||||
],
|
||||
synchronize: false,
|
||||
timezone: 'Z',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
exports: [TypeOrmModule],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
|
||||
100
src/scripts/create-user.ts
Normal file
100
src/scripts/create-user.ts
Normal 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();
|
||||
78
src/scripts/delete-user.ts
Normal file
78
src/scripts/delete-user.ts
Normal 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();
|
||||
@@ -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
|
||||
@@ -12,21 +12,36 @@ import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
// Configuration
|
||||
// const config = {
|
||||
// host: '127.0.0.1',
|
||||
// port: 1122,
|
||||
// username: 'cubie',
|
||||
// privateKey: fs.readFileSync('d:\\163'),
|
||||
// passphrase: process.env.SSH_PASSPHRASE || '',
|
||||
// };
|
||||
|
||||
|
||||
const config = {
|
||||
host: '127.0.0.1',
|
||||
port: 1122,
|
||||
username: 'cubie',
|
||||
host: '139.180.190.142',
|
||||
port: 2211,
|
||||
username: 'root',
|
||||
privateKey: fs.readFileSync('d:\\163'),
|
||||
passphrase: process.env.SSH_PASSPHRASE || '',
|
||||
};
|
||||
|
||||
const destinations = {
|
||||
server: '/home/cubie/down/document/bidding/publish/server',
|
||||
frontend: '/home/cubie/down/document/bidding/publish/frontend',
|
||||
src: '/home/cubie/down/document/bidding/',
|
||||
};
|
||||
// const destinations = {
|
||||
// server: '/home/cubie/down/document/bidding/publish/server',
|
||||
// frontend: '/home/cubie/down/document/bidding/publish/frontend',
|
||||
// src: '/home/cubie/down/document/bidding/',
|
||||
// };
|
||||
|
||||
|
||||
const destinations = {
|
||||
server: '/root/bidding/publish/server',
|
||||
frontend: '/root/bidding/publish/frontend',
|
||||
src: '/root/bidding/',
|
||||
};
|
||||
|
||||
async function uploadDirectory(
|
||||
sftp: SftpClient,
|
||||
localPath: string,
|
||||
|
||||
55
src/scripts/init-users-table.ts
Normal file
55
src/scripts/init-users-table.ts
Normal 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
72
src/scripts/list-users.ts
Normal 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();
|
||||
25
src/users/entities/user.entity.ts
Normal file
25
src/users/entities/user.entity.ts
Normal 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
11
src/users/users.module.ts
Normal 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
112
src/users/users.service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
10
uni-app-version/.hbuilderx/launch.json
Normal file
10
uni-app-version/.hbuilderx/launch.json
Normal 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
197
uni-app-version/README.md
Normal 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` 配置
|
||||
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