feat: 添加代理隧道连接失败的重试机制
refactor(crawler): 在各爬虫服务中实现代理错误重试逻辑 feat(uni-app): 新增投标项目查看器的uni-app版本
This commit is contained in:
@@ -46,6 +46,56 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
|||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查错误是否为代理隧道连接失败
|
||||||
|
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return (
|
||||||
|
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||||
|
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟重试函数
|
||||||
|
async function delayRetry(
|
||||||
|
operation: () => Promise<void>,
|
||||||
|
maxRetries: number = 3,
|
||||||
|
delayMs: number = 5000,
|
||||||
|
logger?: Logger,
|
||||||
|
): Promise<void> {
|
||||||
|
let lastError: Error | unknown;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
await operation();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
if (isTunnelConnectionFailedError(error)) {
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const delay = delayMs * attempt; // 递增延迟
|
||||||
|
logger?.warn(
|
||||||
|
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
} else {
|
||||||
|
logger?.error(
|
||||||
|
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非代理错误,直接抛出
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CdtResult {
|
export interface CdtResult {
|
||||||
title: string;
|
title: string;
|
||||||
publishDate: Date;
|
publishDate: Date;
|
||||||
@@ -87,7 +137,14 @@ export const CdtCrawler = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.log(`Navigating to ${this.url}...`);
|
logger.log(`Navigating to ${this.url}...`);
|
||||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
await delayRetry(
|
||||||
|
async () => {
|
||||||
|
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||||
|
},
|
||||||
|
3,
|
||||||
|
5000,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
// 模拟人类行为
|
// 模拟人类行为
|
||||||
logger.log('Simulating human mouse movements...');
|
logger.log('Simulating human mouse movements...');
|
||||||
|
|||||||
@@ -47,6 +47,56 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
|||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查错误是否为代理隧道连接失败
|
||||||
|
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return (
|
||||||
|
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||||
|
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟重试函数
|
||||||
|
async function delayRetry(
|
||||||
|
operation: () => Promise<void>,
|
||||||
|
maxRetries: number = 3,
|
||||||
|
delayMs: number = 5000,
|
||||||
|
logger?: Logger,
|
||||||
|
): Promise<void> {
|
||||||
|
let lastError: Error | unknown;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
await operation();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
if (isTunnelConnectionFailedError(error)) {
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const delay = delayMs * attempt; // 递增延迟
|
||||||
|
logger?.warn(
|
||||||
|
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
} else {
|
||||||
|
logger?.error(
|
||||||
|
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非代理错误,直接抛出
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
interface CeicCrawlerType {
|
interface CeicCrawlerType {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -90,7 +140,14 @@ export const CeicCrawler = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.log(`Navigating to ${this.url}...`);
|
logger.log(`Navigating to ${this.url}...`);
|
||||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
await delayRetry(
|
||||||
|
async () => {
|
||||||
|
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||||
|
},
|
||||||
|
3,
|
||||||
|
5000,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
// 模拟人类行为
|
// 模拟人类行为
|
||||||
logger.log('Simulating human mouse movements...');
|
logger.log('Simulating human mouse movements...');
|
||||||
|
|||||||
@@ -46,6 +46,56 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
|||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查错误是否为代理隧道连接失败
|
||||||
|
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return (
|
||||||
|
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||||
|
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟重试函数
|
||||||
|
async function delayRetry(
|
||||||
|
operation: () => Promise<void>,
|
||||||
|
maxRetries: number = 3,
|
||||||
|
delayMs: number = 5000,
|
||||||
|
logger?: Logger,
|
||||||
|
): Promise<void> {
|
||||||
|
let lastError: Error | unknown;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
await operation();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
if (isTunnelConnectionFailedError(error)) {
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const delay = delayMs * attempt; // 递增延迟
|
||||||
|
logger?.warn(
|
||||||
|
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
} else {
|
||||||
|
logger?.error(
|
||||||
|
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非代理错误,直接抛出
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CgnpcResult {
|
export interface CgnpcResult {
|
||||||
title: string;
|
title: string;
|
||||||
publishDate: Date;
|
publishDate: Date;
|
||||||
@@ -96,7 +146,14 @@ export const CgnpcCrawler = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.log(`Navigating to ${this.url}...`);
|
logger.log(`Navigating to ${this.url}...`);
|
||||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
await delayRetry(
|
||||||
|
async () => {
|
||||||
|
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||||
|
},
|
||||||
|
3,
|
||||||
|
5000,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
// 模拟人类行为
|
// 模拟人类行为
|
||||||
logger.log('Simulating human mouse movements...');
|
logger.log('Simulating human mouse movements...');
|
||||||
|
|||||||
@@ -14,6 +14,56 @@ interface ChdtpCrawlerType {
|
|||||||
extract(html: string): ChdtpResult[];
|
extract(html: string): ChdtpResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查错误是否为代理隧道连接失败
|
||||||
|
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return (
|
||||||
|
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||||
|
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟重试函数
|
||||||
|
async function delayRetry(
|
||||||
|
operation: () => Promise<void>,
|
||||||
|
maxRetries: number = 3,
|
||||||
|
delayMs: number = 5000,
|
||||||
|
logger?: Logger,
|
||||||
|
): Promise<void> {
|
||||||
|
let lastError: Error | unknown;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
await operation();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
if (isTunnelConnectionFailedError(error)) {
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const delay = delayMs * attempt; // 递增延迟
|
||||||
|
logger?.warn(
|
||||||
|
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
} else {
|
||||||
|
logger?.error(
|
||||||
|
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非代理错误,直接抛出
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
export const ChdtpCrawler = {
|
export const ChdtpCrawler = {
|
||||||
name: '华电集团电子商务平台 ',
|
name: '华电集团电子商务平台 ',
|
||||||
url: 'https://www.chdtp.com/webs/queryWebZbgg.action?zbggType=1',
|
url: 'https://www.chdtp.com/webs/queryWebZbgg.action?zbggType=1',
|
||||||
@@ -42,7 +92,14 @@ export const ChdtpCrawler = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.log(`Navigating to ${this.url}...`);
|
logger.log(`Navigating to ${this.url}...`);
|
||||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
await delayRetry(
|
||||||
|
async () => {
|
||||||
|
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||||
|
},
|
||||||
|
3,
|
||||||
|
5000,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
while (currentPage <= maxPages) {
|
while (currentPage <= maxPages) {
|
||||||
const content = await page.content();
|
const content = await page.content();
|
||||||
|
|||||||
@@ -71,6 +71,56 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查错误是否为代理隧道连接失败
|
||||||
|
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return (
|
||||||
|
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||||
|
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟重试函数
|
||||||
|
async function delayRetry(
|
||||||
|
operation: () => Promise<void>,
|
||||||
|
maxRetries: number = 3,
|
||||||
|
delayMs: number = 5000,
|
||||||
|
logger?: Logger,
|
||||||
|
): Promise<void> {
|
||||||
|
let lastError: Error | unknown;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
await operation();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
if (isTunnelConnectionFailedError(error)) {
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const delay = delayMs * attempt; // 递增延迟
|
||||||
|
logger?.warn(
|
||||||
|
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
} else {
|
||||||
|
logger?.error(
|
||||||
|
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非代理错误,直接抛出
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
interface ChngCrawlerType {
|
interface ChngCrawlerType {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -115,7 +165,14 @@ export const ChngCrawler = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.log('Navigating to Bing...');
|
logger.log('Navigating to Bing...');
|
||||||
await page.goto('https://cn.bing.com', { waitUntil: 'networkidle2' });
|
await delayRetry(
|
||||||
|
async () => {
|
||||||
|
await page.goto('https://cn.bing.com', { waitUntil: 'networkidle2' });
|
||||||
|
},
|
||||||
|
3,
|
||||||
|
5000,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
logger.log('Searching for target site...');
|
logger.log('Searching for target site...');
|
||||||
const searchBoxSelector = 'input[name="q"]';
|
const searchBoxSelector = 'input[name="q"]';
|
||||||
|
|||||||
@@ -46,6 +46,56 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
|||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查错误是否为代理隧道连接失败
|
||||||
|
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return (
|
||||||
|
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||||
|
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟重试函数
|
||||||
|
async function delayRetry(
|
||||||
|
operation: () => Promise<void>,
|
||||||
|
maxRetries: number = 3,
|
||||||
|
delayMs: number = 5000,
|
||||||
|
logger?: Logger,
|
||||||
|
): Promise<void> {
|
||||||
|
let lastError: Error | unknown;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
await operation();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
if (isTunnelConnectionFailedError(error)) {
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const delay = delayMs * attempt; // 递增延迟
|
||||||
|
logger?.warn(
|
||||||
|
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
} else {
|
||||||
|
logger?.error(
|
||||||
|
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非代理错误,直接抛出
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CnncecpResult {
|
export interface CnncecpResult {
|
||||||
title: string;
|
title: string;
|
||||||
publishDate: Date;
|
publishDate: Date;
|
||||||
@@ -96,7 +146,14 @@ export const CnncecpCrawler = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.log(`Navigating to ${this.url}...`);
|
logger.log(`Navigating to ${this.url}...`);
|
||||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
await delayRetry(
|
||||||
|
async () => {
|
||||||
|
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||||
|
},
|
||||||
|
3,
|
||||||
|
5000,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
// 模拟人类行为
|
// 模拟人类行为
|
||||||
logger.log('Simulating human mouse movements...');
|
logger.log('Simulating human mouse movements...');
|
||||||
|
|||||||
@@ -46,6 +46,56 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
|||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查错误是否为代理隧道连接失败
|
||||||
|
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return (
|
||||||
|
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||||
|
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟重试函数
|
||||||
|
async function delayRetry(
|
||||||
|
operation: () => Promise<void>,
|
||||||
|
maxRetries: number = 3,
|
||||||
|
delayMs: number = 5000,
|
||||||
|
logger?: Logger,
|
||||||
|
): Promise<void> {
|
||||||
|
let lastError: Error | unknown;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
await operation();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
if (isTunnelConnectionFailedError(error)) {
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const delay = delayMs * attempt; // 递增延迟
|
||||||
|
logger?.warn(
|
||||||
|
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
} else {
|
||||||
|
logger?.error(
|
||||||
|
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非代理错误,直接抛出
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CnoocResult {
|
export interface CnoocResult {
|
||||||
title: string;
|
title: string;
|
||||||
publishDate: Date;
|
publishDate: Date;
|
||||||
@@ -96,7 +146,14 @@ export const CnoocCrawler = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.log(`Navigating to ${this.url}...`);
|
logger.log(`Navigating to ${this.url}...`);
|
||||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
await delayRetry(
|
||||||
|
async () => {
|
||||||
|
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||||
|
},
|
||||||
|
3,
|
||||||
|
5000,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
// 模拟人类行为
|
// 模拟人类行为
|
||||||
logger.log('Simulating human mouse movements...');
|
logger.log('Simulating human mouse movements...');
|
||||||
|
|||||||
@@ -46,6 +46,56 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
|||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查错误是否为代理隧道连接失败
|
||||||
|
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return (
|
||||||
|
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||||
|
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟重试函数
|
||||||
|
async function delayRetry(
|
||||||
|
operation: () => Promise<void>,
|
||||||
|
maxRetries: number = 3,
|
||||||
|
delayMs: number = 5000,
|
||||||
|
logger?: Logger,
|
||||||
|
): Promise<void> {
|
||||||
|
let lastError: Error | unknown;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
await operation();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
if (isTunnelConnectionFailedError(error)) {
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const delay = delayMs * attempt; // 递增延迟
|
||||||
|
logger?.warn(
|
||||||
|
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
} else {
|
||||||
|
logger?.error(
|
||||||
|
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非代理错误,直接抛出
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EpsResult {
|
export interface EpsResult {
|
||||||
title: string;
|
title: string;
|
||||||
publishDate: Date;
|
publishDate: Date;
|
||||||
@@ -96,7 +146,14 @@ export const EpsCrawler = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.log(`Navigating to ${this.url}...`);
|
logger.log(`Navigating to ${this.url}...`);
|
||||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
await delayRetry(
|
||||||
|
async () => {
|
||||||
|
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||||
|
},
|
||||||
|
3,
|
||||||
|
5000,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
// 模拟人类行为
|
// 模拟人类行为
|
||||||
logger.log('Simulating human mouse movements...');
|
logger.log('Simulating human mouse movements...');
|
||||||
|
|||||||
@@ -46,6 +46,56 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
|||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查错误是否为代理隧道连接失败
|
||||||
|
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return (
|
||||||
|
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||||
|
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟重试函数
|
||||||
|
async function delayRetry(
|
||||||
|
operation: () => Promise<void>,
|
||||||
|
maxRetries: number = 3,
|
||||||
|
delayMs: number = 5000,
|
||||||
|
logger?: Logger,
|
||||||
|
): Promise<void> {
|
||||||
|
let lastError: Error | unknown;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
await operation();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
if (isTunnelConnectionFailedError(error)) {
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const delay = delayMs * attempt; // 递增延迟
|
||||||
|
logger?.warn(
|
||||||
|
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
} else {
|
||||||
|
logger?.error(
|
||||||
|
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非代理错误,直接抛出
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EspicResult {
|
export interface EspicResult {
|
||||||
title: string;
|
title: string;
|
||||||
publishDate: Date;
|
publishDate: Date;
|
||||||
@@ -106,7 +156,14 @@ export const EspicCrawler = {
|
|||||||
try {
|
try {
|
||||||
const url = this.getUrl(currentPage);
|
const url = this.getUrl(currentPage);
|
||||||
logger.log(`Navigating to ${url}...`);
|
logger.log(`Navigating to ${url}...`);
|
||||||
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
|
await delayRetry(
|
||||||
|
async () => {
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||||
|
},
|
||||||
|
3,
|
||||||
|
5000,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
// 等待 WAF 验证通过
|
// 等待 WAF 验证通过
|
||||||
logger.log('Waiting for WAF verification...');
|
logger.log('Waiting for WAF verification...');
|
||||||
|
|||||||
@@ -46,6 +46,56 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
|||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查错误是否为代理隧道连接失败
|
||||||
|
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return (
|
||||||
|
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||||
|
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟重试函数
|
||||||
|
async function delayRetry(
|
||||||
|
operation: () => Promise<void>,
|
||||||
|
maxRetries: number = 3,
|
||||||
|
delayMs: number = 5000,
|
||||||
|
logger?: Logger,
|
||||||
|
): Promise<void> {
|
||||||
|
let lastError: Error | unknown;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
await operation();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
if (isTunnelConnectionFailedError(error)) {
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const delay = delayMs * attempt; // 递增延迟
|
||||||
|
logger?.warn(
|
||||||
|
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
} else {
|
||||||
|
logger?.error(
|
||||||
|
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非代理错误,直接抛出
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PowerbeijingResult {
|
export interface PowerbeijingResult {
|
||||||
title: string;
|
title: string;
|
||||||
publishDate: Date;
|
publishDate: Date;
|
||||||
@@ -96,7 +146,14 @@ export const PowerbeijingCrawler = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.log(`Navigating to ${this.url}...`);
|
logger.log(`Navigating to ${this.url}...`);
|
||||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
await delayRetry(
|
||||||
|
async () => {
|
||||||
|
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||||
|
},
|
||||||
|
3,
|
||||||
|
5000,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
// 模拟人类行为
|
// 模拟人类行为
|
||||||
logger.log('Simulating human mouse movements...');
|
logger.log('Simulating human mouse movements...');
|
||||||
|
|||||||
@@ -46,6 +46,56 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
|||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查错误是否为代理隧道连接失败
|
||||||
|
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return (
|
||||||
|
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||||
|
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟重试函数
|
||||||
|
async function delayRetry(
|
||||||
|
operation: () => Promise<void>,
|
||||||
|
maxRetries: number = 3,
|
||||||
|
delayMs: number = 5000,
|
||||||
|
logger?: Logger,
|
||||||
|
): Promise<void> {
|
||||||
|
let lastError: Error | unknown;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
await operation();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
if (isTunnelConnectionFailedError(error)) {
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const delay = delayMs * attempt; // 递增延迟
|
||||||
|
logger?.warn(
|
||||||
|
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
} else {
|
||||||
|
logger?.error(
|
||||||
|
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非代理错误,直接抛出
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SdiccResult {
|
export interface SdiccResult {
|
||||||
title: string;
|
title: string;
|
||||||
publishDate: Date;
|
publishDate: Date;
|
||||||
@@ -96,7 +146,14 @@ export const SdiccCrawler = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.log(`Navigating to ${this.url}...`);
|
logger.log(`Navigating to ${this.url}...`);
|
||||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
await delayRetry(
|
||||||
|
async () => {
|
||||||
|
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||||
|
},
|
||||||
|
3,
|
||||||
|
5000,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
// 模拟人类行为
|
// 模拟人类行为
|
||||||
logger.log('Simulating human mouse movements...');
|
logger.log('Simulating human mouse movements...');
|
||||||
|
|||||||
@@ -47,6 +47,56 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
|
|||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查错误是否为代理隧道连接失败
|
||||||
|
function isTunnelConnectionFailedError(error: unknown): boolean {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return (
|
||||||
|
error.message.includes('net::ERR_TUNNEL_CONNECTION_FAILED') ||
|
||||||
|
error.message.includes('ERR_TUNNEL_CONNECTION_FAILED')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟重试函数
|
||||||
|
async function delayRetry(
|
||||||
|
operation: () => Promise<void>,
|
||||||
|
maxRetries: number = 3,
|
||||||
|
delayMs: number = 5000,
|
||||||
|
logger?: Logger,
|
||||||
|
): Promise<void> {
|
||||||
|
let lastError: Error | unknown;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
await operation();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
if (isTunnelConnectionFailedError(error)) {
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const delay = delayMs * attempt; // 递增延迟
|
||||||
|
logger?.warn(
|
||||||
|
`代理隧道连接失败,第 ${attempt} 次尝试失败,${delay / 1000} 秒后重试...`,
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
} else {
|
||||||
|
logger?.error(
|
||||||
|
`代理隧道连接失败,已达到最大重试次数 ${maxRetries} 次`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非代理错误,直接抛出
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
interface SzecpCrawlerType {
|
interface SzecpCrawlerType {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -90,7 +140,14 @@ export const SzecpCrawler = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.log(`Navigating to ${this.url}...`);
|
logger.log(`Navigating to ${this.url}...`);
|
||||||
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
await delayRetry(
|
||||||
|
async () => {
|
||||||
|
await page.goto(this.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||||
|
},
|
||||||
|
3,
|
||||||
|
5000,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
// 模拟人类行为
|
// 模拟人类行为
|
||||||
logger.log('Simulating human mouse movements...');
|
logger.log('Simulating human mouse movements...');
|
||||||
|
|||||||
8
uni-app-version/.env
Normal file
8
uni-app-version/.env
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# API 配置
|
||||||
|
VITE_API_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# 应用配置
|
||||||
|
VITE_APP_TITLE=投标项目查看器`nVITE_APP_VERSION=1.0.0
|
||||||
|
|
||||||
|
# 刷新间隔(毫秒)
|
||||||
|
VITE_AUTO_REFRESH_INTERVAL=300000
|
||||||
2
uni-app-version/.env.development
Normal file
2
uni-app-version/.env.development
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# 撘<><E69298>𤑳㴓憓<E3B493><E68693>蝵害nVITE_API_BASE_URL=http://localhost:3000
|
||||||
|
VITE_APP_TITLE=<3D>閙<EFBFBD>憿寧𤌍<E5AFA7>亦<EFBFBD><E4BAA6>?撘<><E69298>?
|
||||||
3
uni-app-version/.npmrc
Normal file
3
uni-app-version/.npmrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@
|
||||||
|
legacy-peer-deps=true
|
||||||
|
strict-peer-dependencies=false
|
||||||
17
uni-app-version/index.html
Normal file
17
uni-app-version/index.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<script>
|
||||||
|
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(safe-area-inset-top)') || CSS.supports('top: constant(safe-area-inset-top)'))
|
||||||
|
document.write('<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' + (coverSupport ? ', viewport-fit=cover' : '') + '" />')
|
||||||
|
</script>
|
||||||
|
<title>投标项目查看器</title>
|
||||||
|
<!--preload-links-->
|
||||||
|
<!--app-context-->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"><!--app-html--></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
45
uni-app-version/package.json
Normal file
45
uni-app-version/package.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "bidding-looker-uniapp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "投标项目查看器 - uni-app 版本",
|
||||||
|
"main": "main.ts",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev:h5": "uni",
|
||||||
|
"build:h5": "uni build",
|
||||||
|
"dev:mp-weixin": "uni -p mp-weixin",
|
||||||
|
"build:mp-weixin": "uni build -p mp-weixin",
|
||||||
|
"dev:app": "uni -p app",
|
||||||
|
"build:app": "uni build -p app",
|
||||||
|
"type-check": "vue-tsc --noEmit"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"uni-app",
|
||||||
|
"bidding",
|
||||||
|
"looker",
|
||||||
|
"typescript",
|
||||||
|
"tailwindcss"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.21"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@dcloudio/types": "^3.4.8",
|
||||||
|
"@dcloudio/uni-app": "3.0.0-alpha-4020920240929001",
|
||||||
|
"@dcloudio/uni-app-plus": "3.0.0-alpha-4020920240929001",
|
||||||
|
"@dcloudio/uni-components": "3.0.0-alpha-4020920240929001",
|
||||||
|
"@dcloudio/uni-h5": "3.0.0-alpha-4020920240929001",
|
||||||
|
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-4020920240929001",
|
||||||
|
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4020920240929001",
|
||||||
|
"@types/node": "^20.14.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"sass": "^1.77.0",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vite": "5.2.13",
|
||||||
|
"vue-tsc": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
uni-app-version/postcss.config.js
Normal file
6
uni-app-version/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
uni-app-version/src/App.vue
Normal file
36
uni-app-version/src/App.vue
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
onLaunch: function() {
|
||||||
|
console.log('App Launch')
|
||||||
|
console.log('API Base URL:', import.meta.env.VITE_API_BASE_URL)
|
||||||
|
},
|
||||||
|
onShow: function() {
|
||||||
|
console.log('App Show')
|
||||||
|
},
|
||||||
|
onHide: function() {
|
||||||
|
console.log('App Hide')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
/* 引入 Tailwind CSS */
|
||||||
|
@import 'tailwindcss/base';
|
||||||
|
@import 'tailwindcss/components';
|
||||||
|
@import 'tailwindcss/utilities';
|
||||||
|
|
||||||
|
/* 引入全局样式 */
|
||||||
|
@import './uni.scss';
|
||||||
|
|
||||||
|
/* 全局样式 */
|
||||||
|
page {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 重置样式 */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
144
uni-app-version/src/components/AiRecommendations.vue
Normal file
144
uni-app-version/src/components/AiRecommendations.vue
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { getAiRecommendations, type AiRecommendation } from '../utils/api'
|
||||||
|
|
||||||
|
const recommendations = ref<AiRecommendation[]>([])
|
||||||
|
const loading = ref<boolean>(true)
|
||||||
|
const error = ref<string>('')
|
||||||
|
|
||||||
|
const loadRecommendations = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
const items = await getAiRecommendations()
|
||||||
|
recommendations.value = items || []
|
||||||
|
} catch (err) {
|
||||||
|
error.value = `加载失败: ${err.message || err}`
|
||||||
|
console.error('加载 AI 推荐失败:', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConfidenceColor = (confidence: number): string => {
|
||||||
|
if (confidence >= 80) return '#27ae60'
|
||||||
|
if (confidence >= 60) return '#f39c12'
|
||||||
|
return '#e74c3c'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConfidenceLabel = (confidence: number): string => {
|
||||||
|
if (confidence >= 80) return '高'
|
||||||
|
if (confidence >= 60) return '中'
|
||||||
|
return '低'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadRecommendations()
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
loadRecommendations
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="ai-recommendations-container">
|
||||||
|
<view v-if="loading" class="loading">
|
||||||
|
加载中...
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="error" class="error">
|
||||||
|
{{ error }}
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="recommendations.length === 0" class="empty">
|
||||||
|
暂无推荐项目
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="recommendation-list">
|
||||||
|
<view
|
||||||
|
v-for="item in recommendations"
|
||||||
|
:key="item.id"
|
||||||
|
class="recommendation-item"
|
||||||
|
>
|
||||||
|
<view class="recommendation-header">
|
||||||
|
<view
|
||||||
|
class="confidence-badge"
|
||||||
|
:style="{ backgroundColor: getConfidenceColor(item.confidence) }"
|
||||||
|
>
|
||||||
|
{{ getConfidenceLabel(item.confidence) }} ({{ item.confidence }}%)
|
||||||
|
</view>
|
||||||
|
<text class="date">{{ item.createdAt }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="recommendation-title">{{ item.title }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ai-recommendations-container {
|
||||||
|
padding: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.error,
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40rpx;
|
||||||
|
color: #666;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation-item {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
padding: 16rpx;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation-item:active {
|
||||||
|
border-color: #3498db;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(52, 152, 219, 0.15);
|
||||||
|
transform: translateY(-2rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confidence-badge {
|
||||||
|
color: #fff;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation-title {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
148
uni-app-version/src/components/CrawlInfo.vue
Normal file
148
uni-app-version/src/components/CrawlInfo.vue
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { getCrawlInfoStats, type CrawlInfoStat } from '../utils/api'
|
||||||
|
|
||||||
|
const crawlStats = ref<CrawlInfoStat[]>([])
|
||||||
|
const loading = ref<boolean>(true)
|
||||||
|
const error = ref<string>('')
|
||||||
|
|
||||||
|
const loadCrawlStats = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
const stats = await getCrawlInfoStats()
|
||||||
|
crawlStats.value = stats || []
|
||||||
|
} catch (err) {
|
||||||
|
error.value = `加载失败: ${err.message || err}`
|
||||||
|
console.error('加载爬虫统计信息失败:', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusText = (stat: CrawlInfoStat): string => {
|
||||||
|
if (stat.error) {
|
||||||
|
return '出错'
|
||||||
|
}
|
||||||
|
if (stat.count > 0) {
|
||||||
|
return '正常'
|
||||||
|
}
|
||||||
|
return '无数据'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusClass = (stat: CrawlInfoStat): string => {
|
||||||
|
if (stat.error) {
|
||||||
|
return 'status-error'
|
||||||
|
}
|
||||||
|
if (stat.count > 0) {
|
||||||
|
return 'status-success'
|
||||||
|
}
|
||||||
|
return 'status-info'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadCrawlStats()
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
loadCrawlStats
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="crawl-info-container">
|
||||||
|
<view v-if="loading" class="loading">
|
||||||
|
加载中...
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="error" class="error">
|
||||||
|
{{ error }}
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="crawlStats.length === 0" class="empty">
|
||||||
|
暂无爬虫统计信息
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="crawl-list">
|
||||||
|
<view
|
||||||
|
v-for="stat in crawlStats"
|
||||||
|
:key="stat.source"
|
||||||
|
class="crawl-item"
|
||||||
|
>
|
||||||
|
<text class="source">{{ stat.source }}</text>
|
||||||
|
<view :class="['status', getStatusClass(stat)]">
|
||||||
|
{{ getStatusText(stat) }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.crawl-info-container {
|
||||||
|
padding: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.error,
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40rpx;
|
||||||
|
color: #666;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crawl-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crawl-item {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crawl-item:active {
|
||||||
|
border-color: #3498db;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(52, 152, 219, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: 22rpx;
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info {
|
||||||
|
background: #e2e3e5;
|
||||||
|
color: #383d41;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
147
uni-app-version/src/components/PinnedBids.vue
Normal file
147
uni-app-version/src/components/PinnedBids.vue
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { getPinnedBids, type BidItem } from '../utils/api'
|
||||||
|
|
||||||
|
const bidItems = ref<BidItem[]>([])
|
||||||
|
const loading = ref<boolean>(true)
|
||||||
|
const error = ref<string>('')
|
||||||
|
|
||||||
|
const loadPinnedBids = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
const items = await getPinnedBids()
|
||||||
|
bidItems.value = items || []
|
||||||
|
} catch (err) {
|
||||||
|
error.value = `加载失败: ${err.message || err}`
|
||||||
|
console.error('加载置顶投标项目失败:', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openUrl = (url: string) => {
|
||||||
|
// #ifdef H5
|
||||||
|
window.open(url, '_blank')
|
||||||
|
// #endif
|
||||||
|
// #ifndef H5
|
||||||
|
// 在非 H5 平台,可以使用 web-view 或复制链接
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: url,
|
||||||
|
success: () => {
|
||||||
|
uni.showToast({
|
||||||
|
title: '链接已复制',
|
||||||
|
icon: 'success'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadPinnedBids()
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
loadPinnedBids
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="pinned-bids-container">
|
||||||
|
<view v-if="loading" class="loading">
|
||||||
|
加载中...
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="error" class="error">
|
||||||
|
{{ error }}
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="bidItems.length === 0" class="empty">
|
||||||
|
暂无置顶项目
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="bid-list">
|
||||||
|
<view
|
||||||
|
v-for="item in bidItems"
|
||||||
|
:key="item.id"
|
||||||
|
class="bid-item"
|
||||||
|
@click="openUrl(item.url)"
|
||||||
|
>
|
||||||
|
<view class="bid-header">
|
||||||
|
<text class="source">{{ item.source }}</text>
|
||||||
|
<text class="date">{{ item.publishDate }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="bid-title">{{ item.title }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pinned-bids-container {
|
||||||
|
padding: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.error,
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40rpx;
|
||||||
|
color: #666;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bid-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bid-item {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
padding: 16rpx;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bid-item:active {
|
||||||
|
border-color: #3498db;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(52, 152, 219, 0.15);
|
||||||
|
transform: translateY(-2rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bid-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source {
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bid-title {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
18
uni-app-version/src/env.d.ts
vendored
Normal file
18
uni-app-version/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL: string
|
||||||
|
readonly VITE_APP_TITLE: string
|
||||||
|
readonly VITE_APP_VERSION: string
|
||||||
|
readonly VITE_AUTO_REFRESH_INTERVAL: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
9
uni-app-version/src/main.ts
Normal file
9
uni-app-version/src/main.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createSSRApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
export function createApp() {
|
||||||
|
const app = createSSRApp(App)
|
||||||
|
return {
|
||||||
|
app
|
||||||
|
}
|
||||||
|
}
|
||||||
73
uni-app-version/src/manifest.json
Normal file
73
uni-app-version/src/manifest.json
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"name": "bidding-looker",
|
||||||
|
"appid": "__UNI__BIDDING_LOOKER",
|
||||||
|
"description": "投标项目查看器",
|
||||||
|
"versionName": "1.0.0",
|
||||||
|
"versionCode": "100",
|
||||||
|
"transformPx": false,
|
||||||
|
"app-plus": {
|
||||||
|
"usingComponents": true,
|
||||||
|
"nvueStyleCompiler": "uni-app",
|
||||||
|
"compilerVersion": 3,
|
||||||
|
"splashscreen": {
|
||||||
|
"alwaysShowBeforeRender": true,
|
||||||
|
"waiting": true,
|
||||||
|
"autoclose": true,
|
||||||
|
"delay": 0
|
||||||
|
},
|
||||||
|
"modules": {},
|
||||||
|
"distribute": {
|
||||||
|
"android": {
|
||||||
|
"permissions": [
|
||||||
|
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||||
|
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ios": {},
|
||||||
|
"sdkConfigs": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quickapp": {},
|
||||||
|
"mp-weixin": {
|
||||||
|
"appid": "",
|
||||||
|
"setting": {
|
||||||
|
"urlCheck": false
|
||||||
|
},
|
||||||
|
"usingComponents": true
|
||||||
|
},
|
||||||
|
"mp-alipay": {
|
||||||
|
"usingComponents": true
|
||||||
|
},
|
||||||
|
"mp-baidu": {
|
||||||
|
"usingComponents": true
|
||||||
|
},
|
||||||
|
"mp-toutiao": {
|
||||||
|
"usingComponents": true
|
||||||
|
},
|
||||||
|
"uniStatistics": {
|
||||||
|
"enable": false
|
||||||
|
},
|
||||||
|
"vueVersion": "3",
|
||||||
|
"h5": {
|
||||||
|
"router": {
|
||||||
|
"mode": "hash",
|
||||||
|
"base": "/"
|
||||||
|
},
|
||||||
|
"devServer": {
|
||||||
|
"port": 8080,
|
||||||
|
"disableHostCheck": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
uni-app-version/src/pages.json
Normal file
26
uni-app-version/src/pages.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"easycom": {
|
||||||
|
"autoscan": true,
|
||||||
|
"custom": {
|
||||||
|
"^u-(.*)": "uview-ui/components/u-$1/u-$1.vue"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "pages/index/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "投标项目查看器",
|
||||||
|
"navigationBarBackgroundColor": "#ffffff",
|
||||||
|
"navigationBarTextStyle": "black",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"globalStyle": {
|
||||||
|
"navigationBarTextStyle": "black",
|
||||||
|
"navigationBarTitleText": "投标项目查看器",
|
||||||
|
"navigationBarBackgroundColor": "#ffffff",
|
||||||
|
"backgroundColor": "#f5f5f5"
|
||||||
|
},
|
||||||
|
"tabBar": {}
|
||||||
|
}
|
||||||
194
uni-app-version/src/pages/index/index.vue
Normal file
194
uni-app-version/src/pages/index/index.vue
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||||
|
import PinnedBids from '../../components/PinnedBids.vue'
|
||||||
|
import AiRecommendations from '../../components/AiRecommendations.vue'
|
||||||
|
import CrawlInfo from '../../components/CrawlInfo.vue'
|
||||||
|
|
||||||
|
type TabId = 'pinned' | 'ai' | 'status'
|
||||||
|
|
||||||
|
interface Tab {
|
||||||
|
id: TabId
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTab = ref<TabId>('pinned')
|
||||||
|
const pinnedBidsRef = ref<InstanceType<typeof PinnedBids> | null>(null)
|
||||||
|
const aiRecommendationsRef = ref<InstanceType<typeof AiRecommendations> | null>(null)
|
||||||
|
const crawlInfoRef = ref<InstanceType<typeof CrawlInfo> | null>(null)
|
||||||
|
let refreshTimer: number | null = null
|
||||||
|
const showToast = ref<boolean>(false)
|
||||||
|
const toastMessage = ref<string>('')
|
||||||
|
|
||||||
|
// 从环境变量读取自动刷新间隔
|
||||||
|
const AUTO_REFRESH_INTERVAL = Number(import.meta.env.VITE_AUTO_REFRESH_INTERVAL) || 300000
|
||||||
|
|
||||||
|
const tabs: Tab[] = [
|
||||||
|
{ id: 'pinned', label: '置顶项目' },
|
||||||
|
{ id: 'ai', label: 'AI 推荐' },
|
||||||
|
{ id: 'status', label: '状态' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
if (activeTab.value === 'pinned' && pinnedBidsRef.value) {
|
||||||
|
pinnedBidsRef.value.loadPinnedBids()
|
||||||
|
showToastMessage('置顶项目已刷新')
|
||||||
|
} else if (activeTab.value === 'ai' && aiRecommendationsRef.value) {
|
||||||
|
aiRecommendationsRef.value.loadRecommendations()
|
||||||
|
showToastMessage('AI 推荐已刷新')
|
||||||
|
} else if (activeTab.value === 'status' && crawlInfoRef.value) {
|
||||||
|
crawlInfoRef.value.loadCrawlStats()
|
||||||
|
showToastMessage('状态已刷新')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showToastMessage = (message: string) => {
|
||||||
|
toastMessage.value = message
|
||||||
|
showToast.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
showToast.value = false
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startAutoRefresh = () => {
|
||||||
|
// 使用环境变量配置的自动刷新间隔
|
||||||
|
refreshTimer = window.setInterval(() => {
|
||||||
|
handleRefresh()
|
||||||
|
}, AUTO_REFRESH_INTERVAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopAutoRefresh = () => {
|
||||||
|
if (refreshTimer !== null) {
|
||||||
|
clearInterval(refreshTimer)
|
||||||
|
refreshTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
startAutoRefresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopAutoRefresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 下拉刷新
|
||||||
|
onPullDownRefresh(() => {
|
||||||
|
handleRefresh()
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
}, 1000)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="app-container">
|
||||||
|
<view class="tabs">
|
||||||
|
<view
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.id"
|
||||||
|
:class="['tab-button', { active: activeTab === tab.id }]"
|
||||||
|
@click="activeTab = tab.id"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</view>
|
||||||
|
<view class="refresh-button" @click="handleRefresh">
|
||||||
|
🔄 刷新
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="tab-content">
|
||||||
|
<PinnedBids v-if="activeTab === 'pinned'" ref="pinnedBidsRef" />
|
||||||
|
<AiRecommendations v-else-if="activeTab === 'ai'" ref="aiRecommendationsRef" />
|
||||||
|
<CrawlInfo v-else-if="activeTab === 'status'" ref="crawlInfoRef" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="showToast" class="toast">
|
||||||
|
{{ toastMessage }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
padding: 0 16rpx;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 4rpx solid transparent;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #666;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button:active {
|
||||||
|
color: #333;
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button.active {
|
||||||
|
color: #3498db;
|
||||||
|
border-bottom-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
padding: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-button {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 8rpx 20rpx;
|
||||||
|
background: #3498db;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
align-self: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-button:active {
|
||||||
|
background: #2980b9;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: #fff;
|
||||||
|
padding: 24rpx 48rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
40
uni-app-version/src/uni.scss
Normal file
40
uni-app-version/src/uni.scss
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* uni-app 全局样式变量
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 颜色变量 */
|
||||||
|
$uni-color-primary: #3498db;
|
||||||
|
$uni-color-success: #27ae60;
|
||||||
|
$uni-color-warning: #f39c12;
|
||||||
|
$uni-color-error: #e74c3c;
|
||||||
|
$uni-color-info: #909399;
|
||||||
|
|
||||||
|
/* 文字颜色 */
|
||||||
|
$uni-text-color: #333333;
|
||||||
|
$uni-text-color-grey: #666666;
|
||||||
|
$uni-text-color-placeholder: #999999;
|
||||||
|
|
||||||
|
/* 背景颜色 */
|
||||||
|
$uni-bg-color: #ffffff;
|
||||||
|
$uni-bg-color-grey: #f5f5f5;
|
||||||
|
$uni-bg-color-hover: #f9f9f9;
|
||||||
|
|
||||||
|
/* 边框颜色 */
|
||||||
|
$uni-border-color: #e0e0e0;
|
||||||
|
|
||||||
|
/* 字体大小 */
|
||||||
|
$uni-font-size-xs: 20rpx;
|
||||||
|
$uni-font-size-sm: 24rpx;
|
||||||
|
$uni-font-size-base: 28rpx;
|
||||||
|
$uni-font-size-lg: 32rpx;
|
||||||
|
|
||||||
|
/* 间距 */
|
||||||
|
$uni-spacing-xs: 8rpx;
|
||||||
|
$uni-spacing-sm: 16rpx;
|
||||||
|
$uni-spacing-base: 24rpx;
|
||||||
|
$uni-spacing-lg: 32rpx;
|
||||||
|
|
||||||
|
/* 圆角 */
|
||||||
|
$uni-border-radius-sm: 6rpx;
|
||||||
|
$uni-border-radius-base: 8rpx;
|
||||||
|
$uni-border-radius-lg: 12rpx;
|
||||||
25
uni-app-version/tailwind.config.js
Normal file
25
uni-app-version/tailwind.config.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
'./index.html',
|
||||||
|
'./pages/**/*.{vue,js,ts,jsx,tsx}',
|
||||||
|
'./components/**/*.{vue,js,ts,jsx,tsx}',
|
||||||
|
'./App.vue'
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: '#3498db',
|
||||||
|
success: '#27ae60',
|
||||||
|
warning: '#f39c12',
|
||||||
|
error: '#e74c3c',
|
||||||
|
info: '#909399'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
// uni-app 配置
|
||||||
|
corePlugins: {
|
||||||
|
preflight: false
|
||||||
|
}
|
||||||
|
}
|
||||||
38
uni-app-version/tsconfig.json
Normal file
38
uni-app-version/tsconfig.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
},
|
||||||
|
"types": [
|
||||||
|
"@dcloudio/types",
|
||||||
|
"vite/client"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.d.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
"**/*.vue"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"unpackage"
|
||||||
|
]
|
||||||
|
}
|
||||||
18
uni-app-version/vite.config.ts
Normal file
18
uni-app-version/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import uniPlugin from '@dcloudio/vite-plugin-uni'
|
||||||
|
|
||||||
|
// Handle both ESM default export and CJS module.exports
|
||||||
|
const uni = (uniPlugin as any).default || uniPlugin
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [uni()],
|
||||||
|
server: {
|
||||||
|
port: 8080,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user