Compare commits

...
4 Commits
Author SHA1 Message Date
ZyyandCodex bf23658aaf chore: bump version to 3.2.3
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 01:25:44 +08:00
ZyyandCodex bc4728332e fix(frontend): polish dashboard and table interactions
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 01:24:49 +08:00
ZyyandCodex 2cb5cda077 fix(quota): report exhausted usage as full
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 01:07:20 +08:00
ZyyandCodex 9d90dedb81 fix(frontend): refresh account data without table loading
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 01:07:12 +08:00
15 changed files with 146 additions and 44 deletions
@@ -631,25 +631,74 @@ impl ProviderQuota {
/// 这里只解释跨 Provider 共享的窗口语义,绝不读取 Provider 私有 JSON。
#[must_use]
pub fn representative_used_percent(&self) -> Option<f64> {
if self.limit_reached {
return Some(100.0);
}
self.representative_used_window()
.map(|(_, used_percent)| used_percent)
}
/// 将已确认的账号级额度耗尽事实投影到展示窗口。
///
/// Provider 已指出具体触顶窗口时只归一化这些窗口;否则归一化 Dashboard
/// 同样会选择的代表窗口,避免把多个独立额度窗口全部伪造成已用尽。
pub fn apply_limit_reached_display(&mut self) {
if !self.limit_reached {
return;
}
let reached_windows = self
.windows
.iter()
.enumerate()
.filter_map(|(index, window)| window.limit_reached.then_some(index))
.collect::<Vec<_>>();
if !reached_windows.is_empty() {
for index in reached_windows {
self.windows[index].used_percent = Some(100.0);
}
return;
}
let representative = self
.representative_used_window()
.map(|(index, _)| index)
.or_else(|| {
self.windows
.iter()
.enumerate()
.min_by_key(|(index, window)| (quota_usage_priority(window), *index))
.map(|(index, _)| index)
});
if let Some(index) = representative {
self.windows[index].used_percent = Some(100.0);
self.windows[index].limit_reached = true;
}
}
fn representative_used_window(&self) -> Option<(usize, f64)> {
self.windows
.iter()
.enumerate()
.filter_map(|window| {
let (index, window) = window;
let used_percent = window
.used_percent
.filter(|value| value.is_finite())
.map(|value| value.clamp(0.0, 100.0))?;
Some((quota_usage_priority(window), used_percent))
Some((index, quota_usage_priority(window), used_percent))
})
.fold(None::<(u8, f64)>, |selected, candidate| match selected {
Some(current)
if current.0 < candidate.0
|| (current.0 == candidate.0 && current.1 >= candidate.1) =>
{
.fold(
None::<(usize, u8, f64)>,
|selected, candidate| match selected {
Some(current)
}
_ => Some(candidate),
})
.map(|(_, used_percent)| used_percent)
if current.1 < candidate.1
|| (current.1 == candidate.1 && current.2 >= candidate.2) =>
{
Some(current)
}
_ => Some(candidate),
},
)
.map(|(index, _, used_percent)| (index, used_percent))
}
}
@@ -135,6 +135,50 @@ fn representative_quota_should_prefer_short_window_and_highest_usage() {
assert_eq!(quota.representative_used_percent(), Some(45.0));
}
#[test]
fn exhausted_quota_should_project_full_usage_to_only_the_representative_window() {
let mut quota = ProviderQuota {
observed_at: None,
refresh_token_expires_at: None,
windows: vec![
quota_window("monthly", Some(2_592_000), Some(99.0)),
quota_window("shortTerm", Some(604_800), Some(80.0)),
quota_window("shortTerm", Some(18_000), Some(30.0)),
quota_window("shortTerm", Some(14_400), Some(95.0)),
],
limit_reached: true,
provider_data: None,
};
assert_eq!(quota.representative_used_percent(), Some(100.0));
quota.apply_limit_reached_display();
assert_eq!(quota.windows[0].used_percent, Some(99.0));
assert_eq!(quota.windows[1].used_percent, Some(80.0));
assert_eq!(quota.windows[2].used_percent, Some(30.0));
assert_eq!(quota.windows[3].used_percent, Some(100.0));
assert!(quota.windows[3].limit_reached);
}
#[test]
fn exhausted_quota_should_preserve_the_provider_identified_reached_window() {
let mut reached = quota_window("monthly", Some(2_592_000), Some(98.0));
reached.limit_reached = true;
let mut quota = ProviderQuota {
observed_at: None,
refresh_token_expires_at: None,
windows: vec![reached, quota_window("shortTerm", Some(18_000), Some(95.0))],
limit_reached: true,
provider_data: None,
};
quota.apply_limit_reached_display();
assert_eq!(quota.windows[0].used_percent, Some(100.0));
assert_eq!(quota.windows[1].used_percent, Some(95.0));
assert!(!quota.windows[1].limit_reached);
}
fn quota_window(
group: &str,
window_seconds: Option<u64>,
@@ -1574,10 +1574,11 @@ fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> AccountView {
}
fn account_quota_view(
quota: ProviderQuota,
mut quota: ProviderQuota,
rate_limited_until: Option<DateTime<Utc>>,
now: DateTime<Utc>,
) -> (AccountQuotaView, Option<String>) {
quota.apply_limit_reached_display();
let refresh_token_expires_at = quota
.refresh_token_expires_at
.map(|value| china_rfc3339(&value));
@@ -82,6 +82,7 @@ const loadingIndicatorStyle = computed(() => {
const classes = computed(() => [
'inline-flex items-center justify-center border-0 font-bold leading-[1.15] transition-[background-color,box-shadow,color,opacity,transform] duration-150 cursor-pointer outline-none motion-safe:active:translate-y-px motion-safe:active:scale-[0.985]',
props.iconOnly ? iconOnlySizeClasses[props.size] : sizeClasses[props.size],
props.iconOnly ? 'shrink-0' : undefined,
props.iconOnly ? '' : 'rounded-(--cp-button-radius-base)',
'focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-(--cp-disabled-bg) disabled:text-(--cp-disabled-text) disabled:shadow-none',
+10 -6
View File
@@ -27,10 +27,12 @@ export function usePagedQuery<Result extends PageResult>(options: {
const error = shallowRef('')
let requestSequence = 0
async function execute() {
async function execute(execution: { silent?: boolean } = {}) {
const requestId = ++requestSequence
loading.value = true
error.value = ''
if (!execution.silent) {
loading.value = true
error.value = ''
}
try {
const result = await options.load({
@@ -42,7 +44,7 @@ export function usePagedQuery<Result extends PageResult>(options: {
if (result.items.length === 0 && result.page.total > 0 && result.page.page > 1) {
page.value = clamp(result.page.totalPages, 1, Number.POSITIVE_INFINITY)
return execute()
return execute(execution)
}
items.value = result.items
@@ -55,8 +57,10 @@ export function usePagedQuery<Result extends PageResult>(options: {
catch (cause: unknown) {
if (requestId !== requestSequence)
return false
error.value = errorMessage(cause, '加载失败')
options.onError?.(cause)
if (!execution.silent) {
error.value = errorMessage(cause, '加载失败')
options.onError?.(cause)
}
return false
}
finally {
@@ -91,8 +91,16 @@ const statusView = computed(() => {
iconClass: 'text-(--cp-success)',
}
}
if (updateInfo.value) {
return {
label: '已是最新',
icon: CheckCircle2,
badge: 'bg-(--cp-success-bg) text-(--cp-success-text)',
iconClass: 'text-(--cp-success)',
}
}
return {
label: updateInfo.value ? '已是最新' : '未检查',
label: '未检查',
icon: CheckCircle2,
badge: 'bg-(--cp-bg-muted) text-(--cp-text-secondary)',
iconClass: 'text-(--cp-text-muted)',
@@ -318,6 +326,7 @@ watch(
<BaseModal
v-model="open"
title="系统更新"
description="检查版本、查看发布说明并执行在线更新"
variant="success"
width="820px"
body-max-height="72dvh"
@@ -66,8 +66,8 @@ export const accountGroupColumns = [
{
key: 'actions',
label: '操作',
width: '100px',
minWidth: '100px',
width: '136px',
minWidth: '136px',
cellClass,
},
] satisfies BaseTableColumn<AccountGroup>[]
@@ -45,6 +45,7 @@ const quotaWindows = computed(() => orderedPanelQuotaWindows(props.account.quota
variant="ghost"
size="sm"
title="刷新额度"
spin-icon-on-loading
:loading="refreshing"
:disabled="refreshing"
@click="emit('refreshQuota', account.id)"
@@ -141,6 +141,7 @@ export function useAccountsQuery() {
loading: query.loading,
accounts: query.items,
loadAccounts: query.execute,
refreshAccountsSilently: () => query.execute({ silent: true }),
searchQuery,
providerQuery,
statusQuery,
+2 -2
View File
@@ -121,8 +121,8 @@ export const accountColumns = [
{
key: 'actions',
label: '操作',
width: '120px',
minWidth: '120px',
width: '136px',
minWidth: '136px',
},
] satisfies BaseTableColumn<AccountRow>[]
+2 -1
View File
@@ -37,6 +37,7 @@ const {
loading,
accounts,
loadAccounts,
refreshAccountsSilently,
searchQuery,
providerQuery,
statusQuery,
@@ -108,7 +109,7 @@ const {
openConnectionTest,
handleRefreshConnectionTestModels,
handleTestConnection,
} = useAccountConnectionTest({ reload: loadAccounts })
} = useAccountConnectionTest({ reload: refreshAccountsSilently })
const {
expandedAccountIds,
+2 -2
View File
@@ -43,8 +43,8 @@ export const apiKeyColumns = [
{
key: 'actions',
label: '操作',
width: '172px',
minWidth: '172px',
width: '208px',
minWidth: '208px',
headerClass: relaxedActionCellClass,
cellClass: relaxedActionCellClass,
},
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { dashboardSnapshotView, MetricTone } from '../composables/presenter'
import { CircleCheck, CircleOff, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
import { CircleCheck, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
import { clamp } from 'es-toolkit'
import { computed } from 'vue'
@@ -106,8 +106,7 @@ const statusRows = computed(() => {
{ label: '正常', description: '暂无账号池观测', value: '—', tone: 'success', icon: CircleCheck },
{ label: '配额耗尽', description: '暂无账号池观测', value: '—', tone: 'warning', icon: TriangleAlert },
{ label: '限流中', description: '暂无账号池观测', value: '—', tone: 'normal', icon: RefreshCw },
{ label: '已停用', description: '暂无账号池观测', value: '—', tone: 'normal', icon: CircleOff },
{ label: '错误', description: '暂无账号池观测', value: '—', tone: 'danger', icon: ShieldAlert },
{ label: '待处理', description: '停用 — · 错误 —', value: '—', tone: 'danger', icon: ShieldAlert },
]
}
@@ -134,16 +133,9 @@ const statusRows = computed(() => {
icon: RefreshCw,
},
{
label: '已停用',
description: '账号已停用',
value: String(counts.disabled),
tone: 'normal',
icon: CircleOff,
},
{
label: '错误',
description: '账号状态异常',
value: String(counts.error),
label: '待处理',
description: `停用 ${counts.disabled} · 错误 ${counts.error}`,
value: String(counts.disabled + counts.error),
tone: 'danger',
icon: ShieldAlert,
},
@@ -165,8 +157,7 @@ const statusBars = computed(() => {
{ pct: (counts.normal / counts.total) * 100, cls: 'bg-(--cp-success)' },
{ pct: (counts.quotaExhausted / counts.total) * 100, cls: 'bg-(--cp-warning)' },
{ pct: (counts.rateLimited / counts.total) * 100, cls: 'bg-(--cp-normal)' },
{ pct: (counts.disabled / counts.total) * 100, cls: 'bg-(--cp-text-secondary)' },
{ pct: (counts.error / counts.total) * 100, cls: 'bg-(--cp-danger)' },
{ pct: ((counts.disabled + counts.error) / counts.total) * 100, cls: 'bg-(--cp-danger)' },
].filter(b => b.pct > 0)
})
</script>
@@ -333,11 +324,11 @@ const statusBars = computed(() => {
</div>
</div>
<div class="mt-5 grid h-67 w-full grid-rows-5 gap-2">
<div class="mt-6.5 grid h-65.5 w-full gap-2.5">
<div
v-for="row in statusRows"
:key="row.label"
class="grid min-h-0 grid-cols-[28px_14px_minmax(0,1fr)_76px] items-center rounded-[14px] bg-(--cp-bg-subtle) px-3.5"
class="grid h-14.5 grid-cols-[28px_14px_minmax(0,1fr)_76px] items-center rounded-[14px] bg-(--cp-bg-subtle) px-3.5"
>
<span
class="inline-flex size-7 items-center justify-center rounded-[9px]"
@@ -45,7 +45,7 @@ const columns: BaseTableColumn<BackupRecord>[] = [
{ key: 'expiresAt', label: '过期时间', width: 170 },
{ key: 'trigger', label: '触发方式', width: 100 },
{ key: 'startedAt', label: '开始时间', width: 170 },
{ key: 'actions', label: '操作', width: 96, fixed: 'right' },
{ key: 'actions', label: '操作', width: 112, minWidth: 112, fixed: 'right' },
]
function triggerLabel(value: string): string {
+1 -1
View File
@@ -1 +1 @@
version: "3.2.2"
version: "3.2.3"