diff --git a/.env b/.env index c91b64a..e76f468 100644 --- a/.env +++ b/.env @@ -46,3 +46,6 @@ SSH_PASSPHRASE=x API_KEY=22c64b60-6e60-433c-991d-f6d658024b9e +# 是否启用 Basic Auth 认证(true/false) +ENABLE_BASIC_AUTH=true + diff --git a/.env.example b/.env.example index 4af1f09..451a5ee 100644 --- a/.env.example +++ b/.env.example @@ -26,4 +26,7 @@ LOG_LEVEL=info # OpenAI API Key (用于 AI 推荐) ARK_API_KEY=your_openai_api_key_here -API_KEY=your_secure_api_key_here \ No newline at end of file +API_KEY=your_secure_api_key_here + +# 是否启用 Basic Auth 认证(true/false) +ENABLE_BASIC_AUTH=true \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0c3e980..c3388db 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -40,7 +40,8 @@ - Admin + {{ currentUser }} + 退出登录 @@ -60,11 +61,26 @@ + + + + + + + + + + + + + 登录 + + diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts index 9279b28..ff58442 100644 --- a/frontend/src/utils/api.ts +++ b/frontend/src/utils/api.ts @@ -1,5 +1,38 @@ 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配置 * 配置axios实例,设置baseURL和请求拦截器 @@ -13,22 +46,10 @@ const api = axios.create({ // 请求拦截器 api.interceptors.request.use( (config: InternalAxiosRequestConfig) => { - // 如果 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; - } + // 添加 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); }, diff --git a/package.json b/package.json index cd24172..11a2072 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app.module.ts b/src/app.module.ts index f8168e6..2ef4bee 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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, diff --git a/src/common/auth/auth.guard.ts b/src/common/auth/auth.guard.ts index c3d3418..fc47347 100644 --- a/src/common/auth/auth.guard.ts +++ b/src/common/auth/auth.guard.ts @@ -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 { const request = context.switchToHttp().getRequest(); - const clientIp = this.getClientIp(request); - // 检查是否为本地 IP - if (this.isLocalIp(clientIp)) { - return true; // 本地访问直接放行 - } + // 检查是否启用 Basic Auth + const enableBasicAuth = + this.configService.get('ENABLE_BASIC_AUTH') === 'true'; - // 公网访问需要验证 API Key - const apiKey = request.headers['x-api-key'] as string; - const expectedApiKey = this.configService.get('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; - } } diff --git a/src/common/auth/auth.module.ts b/src/common/auth/auth.module.ts index 0609fd0..b22e407 100644 --- a/src/common/auth/auth.module.ts +++ b/src/common/auth/auth.module.ts @@ -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, diff --git a/src/database/database.module.ts b/src/database/database.module.ts index c672314..fa76951 100644 --- a/src/database/database.module.ts +++ b/src/database/database.module.ts @@ -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('DATABASE_USERNAME', 'root'), password: configService.get('DATABASE_PASSWORD', 'root'), database: configService.get('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 {} diff --git a/src/scripts/create-user.ts b/src/scripts/create-user.ts new file mode 100644 index 0000000..3fce0ea --- /dev/null +++ b/src/scripts/create-user.ts @@ -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(); diff --git a/src/scripts/delete-user.ts b/src/scripts/delete-user.ts new file mode 100644 index 0000000..64ee1f5 --- /dev/null +++ b/src/scripts/delete-user.ts @@ -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 '); + 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(); diff --git a/src/scripts/init-users-table.ts b/src/scripts/init-users-table.ts new file mode 100644 index 0000000..2127be5 --- /dev/null +++ b/src/scripts/init-users-table.ts @@ -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(); diff --git a/src/scripts/list-users.ts b/src/scripts/list-users.ts new file mode 100644 index 0000000..60fc196 --- /dev/null +++ b/src/scripts/list-users.ts @@ -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(); diff --git a/src/users/entities/user.entity.ts b/src/users/entities/user.entity.ts new file mode 100644 index 0000000..978c8ad --- /dev/null +++ b/src/users/entities/user.entity.ts @@ -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; +} diff --git a/src/users/users.module.ts b/src/users/users.module.ts new file mode 100644 index 0000000..f8cc930 --- /dev/null +++ b/src/users/users.module.ts @@ -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 {} diff --git a/src/users/users.service.ts b/src/users/users.service.ts new file mode 100644 index 0000000..653895f --- /dev/null +++ b/src/users/users.service.ts @@ -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, + ) {} + + /** + * 创建新用户 + * @param username 用户名 + * @param password 明文密码 + * @returns 创建的用户 + */ + async createUser(username: string, password: string): Promise { + // 检查用户名是否已存在 + 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 { + 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[]> { + const users = await this.userRepository.find(); + return users.map((user) => { + const { password, ...userWithoutPassword } = user; + return userWithoutPassword; + }); + } + + /** + * 根据用户名查找用户 + * @param username 用户名 + * @returns 用户对象(不包含密码) + */ + async findByUsername( + username: string, + ): Promise | 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 { + await this.userRepository.delete(id); + } + + /** + * 根据用户名删除用户 + * @param username 用户名 + */ + async deleteUserByUsername(username: string): Promise { + await this.userRepository.delete({ username }); + } +} diff --git a/uni-app-version/.hbuilderx/launch.json b/uni-app-version/.hbuilderx/launch.json new file mode 100644 index 0000000..07b129c --- /dev/null +++ b/uni-app-version/.hbuilderx/launch.json @@ -0,0 +1,10 @@ +{ + "version" : "1.0", + "configurations" : [ + { + "customPlaygroundType" : "device", + "playground" : "standard", + "type" : "uni-app:app-android" + } + ] +} diff --git a/uni-app-version/README.md b/uni-app-version/README.md new file mode 100644 index 0000000..09290f9 --- /dev/null +++ b/uni-app-version/README.md @@ -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 + + + +``` + +### 环境变量 + +通过 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` 配置 diff --git a/widget/looker/frontend/src/shared/README.md b/widget/looker/frontend/src/shared/README.md new file mode 100644 index 0000000..8d3b33b --- /dev/null +++ b/widget/looker/frontend/src/shared/README.md @@ -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 + + + + + +``` + +## API接口 + +### BidAPI接口 + +```typescript +interface BidAPI { + // 投标相关 + getPinnedBids(): Promise + getRecentBids(limit?: number): Promise + getBidsByDateRange(startDate: string, endDate: string): Promise + getBidsByParams(params: BidQueryParams): Promise> + updatePinStatus(title: string, pin: boolean): Promise + getSources(): Promise + + // AI推荐相关 + getAiRecommendations(): Promise + saveAiRecommendations(recommendations: AiRecommendation[]): Promise + getLatestRecommendations(): Promise + + // 关键词相关 + getKeywords(): Promise + addKeyword(word: string, weight?: number): Promise + deleteKeyword(id: string): Promise + + // 爬虫相关 + getCrawlStats(): Promise + runCrawler(): Promise + runCrawlerBySource(sourceName: string): Promise + 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) diff --git a/widget/looker/frontend/src/shared/api/api-factory.ts b/widget/looker/frontend/src/shared/api/api-factory.ts new file mode 100644 index 0000000..8892a2b --- /dev/null +++ b/widget/looker/frontend/src/shared/api/api-factory.ts @@ -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() diff --git a/widget/looker/frontend/src/shared/api/bid-api.ts b/widget/looker/frontend/src/shared/api/bid-api.ts new file mode 100644 index 0000000..8fc0c72 --- /dev/null +++ b/widget/looker/frontend/src/shared/api/bid-api.ts @@ -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 + getRecentBids(limit?: number): Promise + getBidsByDateRange(startDate: string, endDate: string): Promise + getBidsByParams(params: BidQueryParams): Promise> + updatePinStatus(title: string, pin: boolean): Promise + getSources(): Promise + + // AI推荐相关 + getAiRecommendations(): Promise + saveAiRecommendations(recommendations: AiRecommendation[]): Promise + getLatestRecommendations(): Promise + + // 关键词相关 + getKeywords(): Promise + addKeyword(word: string, weight?: number): Promise + deleteKeyword(id: string): Promise + + // 爬虫相关 + getCrawlStats(): Promise + runCrawler(): Promise + runCrawlerBySource(sourceName: string): Promise + getCrawlerStatus(): Promise<{ status: string; lastRun: string }> +} diff --git a/widget/looker/frontend/src/shared/api/http-adapter.ts b/widget/looker/frontend/src/shared/api/http-adapter.ts new file mode 100644 index 0000000..6964c67 --- /dev/null +++ b/widget/looker/frontend/src/shared/api/http-adapter.ts @@ -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 { + const response = await this.api.get('/api/bids/pinned') + return response.data + } + + async getRecentBids(limit?: number): Promise { + const response = await this.api.get('/api/bids/recent', { + params: { limit } + }) + return response.data + } + + async getBidsByDateRange(startDate: string, endDate: string): Promise { + const response = await this.api.get('/api/bids/by-date-range', { + params: { startDate, endDate } + }) + return response.data + } + + async getBidsByParams(params: BidQueryParams): Promise> { + const response = await this.api.get>('/api/bids', { + params + }) + return response.data + } + + async updatePinStatus(title: string, pin: boolean): Promise { + await this.api.patch(`/api/bids/${encodeURIComponent(title)}/pin`, { pin }) + } + + async getSources(): Promise { + const response = await this.api.get('/api/bids/sources') + return response.data + } + + // AI推荐相关 + async getAiRecommendations(): Promise { + const response = await this.api.get('/api/ai/latest-recommendations') + return response.data + } + + async saveAiRecommendations(recommendations: AiRecommendation[]): Promise { + await this.api.post('/api/ai/save-recommendations', { recommendations }) + } + + async getLatestRecommendations(): Promise { + const response = await this.api.get('/api/ai/latest-recommendations') + return response.data + } + + // 关键词相关 + async getKeywords(): Promise { + const response = await this.api.get('/api/keywords') + return response.data + } + + async addKeyword(word: string, weight?: number): Promise { + const response = await this.api.post('/api/keywords', { word, weight }) + return response.data + } + + async deleteKeyword(id: string): Promise { + await this.api.delete(`/api/keywords/${id}`) + } + + // 爬虫相关 + async getCrawlStats(): Promise { + const response = await this.api.get('/api/bids/crawl-info-stats') + return response.data + } + + async runCrawler(): Promise { + await this.api.post('/api/crawler/run') + } + + async runCrawlerBySource(sourceName: string): Promise { + 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 + } +} diff --git a/widget/looker/frontend/src/shared/api/index.ts b/widget/looker/frontend/src/shared/api/index.ts new file mode 100644 index 0000000..b081dc1 --- /dev/null +++ b/widget/looker/frontend/src/shared/api/index.ts @@ -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' diff --git a/widget/looker/frontend/src/shared/api/ipc-adapter.ts b/widget/looker/frontend/src/shared/api/ipc-adapter.ts new file mode 100644 index 0000000..86b9734 --- /dev/null +++ b/widget/looker/frontend/src/shared/api/ipc-adapter.ts @@ -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 + GetAiRecommendations(): Promise + GetCrawlInfoStats(): Promise + GetAllBids(): Promise + UpdatePinStatus(title: string, pin: boolean): Promise + SaveAiRecommendations(recommendations: AiRecommendation[]): Promise + GetKeywords(): Promise + AddKeyword(word: string, weight?: number): Promise + DeleteKeyword(id: string): Promise + GetSources(): Promise + RunCrawler(): Promise + RunCrawlerBySource(sourceName: string): Promise + 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 { + return await this.app.GetPinnedBidItems() + } + + async getRecentBids(limit?: number): Promise { + const allBids = await this.app.GetAllBids() + return allBids.slice(0, limit || 20) + } + + async getBidsByDateRange(startDate: string, endDate: string): Promise { + 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> { + 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 { + await this.app.UpdatePinStatus(title, pin) + } + + async getSources(): Promise { + return await this.app.GetSources() + } + + // AI推荐相关 + async getAiRecommendations(): Promise { + return await this.app.GetAiRecommendations() + } + + async saveAiRecommendations(recommendations: AiRecommendation[]): Promise { + await this.app.SaveAiRecommendations(recommendations) + } + + async getLatestRecommendations(): Promise { + return await this.app.GetAiRecommendations() + } + + // 关键词相关 + async getKeywords(): Promise { + return await this.app.GetKeywords() + } + + async addKeyword(word: string, weight?: number): Promise { + return await this.app.AddKeyword(word, weight) + } + + async deleteKeyword(id: string): Promise { + await this.app.DeleteKeyword(id) + } + + // 爬虫相关 + async getCrawlStats(): Promise { + return await this.app.GetCrawlInfoStats() + } + + async runCrawler(): Promise { + await this.app.RunCrawler() + } + + async runCrawlerBySource(sourceName: string): Promise { + await this.app.RunCrawlerBySource(sourceName) + } + + async getCrawlerStatus(): Promise<{ status: string; lastRun: string }> { + return await this.app.GetCrawlerStatus() + } +} diff --git a/widget/looker/frontend/src/shared/components/BaseAiRecommendations.vue b/widget/looker/frontend/src/shared/components/BaseAiRecommendations.vue new file mode 100644 index 0000000..d7e1a0e --- /dev/null +++ b/widget/looker/frontend/src/shared/components/BaseAiRecommendations.vue @@ -0,0 +1,146 @@ + + + + + + 加载中... + + + + {{ error }} + + + + 暂无推荐项目 + + + + + + + {{ getConfidenceLabel(item.confidence) }} ({{ item.confidence }}%) + + {{ item.createdAt }} + + {{ item.title }} + + + + + + diff --git a/widget/looker/frontend/src/shared/components/BaseBidList.vue b/widget/looker/frontend/src/shared/components/BaseBidList.vue new file mode 100644 index 0000000..62fd270 --- /dev/null +++ b/widget/looker/frontend/src/shared/components/BaseBidList.vue @@ -0,0 +1,158 @@ + + + + + + 加载中... + + + + {{ error }} + + + + 暂无数据 + + + + + + {{ item.source }} + {{ item.publishDate }} + + {{ item.title }} + + 📌 已置顶 + + + + + + + diff --git a/widget/looker/frontend/src/shared/components/BaseCrawlInfo.vue b/widget/looker/frontend/src/shared/components/BaseCrawlInfo.vue new file mode 100644 index 0000000..bb1bcc5 --- /dev/null +++ b/widget/looker/frontend/src/shared/components/BaseCrawlInfo.vue @@ -0,0 +1,142 @@ + + + + + + 加载中... + + + + {{ error }} + + + + 暂无爬虫统计信息 + + + + + {{ stat.source }} + + {{ getStatusText(stat) }} + + + + + + + diff --git a/widget/looker/frontend/src/shared/components/index.ts b/widget/looker/frontend/src/shared/components/index.ts new file mode 100644 index 0000000..e7b1c3f --- /dev/null +++ b/widget/looker/frontend/src/shared/components/index.ts @@ -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' diff --git a/widget/looker/frontend/src/shared/models/bid-item.ts b/widget/looker/frontend/src/shared/models/bid-item.ts new file mode 100644 index 0000000..77a9937 --- /dev/null +++ b/widget/looker/frontend/src/shared/models/bid-item.ts @@ -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 { + data: T[] + total: number + page: number + pageSize: number +} diff --git a/widget/looker/frontend/src/shared/package.json b/widget/looker/frontend/src/shared/package.json new file mode 100644 index 0000000..64b92de --- /dev/null +++ b/widget/looker/frontend/src/shared/package.json @@ -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" + } +}