feat: 添加代理隧道连接失败的重试机制

refactor(crawler): 在各爬虫服务中实现代理错误重试逻辑
feat(uni-app): 新增投标项目查看器的uni-app版本
This commit is contained in:
dmy
2026-01-15 14:03:46 +08:00
parent 37200aa115
commit 20c7c0da0c
31 changed files with 1693 additions and 12 deletions

8
uni-app-version/.env Normal file
View 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

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

@@ -0,0 +1,3 @@
@
legacy-peer-deps=true
strict-peer-dependencies=false

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

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

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

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

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

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

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

View File

@@ -0,0 +1,9 @@
import { createSSRApp } from 'vue'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
return {
app
}
}

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

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

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

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

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

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

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