Merge remote-tracking branch 'origin/main' into codex/usage-pending-reasoning-reset-expiry-20260712

# Conflicts:
#	crates/aether-ai-formats/src/formats/openai/responses/mod.rs
#	crates/aether-usage/runtime/src/runtime.rs
#	frontend/src/features/usage/components/UsageRecordsTable.vue
#	frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts
This commit is contained in:
MMEXA
2026-07-16 23:41:58 +08:00
1027 changed files with 48510 additions and 29027 deletions
+17 -97
View File
@@ -7,30 +7,12 @@
ref="statsPanelRef"
class="flex-1 min-w-0 flex flex-col"
>
<div class="mb-4 flex items-center justify-between gap-3">
<Badge
:variant="authStore.isAdmin ? 'default' : 'secondary'"
class="uppercase tracking-[0.45em]"
>
{{ dashboardModeLabel }}
</Badge>
<Button
type="button"
:variant="autoRefreshEnabled ? 'default' : 'outline'"
class="h-8 gap-1.5 px-3 text-xs"
:aria-pressed="autoRefreshEnabled"
:title="autoRefreshButtonTitle"
@click="toggleDashboardAutoRefresh"
>
<RefreshCw
class="h-3.5 w-3.5"
:class="{
'animate-spin': autoRefreshEnabled && autoRefreshLoading,
}"
/>
<span>{{ autoRefreshButtonLabel }}</span>
</Button>
</div>
<Badge
:variant="authStore.isAdmin ? 'default' : 'secondary'"
class="mb-4 self-start uppercase tracking-[0.45em]"
>
{{ dashboardModeLabel }}
</Badge>
<!-- 主要统计卡片 -->
<div class="grid grid-cols-2 gap-3 sm:gap-4 xl:grid-cols-4">
@@ -956,7 +938,6 @@ import {
Clock,
Database,
Shuffle,
RefreshCw,
} from "lucide-vue-next";
import { formatTokens, formatCurrency } from "@/utils/format";
import { parseDateLike } from "@/utils/date";
@@ -971,16 +952,10 @@ import type {
const authStore = useAuthStore();
const DASHBOARD_AUTO_REFRESH_INTERVAL_MS = 1_000;
type DashboardStatCard = Omit<DashboardStat, "icon"> & {
icon: Component;
};
type DashboardLoadOptions = {
silent?: boolean;
};
const statsPanelRef = ref<HTMLElement | null>(null);
const announcementsHeight = ref<number | null>(null);
const announcementsTimelineRef = ref<HTMLElement | null>(null);
@@ -1005,8 +980,6 @@ function checkScreenSize() {
let statsPanelObserver: ResizeObserver | null = null;
let announcementsTimelineObserver: ResizeObserver | null = null;
let dashboardAutoRefreshTimer: ReturnType<typeof setInterval> | null = null;
let dashboardRefreshPromise: Promise<void> | null = null;
function updateAnnouncementsHeight() {
if (typeof window === "undefined") return;
@@ -1107,14 +1080,6 @@ const getStatIconColor = (_index: number): string => {
// 统计数据
const stats = ref<DashboardStatCard[]>([]);
const autoRefreshEnabled = ref(false);
const autoRefreshLoading = ref(false);
const autoRefreshButtonLabel = computed(() =>
autoRefreshEnabled.value ? "自动刷新中" : "自动刷新",
);
const autoRefreshButtonTitle = computed(() =>
autoRefreshEnabled.value ? "点击关闭自动刷新" : "点击开启自动刷新",
);
const todayStats = ref<{
requests: number;
tokens: number;
@@ -1499,55 +1464,17 @@ const dailyUsageTrendChartOptions = computed<ChartOptions<"line">>(() => {
};
});
async function refreshDashboardContent(options: DashboardLoadOptions = {}) {
if (dashboardRefreshPromise) {
return dashboardRefreshPromise;
}
autoRefreshLoading.value = true;
dashboardRefreshPromise = (async () => {
await Promise.all([loadDashboardData(options), loadDailyStats(options)]);
await nextTick();
updateAnnouncementsHeight();
updateTimelineLine();
})().finally(() => {
autoRefreshLoading.value = false;
dashboardRefreshPromise = null;
});
return dashboardRefreshPromise;
}
function startDashboardAutoRefresh() {
if (typeof window === "undefined") return;
stopDashboardAutoRefresh();
dashboardAutoRefreshTimer = window.setInterval(() => {
void refreshDashboardContent({ silent: true });
}, DASHBOARD_AUTO_REFRESH_INTERVAL_MS);
}
function stopDashboardAutoRefresh() {
if (dashboardAutoRefreshTimer !== null) {
clearInterval(dashboardAutoRefreshTimer);
dashboardAutoRefreshTimer = null;
}
}
function toggleDashboardAutoRefresh() {
autoRefreshEnabled.value = !autoRefreshEnabled.value;
if (autoRefreshEnabled.value) {
startDashboardAutoRefresh();
void refreshDashboardContent({ silent: true });
} else {
stopDashboardAutoRefresh();
}
}
onMounted(async () => {
checkScreenSize();
setupResizeObserver();
if (typeof window !== "undefined") {
window.addEventListener("resize", handleWindowResize);
}
await Promise.all([refreshDashboardContent(), loadAnnouncements()]);
await Promise.all([
loadDashboardData(),
loadDailyStats(),
loadAnnouncements(),
]);
await nextTick();
setupTimelineResizeObserver();
updateAnnouncementsHeight();
@@ -1555,7 +1482,6 @@ onMounted(async () => {
});
onBeforeUnmount(() => {
stopDashboardAutoRefresh();
if (typeof window !== "undefined") {
window.removeEventListener("resize", handleWindowResize);
}
@@ -1575,10 +1501,8 @@ onBeforeUnmount(() => {
dailyStatsRequestId += 1;
});
async function loadDashboardData(options: DashboardLoadOptions = {}) {
if (!options.silent) {
loading.value = true;
}
async function loadDashboardData() {
loading.value = true;
try {
const statsData = await dashboardApi.getStats({
timezone: dailyTimeRange.value.timezone,
@@ -1604,21 +1528,17 @@ async function loadDashboardData(options: DashboardLoadOptions = {}) {
userMonthlyCost.value = statsData.monthly_cost;
}
} finally {
if (!options.silent) {
loading.value = false;
}
loading.value = false;
}
}
async function loadDailyStats(options: DashboardLoadOptions = {}) {
async function loadDailyStats() {
if (dailyStatsLoadPromise) {
hasPendingDailyStatsLoad = true;
return dailyStatsLoadPromise;
}
const requestId = ++dailyStatsRequestId;
if (!options.silent) {
loadingDaily.value = true;
}
loadingDaily.value = true;
dailyStatsLoadPromise = (async () => {
try {
const response = await dashboardApi.getDailyStats(dailyTimeRange.value);
@@ -1630,7 +1550,7 @@ async function loadDailyStats(options: DashboardLoadOptions = {}) {
dailyStats.value = [];
providerSummary.value = [];
} finally {
if (requestId === dailyStatsRequestId && !options.silent) {
if (requestId === dailyStatsRequestId) {
loadingDaily.value = false;
}
}
+11 -4
View File
@@ -154,6 +154,7 @@ import {
getDateRangeFromPeriod
} from '@/features/usage/composables'
import { reconcileActiveRequestDiscovery } from '@/features/usage/utils/activeRequestDiscovery'
import { mergeUsageRecordFirstByteTimeMs } from '@/features/usage/utils/recordSync'
import {
hasUsageFallback,
isUsageRecordFailed,
@@ -521,7 +522,10 @@ async function pollActiveRequests() {
record.actual_cost = update.actual_cost ?? undefined
record.rate_multiplier = update.rate_multiplier ?? undefined
record.response_time_ms = update.response_time_ms ?? undefined
record.first_byte_time_ms = update.first_byte_time_ms ?? undefined
record.first_byte_time_ms = mergeUsageRecordFirstByteTimeMs(
record.first_byte_time_ms,
update.first_byte_time_ms
)
if ('updated_at' in update) {
record.updated_at = typeof update.updated_at === 'string' ? update.updated_at : null
}
@@ -822,6 +826,7 @@ onMounted(async () => {
const heatmapPromise = loadHeatmapData().catch(err => {
log.error('加载热力图数据失败:', err)
})
const adminUsersPromise = loadAdminUsers()
await loadRecords(
{ page: currentPage.value, pageSize: pageSize.value },
@@ -830,8 +835,7 @@ onMounted(async () => {
)
void (async () => {
await refreshAdminAnalytics({ force: true, preserveOnFailure: false })
await heatmapPromise
await loadAdminUsers()
await Promise.all([heatmapPromise, adminUsersPromise])
})()
} else {
// 用户页面:loadStats 已包含记录加载,不需要单独调用 loadRecords
@@ -1109,7 +1113,10 @@ function handleDetailRequestState(update: {
record.response_time_ms = update.responseTimeMs
}
if ('firstByteTimeMs' in update) {
record.first_byte_time_ms = update.firstByteTimeMs ?? undefined
record.first_byte_time_ms = mergeUsageRecordFirstByteTimeMs(
record.first_byte_time_ms,
update.firstByteTimeMs
)
}
if ('isStream' in update && typeof update.isStream === 'boolean') {
record.is_stream = update.isStream
@@ -101,7 +101,6 @@ vi.mock('lucide-vue-next', async () => {
Clock: Icon,
Database: Icon,
Shuffle: Icon,
RefreshCw: Icon,
}
})
@@ -168,3 +167,27 @@ describe('Dashboard ordinary user wallet card', () => {
expect(root.textContent).toContain('套餐额度 $100.00 · 钱包余额 $10.00')
})
})
describe('Dashboard refresh controls', () => {
it('does not render or run automatic refresh', async () => {
vi.useFakeTimers()
dashboardApiMocks.getStats.mockResolvedValue({ stats: [] })
try {
const root = mountDashboard()
await settle()
expect(root.textContent).not.toContain('自动刷新')
expect(dashboardApiMocks.getStats).toHaveBeenCalledTimes(1)
expect(dashboardApiMocks.getDailyStats).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(60_000)
await settle()
expect(dashboardApiMocks.getStats).toHaveBeenCalledTimes(1)
expect(dashboardApiMocks.getDailyStats).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
})
@@ -0,0 +1,23 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
resolve(process.cwd(), 'src/views/shared/Usage.vue'),
'utf8',
)
describe('admin usage initial loading', () => {
it('starts the user filter request before analytics completes', () => {
const mountedBlock = source
.split('onMounted(async () => {')[1]
?.split('// 处理时间范围变化')[0]
expect(mountedBlock).toBeTruthy()
expect(mountedBlock).toContain('const adminUsersPromise = loadAdminUsers()')
expect(mountedBlock).toContain('Promise.all([heatmapPromise, adminUsersPromise])')
expect(mountedBlock?.indexOf('const adminUsersPromise = loadAdminUsers()'))
.toBeLessThan(mountedBlock?.indexOf('await loadRecords(') ?? -1)
expect(mountedBlock).not.toContain('await loadAdminUsers()')
})
})