-
+
+
@@ -27,29 +27,73 @@ const props = defineProps({
data: {
type: Object,
default: () => ({})
+ },
+ /** available:仅已验证可用;pending:仅待验证池 */
+ variant: {
+ type: String,
+ default: 'available',
+ validator: (v) => ['available', 'pending'].includes(v)
+ },
+ /** 并排展示时略压低高度 */
+ compact: {
+ type: Boolean,
+ default: false
}
})
+const titleText = computed(() =>
+ props.variant === 'pending' ? '待验证 · 协议分布' : '可用代理 · 协议分布'
+)
+
+const helpText = computed(() =>
+ props.variant === 'pending'
+ ? '仅统计 validated=0 的待验证代理,与各协议在队列中的占比'
+ : '仅统计已验证且分数大于 0 的可用代理,不含待验证与低分条目'
+)
+
+const emptyText = computed(() =>
+ props.variant === 'pending' ? '暂无待验证代理' : '暂无可用代理'
+)
+
const chartRef = ref(null)
let chartInstance = null
let resizeTimer = null
const cachedColors = ref(null)
// ==================== 计算属性 ====================
+const counts = computed(() => {
+ const d = props.data || {}
+ if (props.variant === 'pending') {
+ return {
+ http: d.pending_http_count || 0,
+ https: d.pending_https_count || 0,
+ socks4: d.pending_socks4_count || 0,
+ socks5: d.pending_socks5_count || 0
+ }
+ }
+ return {
+ http: d.http_count || 0,
+ https: d.https_count || 0,
+ socks4: d.socks4_count || 0,
+ socks5: d.socks5_count || 0
+ }
+})
+
const hasData = computed(() => {
- const { http_count, https_count, socks4_count, socks5_count } = props.data
- return (http_count || 0) + (https_count || 0) + (socks4_count || 0) + (socks5_count || 0) > 0
+ const c = counts.value
+ return c.http + c.https + c.socks4 + c.socks5 > 0
})
const chartData = computed(() => {
if (!cachedColors.value) return []
const colors = cachedColors.value
+ const c = counts.value
return [
- { value: props.data.http_count || 0, name: 'HTTP', itemStyle: { color: colors.info } },
- { value: props.data.https_count || 0, name: 'HTTPS', itemStyle: { color: colors.success } },
- { value: props.data.socks4_count || 0, name: 'SOCKS4', itemStyle: { color: colors.primary } },
- { value: props.data.socks5_count || 0, name: 'SOCKS5', itemStyle: { color: colors.warning } }
- ].filter(item => item.value > 0)
+ { value: c.http, name: 'HTTP', itemStyle: { color: colors.info } },
+ { value: c.https, name: 'HTTPS', itemStyle: { color: colors.success } },
+ { value: c.socks4, name: 'SOCKS4', itemStyle: { color: colors.primary } },
+ { value: c.socks5, name: 'SOCKS5', itemStyle: { color: colors.warning } }
+ ].filter((item) => item.value > 0)
})
const total = computed(() =>
@@ -141,11 +185,16 @@ function getChartOption() {
function initChart() {
if (!chartRef.value || !hasData.value) return
-
+
loadColors()
+ if (chartInstance) {
+ updateChart()
+ return
+ }
+
chartInstance = echarts.init(chartRef.value)
updateChart()
-
+
window.addEventListener('resize', handleResize)
}
@@ -172,13 +221,21 @@ function destroyChart() {
}
// ==================== 监听 ====================
-watch(() => props.data, () => {
- if (!chartInstance && hasData.value) {
- initChart()
- } else {
- updateChart()
- }
-}, { deep: true })
+watch(
+ () => [props.data, props.variant, props.compact],
+ () => {
+ if (!hasData.value) {
+ destroyChart()
+ return
+ }
+ if (!chartInstance) {
+ initChart()
+ } else {
+ updateChart()
+ }
+ },
+ { deep: true }
+)
// ==================== 生命周期 ====================
onMounted(() => {
@@ -200,6 +257,14 @@ onUnmounted(() => {
border: 1px solid var(--border);
}
+.chart-card--compact {
+ min-height: 340px;
+}
+
+.chart-card--compact .chart-container {
+ height: 300px;
+}
+
.chart-card:hover {
border-color: var(--border-light);
}
diff --git a/WebUI/src/components/StatCard.vue b/WebUI/src/components/StatCard.vue
index 811a226..a8702ef 100644
--- a/WebUI/src/components/StatCard.vue
+++ b/WebUI/src/components/StatCard.vue
@@ -25,7 +25,16 @@ const props = defineProps({
type: String,
default: 'default',
validator: (value) =>
- ['default', 'total', 'pending', 'available', 'new', 'score'].includes(value)
+ [
+ 'default',
+ 'total',
+ 'pending',
+ 'available',
+ 'new',
+ 'score',
+ 'invalid',
+ 'latency'
+ ].includes(value)
},
/** 图标组件 */
icon: {
@@ -45,6 +54,9 @@ const props = defineProps({
})
const displayValue = computed(() => {
+ if (props.value === '—' || props.value === '-') {
+ return props.value
+ }
const num = Number(props.value)
if (!isNaN(num) && num > 9999) {
return (num / 10000).toFixed(1) + 'w'
@@ -95,6 +107,16 @@ const displayValue = computed(() => {
filter: drop-shadow(0 0 8px rgba(146, 124, 255, 0.4));
}
+.stat-card.invalid .stat-icon {
+ color: var(--danger, #f56c6c);
+ filter: drop-shadow(0 0 8px rgba(245, 108, 108, 0.35));
+}
+
+.stat-card.latency .stat-icon {
+ color: var(--info);
+ filter: drop-shadow(0 0 8px rgba(56, 189, 248, 0.35));
+}
+
.stat-content {
display: flex;
align-items: center;
diff --git a/WebUI/src/composables/useStatsWebSocket.js b/WebUI/src/composables/useStatsWebSocket.js
index 7f9c7ba..0428236 100644
--- a/WebUI/src/composables/useStatsWebSocket.js
+++ b/WebUI/src/composables/useStatsWebSocket.js
@@ -9,12 +9,16 @@ const INITIAL_DELAY_MS = 1000
* @returns {string}
*/
export function resolveWebSocketStatsUrl() {
- const explicit = import.meta.env.VITE_WS_URL
+ const explicit =
+ typeof __WEBUI_WS_URL__ !== 'undefined' ? String(__WEBUI_WS_URL__).trim() : ''
if (explicit) {
- const t = String(explicit).trim().replace(/\/$/, '')
+ const t = explicit.replace(/\/$/, '')
return t.endsWith('/api/ws') ? t : `${t}/api/ws`
}
- const api = import.meta.env.VITE_API_BASE_URL || 'http://localhost:18080'
+ const api =
+ typeof __WEBUI_API_BASE_URL__ !== 'undefined'
+ ? __WEBUI_API_BASE_URL__
+ : 'http://127.0.0.1:18080'
const u = new URL(api)
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:'
u.pathname = '/api/ws'
diff --git a/WebUI/src/views/Dashboard.vue b/WebUI/src/views/Dashboard.vue
index 5eb75a0..a3f8361 100644
--- a/WebUI/src/views/Dashboard.vue
+++ b/WebUI/src/views/Dashboard.vue
@@ -31,13 +31,32 @@
type="score"
:icon="StarFilled"
:value="avgScore"
- label="平均分数"
+ label="平均分数(可用)"
+ />
+
+