diff --git a/frontend/src/components/account/AccountPlanBadge.vue b/frontend/src/components/account/AccountPlanBadge.vue
index f995c222a..930552bae 100644
--- a/frontend/src/components/account/AccountPlanBadge.vue
+++ b/frontend/src/components/account/AccountPlanBadge.vue
@@ -13,6 +13,7 @@ const props = withDefaults(
upstream?: AccountUpstream | null
upstreamPeer?: boolean | null
planTypeDisplay: string
+ count?: number
size?: 'xs' | 'sm' | 'md'
}>(),
{
@@ -62,13 +63,14 @@ const paletteClass = computed(() => {
-
+
{{ displayText }}
+ {{ count }}
diff --git a/frontend/src/components/account/AccountQuotaWindowDetails.vue b/frontend/src/components/account/AccountQuotaWindowDetails.vue
index c2003bbc0..60a4f7b0b 100644
--- a/frontend/src/components/account/AccountQuotaWindowDetails.vue
+++ b/frontend/src/components/account/AccountQuotaWindowDetails.vue
@@ -116,10 +116,6 @@ function quotaWindowCode(windowSeconds: number | null, role: AccountQuotaWindow[
-
- {{ item.observationDisplay }}
-
-
-
diff --git a/frontend/src/components/account/AccountUpstreamStatus.vue b/frontend/src/components/account/AccountUpstreamStatus.vue
index db79beba0..69c849419 100644
--- a/frontend/src/components/account/AccountUpstreamStatus.vue
+++ b/frontend/src/components/account/AccountUpstreamStatus.vue
@@ -13,15 +13,15 @@ const status = computed(() => {
return accountUpstreamErrorMessage(upstream.lastError)
if (!upstream.observedAt)
return ''
- const observed = t('accounts.upstream.observedAt', {
+ return t('accounts.upstream.observedAt', {
time: formatRelativeTime(upstream.observedAtRelative),
})
- return upstream.stale
- ? `${observed} · ${t('accounts.upstream.stale')}`
- : observed
})
const detail = computed(() => [
props.upstream.observedAtDisplay,
+ props.upstream.stale
+ ? t('accounts.upstream.stale')
+ : null,
accountUpstreamErrorMessage(props.upstream.lastError),
].filter(Boolean).join(' · '))
diff --git a/frontend/src/components/account/account-usage-window/presenter.ts b/frontend/src/components/account/account-usage-window/presenter.ts
index 305c874b4..c400a1f81 100644
--- a/frontend/src/components/account/account-usage-window/presenter.ts
+++ b/frontend/src/components/account/account-usage-window/presenter.ts
@@ -381,8 +381,11 @@ function rollingWindowDurationDisplay(windowSeconds: number | null | undefined)
}
export function quotaWindowPresentation(window: AccountQuotaWindow, minimumWidth: string) {
- const percent = clamp(window.usedPercent ?? 0, 0, 100)
- const tone = quotaWindowTone(window.stale ? null : window.usedPercent)
+ const unlimited = window.amount?.totalDisplay === '∞'
+ const percent = unlimited
+ ? 100
+ : clamp(window.usedPercent ?? 0, 0, 100)
+ const tone = quotaWindowTone(unlimited ? 0 : window.usedPercent)
return {
barStyle: {
width: `${percent}%`,
diff --git a/frontend/src/i18n/locales/en/accounts.json b/frontend/src/i18n/locales/en/accounts.json
index 379b872ac..4af3dc75f 100644
--- a/frontend/src/i18n/locales/en/accounts.json
+++ b/frontend/src/i18n/locales/en/accounts.json
@@ -627,6 +627,8 @@
"unavailable": "Upstream unavailable",
"pool": "Shared pool",
"ready": "available",
+ "otherPlans": "Other plans",
+ "morePlans": "View {count} more plans",
"nodeQuota": "Node quota",
"quota": "Upstream quota"
}
diff --git a/frontend/src/i18n/locales/zh-Hans/accounts.json b/frontend/src/i18n/locales/zh-Hans/accounts.json
index c8dbe878e..7072331c3 100644
--- a/frontend/src/i18n/locales/zh-Hans/accounts.json
+++ b/frontend/src/i18n/locales/zh-Hans/accounts.json
@@ -627,6 +627,8 @@
"unavailable": "上游暂不可用",
"pool": "共享池",
"ready": "可用",
+ "otherPlans": "其他套餐",
+ "morePlans": "查看其余 {count} 种套餐",
"nodeQuota": "节点额度",
"quota": "上游额度"
}
diff --git a/frontend/src/i18n/locales/zh-Hant/accounts.json b/frontend/src/i18n/locales/zh-Hant/accounts.json
index 3ab70d94b..011a5ed45 100644
--- a/frontend/src/i18n/locales/zh-Hant/accounts.json
+++ b/frontend/src/i18n/locales/zh-Hant/accounts.json
@@ -627,6 +627,8 @@
"unavailable": "上游暫不可用",
"pool": "共享池",
"ready": "可用",
+ "otherPlans": "其他套餐",
+ "morePlans": "查看其餘 {count} 種套餐",
"nodeQuota": "節點額度",
"quota": "上游額度"
}
diff --git a/frontend/src/views/accounts/components/account-quota-panel/PoolChart.vue b/frontend/src/views/accounts/components/account-quota-panel/PoolChart.vue
index 18b829608..12adda0eb 100644
--- a/frontend/src/views/accounts/components/account-quota-panel/PoolChart.vue
+++ b/frontend/src/views/accounts/components/account-quota-panel/PoolChart.vue
@@ -33,6 +33,12 @@ const activeNode = computed(() => activeIndex.value === null
const activeWindows = computed(() => groupedAccountQuotaWindows(
orderedPanelQuotaWindows(activeNode.value?.windows ?? []),
).flatMap(entry => entry.windows))
+const remainingLabel = computed(() => {
+ const remaining = activeNode.value?.remainingPercent
+ return remaining == null
+ ? ''
+ : t('accounts.quota.remaining', { remaining: Math.round(remaining * 10) / 10 })
+})
const canZoom = computed(() => props.nodes.length > MIN_VISIBLE_NODES)
let chart: EChartsType | undefined
let highlightedIndex: number | null = null
@@ -49,6 +55,7 @@ const option = computed(() => {
unknown: color('color-border', ''),
}
const nodes = props.nodes
+ const trackFill = color('color-border-secondary', '')
return {
animation: reducedMotion.value !== 'reduce',
animationDuration: 0,
@@ -89,13 +96,15 @@ const option = computed(() => {
series: [
{
type: 'custom',
+ // 节点命中由容器统一处理,避免色条与空隙触发不同光标
+ silent: true,
animationThreshold: nodes.length,
clip: true,
dimensions: ['account', 'value'],
encode: { x: 0, y: 1 },
data: nodes.map((node, index) => ({
id: node.id,
- value: [index, 1],
+ value: [index, (node.remainingPercent ?? 0) / 100],
})),
renderItem: (_params, api) => {
const index = Number(api.value(0))
@@ -103,19 +112,34 @@ const option = computed(() => {
const pitch = api.coord([index + 1, 0])[0]! - x
const width = Math.min(8, pitch * 0.72)
const fill = colors[nodes[index]!.tone]
+ const shape = {
+ x: x - width / 2,
+ y: 7,
+ width,
+ height: 22,
+ r: Math.min(2, width / 2),
+ }
+ // 耗尽时保留红色底线,未知额度只显示灰色轨道
+ const height = nodes[index]!.remainingPercent === null
+ ? 0
+ : Math.max(2, Number(api.value(1)) * 22)
return {
- type: 'rect',
- shape: {
- x: x - width / 2,
- y: 7,
- width,
- height: 22,
- r: Math.min(2, width / 2),
- },
- style: { fill, stroke: fill, lineWidth: 0 },
- emphasis: {
- style: { fill, stroke: fill, lineWidth: 2 },
- },
+ type: 'group',
+ children: [
+ {
+ type: 'rect',
+ shape,
+ style: { fill: trackFill, stroke: fill, lineWidth: 0 },
+ emphasis: {
+ style: { stroke: fill, lineWidth: 2 },
+ },
+ },
+ {
+ type: 'rect',
+ shape: { ...shape, y: 29 - height, height },
+ style: { fill },
+ },
+ ],
}
},
},
@@ -260,12 +284,12 @@ onBeforeUnmount(() => chart?.off('datazoom', onDataZoom))
-
+
diff --git a/frontend/src/views/accounts/components/account-quota-panel/UpstreamPool.vue b/frontend/src/views/accounts/components/account-quota-panel/UpstreamPool.vue
index 938fcf875..341417177 100644
--- a/frontend/src/views/accounts/components/account-quota-panel/UpstreamPool.vue
+++ b/frontend/src/views/accounts/components/account-quota-panel/UpstreamPool.vue
@@ -1,5 +1,6 @@
-
+
{{ pool.states.ready }}
/ {{ pool.accountCount }}
-
-
-
- {{ plan.count }}
-
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/accounts/components/account-quota-panel/index.vue b/frontend/src/views/accounts/components/account-quota-panel/index.vue
index d1362c5bf..38b5f8376 100644
--- a/frontend/src/views/accounts/components/account-quota-panel/index.vue
+++ b/frontend/src/views/accounts/components/account-quota-panel/index.vue
@@ -1,7 +1,7 @@
-
+
{{ account.upstream?.discovered ? t('accounts.upstream.quota') : t('accounts.quota.accountLimit') }}
@@ -78,7 +78,6 @@ watch(hasPersonalInfo, (available) => {
>
- ·
{{ lastRefreshed }}
@@ -130,14 +129,18 @@ watch(hasPersonalInfo, (available) => {
surface="none"
/>
-
-
-
+
diff --git a/frontend/src/views/accounts/components/account-quota-panel/pool.ts b/frontend/src/views/accounts/components/account-quota-panel/pool.ts
index 0b8684553..489b8cc59 100644
--- a/frontend/src/views/accounts/components/account-quota-panel/pool.ts
+++ b/frontend/src/views/accounts/components/account-quota-panel/pool.ts
@@ -1,4 +1,5 @@
import type { AccountQuotaWindow } from '@/api'
+import { clamp } from 'es-toolkit'
import { quotaWindowTone } from '@/components/account/account-usage-window/presenter'
// 与互联协议的账号上限一致,汇总数字不能触发无界节点分配
@@ -7,6 +8,7 @@ const MAX_POOL_NODES = 3_000
export interface PoolNode {
id: string
windows: AccountQuotaWindow[]
+ remainingPercent: number | null
tone: ReturnType
}
@@ -25,21 +27,31 @@ export function poolNodes(windows: AccountQuotaWindow[], count?: number): PoolNo
accounts.set(window.sourceAccountId, group)
}
const nodes: PoolNode[] = [...accounts].map(([id, windows]) => {
- const percentages = windows.filter(window => !window.stale)
+ const percentages = windows
.map(window => window.limitReached ? 100 : window.usedPercent)
.filter((value): value is number => typeof value === 'number')
+ const usedPercent = percentages.length
+ ? Math.max(...percentages)
+ : null
return {
id,
windows,
- tone: quotaWindowTone(percentages.length
- ? Math.max(...percentages)
- : null),
+ remainingPercent: usedPercent === null
+ ? null
+ : 100 - clamp(usedPercent, 0, 100),
+ tone: quotaWindowTone(usedPercent),
}
})
// 未公开逐账号额度时仅补灰色位置,不能把汇总可用数解释为各账号剩余额度
if (count !== undefined) {
- while (nodes.length < limit)
- nodes.push({ id: `unobserved:${nodes.length}`, windows: [], tone: 'unknown' })
+ while (nodes.length < limit) {
+ nodes.push({
+ id: `unobserved:${nodes.length}`,
+ windows: [],
+ remainingPercent: null,
+ tone: 'unknown',
+ })
+ }
}
return nodes
}
diff --git a/frontend/src/views/accounts/components/account-quota-summary-cell/Entry.vue b/frontend/src/views/accounts/components/account-quota-summary-cell/Entry.vue
index 696b12ec5..f7e9d6b9b 100644
--- a/frontend/src/views/accounts/components/account-quota-summary-cell/Entry.vue
+++ b/frontend/src/views/accounts/components/account-quota-summary-cell/Entry.vue
@@ -32,7 +32,7 @@ const hasQuotaWindow = computed(() => props.windows.some(window => typeof window
-
+