forked from EduCraft/curriculum-project-hub
4e7b158ff9
Silo hostname already carries tenancy. Admin SPA routes become /admin/..., legacy /admin/org/:slug/* bookmarks redirect, login lands on /admin.
242 lines
7.7 KiB
Svelte
242 lines
7.7 KiB
Svelte
<script lang="ts">
|
||
import { page } from '$app/state';
|
||
import { api, type UsageReport, type UsageBreakdownRow } from '$lib/api';
|
||
import { session } from '$lib/session';
|
||
import { resolveOrg } from '$lib/org';
|
||
import {
|
||
fmtCost,
|
||
fmtDateOnly,
|
||
fmtNum,
|
||
fmtQuantity,
|
||
fmtTokens,
|
||
usageKindLabel,
|
||
} from '$lib/format';
|
||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||
import StatCard from '$lib/components/StatCard.svelte';
|
||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||
|
||
const org = $derived(resolveOrg($session.me, page.url.search));
|
||
const slug = $derived(org?.slug ?? '');
|
||
|
||
let usage = $state<UsageReport | null>(null);
|
||
let loading = $state(true);
|
||
let error = $state<string | null>(null);
|
||
let from = $state('');
|
||
let to = $state('');
|
||
|
||
function toIsoStart(dateLocal: string): string | undefined {
|
||
if (!dateLocal) return undefined;
|
||
const d = new Date(`${dateLocal}T00:00:00`);
|
||
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
|
||
}
|
||
|
||
function toIsoEnd(dateLocal: string): string | undefined {
|
||
if (!dateLocal) return undefined;
|
||
const d = new Date(`${dateLocal}T23:59:59.999`);
|
||
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
|
||
}
|
||
|
||
async function load() {
|
||
if (!slug) return;
|
||
loading = true;
|
||
error = null;
|
||
try {
|
||
usage = await api.usage(slug, {
|
||
...(toIsoStart(from) !== undefined ? { from: toIsoStart(from) } : {}),
|
||
...(toIsoEnd(to) !== undefined ? { to: toIsoEnd(to) } : {}),
|
||
});
|
||
} catch (err) {
|
||
error = err instanceof Error ? err.message : String(err);
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
}
|
||
|
||
function clearRange() {
|
||
from = '';
|
||
to = '';
|
||
void load();
|
||
}
|
||
|
||
function sourceLabel(row: UsageBreakdownRow): string {
|
||
if (row.capabilityId) return row.capabilityId;
|
||
if (row.model) return row.model;
|
||
return '—';
|
||
}
|
||
|
||
function meterCell(row: UsageBreakdownRow): string {
|
||
if (row.unit) return fmtQuantity(row.quantity, row.unit);
|
||
if (row.inputTokens > 0 || row.outputTokens > 0) return fmtTokens(row.inputTokens, row.outputTokens);
|
||
return '—';
|
||
}
|
||
|
||
function meterHint(row: UsageBreakdownRow): string {
|
||
if (row.unit) return '非 token 计量';
|
||
if (row.inputTokens > 0 || row.outputTokens > 0) return 'in / out tokens';
|
||
return '无计量';
|
||
}
|
||
|
||
$effect(() => {
|
||
if (slug) void load();
|
||
});
|
||
</script>
|
||
|
||
{#if loading && !usage}
|
||
<LoadingState />
|
||
{:else if error && !usage}
|
||
<ErrorBanner message={error} onretry={load} />
|
||
{:else if usage}
|
||
<PageHeader
|
||
title="用量报告"
|
||
description="按 UsageFact 分账:模型完成与外部能力(PDF→MD、ASR 等)分开汇总。缺失成本计为未知,不为 0。"
|
||
/>
|
||
|
||
<div class="saas-card-pad mb-6">
|
||
<div class="flex flex-wrap items-end gap-3">
|
||
<div>
|
||
<label class="saas-label" for="usage-from">从</label>
|
||
<input id="usage-from" class="saas-input" type="date" bind:value={from} />
|
||
</div>
|
||
<div>
|
||
<label class="saas-label" for="usage-to">到</label>
|
||
<input id="usage-to" class="saas-input" type="date" bind:value={to} />
|
||
</div>
|
||
<button class="saas-btn-primary py-1.5! text-sm" type="button" onclick={load} disabled={loading}>
|
||
{loading ? '加载中…' : '应用筛选'}
|
||
</button>
|
||
<button class="saas-btn-secondary py-1.5! text-sm" type="button" onclick={clearRange} disabled={loading}>
|
||
清除
|
||
</button>
|
||
{#if usage.from || usage.to}
|
||
<p class="saas-muted grow text-right text-xs">
|
||
窗口:
|
||
{usage.from ? fmtDateOnly(usage.from) : '—'}
|
||
→
|
||
{usage.to ? fmtDateOnly(usage.to) : '—'}
|
||
</p>
|
||
{/if}
|
||
</div>
|
||
{#if error}
|
||
<p class="mt-3 text-sm text-error-700">{error}</p>
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
<StatCard label="运行总数" value={fmtNum(usage.totals.runCount)} />
|
||
<StatCard
|
||
label="有成本 / 无成本"
|
||
value={`${fmtNum(usage.totals.runsWithCost)} / ${fmtNum(usage.totals.runsWithoutCost)}`}
|
||
hint="无成本 = 成本未知,不是 $0"
|
||
/>
|
||
<StatCard label="成本 (USD)" value={fmtCost(usage.totals.costUsd)} hint="仅汇总已知 costUsd" />
|
||
<StatCard label="输入 tokens" value={fmtNum(usage.totals.inputTokens)} hint="主要来自模型完成" />
|
||
<StatCard label="输出 tokens" value={fmtNum(usage.totals.outputTokens)} hint="主要来自模型完成" />
|
||
<StatCard
|
||
label="分账条目"
|
||
value={fmtNum(usage.breakdown.reduce((n, b) => n + b.factCount, 0))}
|
||
hint={`${fmtNum(usage.breakdown.length)} 个分项`}
|
||
/>
|
||
</div>
|
||
|
||
<div class="saas-card overflow-hidden mb-6">
|
||
<div class="border-b border-surface-200 px-5 py-3">
|
||
<h3 class="text-sm font-semibold text-surface-800">按来源分账</h3>
|
||
<p class="saas-muted mt-0.5 text-xs">
|
||
kind × provider × model/capability。外部能力显示页数/秒等计量,不与 tokens 混排。
|
||
</p>
|
||
</div>
|
||
{#if usage.breakdown.length === 0}
|
||
<EmptyState title="暂无用量事实" description="跑过智能体后,模型与外部能力消费会出现在此。" />
|
||
{:else}
|
||
<div class="overflow-x-auto">
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>类型</th>
|
||
<th>供应方</th>
|
||
<th>模型 / 能力</th>
|
||
<th>次数</th>
|
||
<th>计量</th>
|
||
<th>有成本 / 未知</th>
|
||
<th>成本</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{#each usage.breakdown as row}
|
||
<tr>
|
||
<td>
|
||
<span
|
||
class={row.kind === 'external_capability'
|
||
? 'saas-badge-primary'
|
||
: row.kind === 'model_completion'
|
||
? 'saas-badge-success'
|
||
: 'saas-badge-primary'}
|
||
>
|
||
{usageKindLabel(row.kind)}
|
||
</span>
|
||
</td>
|
||
<td class="font-mono text-xs">{row.provider}</td>
|
||
<td class="font-mono text-xs">{sourceLabel(row)}</td>
|
||
<td class="tabular-nums">{fmtNum(row.factCount)}</td>
|
||
<td class="tabular-nums">
|
||
<div>{meterCell(row)}</div>
|
||
<div class="text-[11px] text-surface-600">{meterHint(row)}</div>
|
||
</td>
|
||
<td class="tabular-nums text-surface-700">
|
||
{fmtNum(row.factsWithCost)} / {fmtNum(row.factsWithoutCost)}
|
||
</td>
|
||
<td class="tabular-nums font-medium">{fmtCost(row.costUsd)}</td>
|
||
</tr>
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="saas-card overflow-hidden">
|
||
<div class="border-b border-surface-200 px-5 py-3">
|
||
<h3 class="text-sm font-semibold text-surface-800">按项目</h3>
|
||
<p class="saas-muted mt-0.5 text-xs">项目仍是权限边界;行内成本已含该项目全部 fact 类型。</p>
|
||
</div>
|
||
{#if usage.projects.length === 0}
|
||
<EmptyState title="暂无项目" description="创建项目并触发智能体后会出现用量。" />
|
||
{:else}
|
||
<div class="overflow-x-auto">
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>项目</th>
|
||
<th>运行</th>
|
||
<th>有成本 / 未知</th>
|
||
<th>in / out tokens</th>
|
||
<th>成本</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{#each usage.projects as p}
|
||
<tr>
|
||
<td class="font-medium">{p.projectName}</td>
|
||
<td class="tabular-nums">{fmtNum(p.runCount)}</td>
|
||
<td class="tabular-nums text-surface-700">
|
||
{fmtNum(p.runsWithCost)} / {fmtNum(p.runsWithoutCost)}
|
||
</td>
|
||
<td class="tabular-nums text-surface-600">{fmtTokens(p.inputTokens, p.outputTokens)}</td>
|
||
<td class="tabular-nums">{fmtCost(p.costUsd)}</td>
|
||
<td class="text-right">
|
||
<a class="text-sm text-primary-700 hover:underline" href={`/admin/projects/${p.projectId}`}>
|
||
查看项目
|
||
</a>
|
||
</td>
|
||
</tr>
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|