Files
curriculum-project-hub/hub/admin-web/src/routes/admin/+page.svelte
T
hongjr03 4e7b158ff9 feat(hub): drop redundant /admin/org/:slug path + release v0.0.35
Silo hostname already carries tenancy. Admin SPA routes become /admin/...,
legacy /admin/org/:slug/* bookmarks redirect, login lands on /admin.
2026-07-18 17:36:08 +00:00

191 lines
7.0 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { page } from '$app/state';
import { api, type OrgMembership } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtCost, fmtNum, orgRoleLabel } 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 SwitchControl from '$lib/components/SwitchControl.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const orgFromSession = $derived(resolveOrg($session.me, page.url.search));
let orgSlug = $derived(orgFromSession?.slug ?? '');
let org = $derived($session.me?.organizations.find((o) => o.slug === orgSlug) as OrgMembership | undefined);
let settings = $state<{ membersCanCreateProjects: boolean } | null>(null);
let usage = $state<Awaited<ReturnType<typeof api.usage>> | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let saving = $state(false);
async function load() {
loading = true;
error = null;
try {
[settings, usage] = await Promise.all([api.settings(orgSlug), api.usage(orgSlug)]);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function setMembersCanCreate(next: boolean) {
if (!settings || settings.membersCanCreateProjects === next) return;
saving = true;
const prev = settings.membersCanCreateProjects;
settings.membersCanCreateProjects = next;
try {
await api.setSettings(orgSlug, { membersCanCreateProjects: next });
toastSuccess(next ? '已允许成员自助建项' : '已关闭成员自助建项');
} catch (err) {
settings.membersCanCreateProjects = prev;
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
$effect(() => {
if (orgSlug) load();
});
</script>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else if org && settings && usage}
<PageHeader title={org.name} description="组织健康度、用量与生产策略一览。" />
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div class="saas-card-pad sm:col-span-2 lg:col-span-1">
<p class="saas-section-title mb-3">组织信息</p>
<dl class="space-y-2.5 text-sm">
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">Slug</dt>
<dd class="font-mono text-xs text-surface-800">/{org.slug}</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">状态</dt>
<dd><span class="saas-badge-success">{org.status}</span></dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">你的角色</dt>
<dd><span class="saas-badge-primary">{orgRoleLabel(org.role)}</span></dd>
</div>
</dl>
</div>
<div class="saas-card-pad sm:col-span-2">
<p class="saas-section-title mb-1">项目自助创建策略</p>
<p class="saas-muted mb-4">开启后,普通老师可在飞书群自助创建项目;关闭后仅所有者与管理员可建。</p>
<div class="flex items-center gap-3 border border-surface-300 bg-surface-100 px-4 py-3">
<SwitchControl checked={settings.membersCanCreateProjects} disabled={saving} onchange={setMembersCanCreate} />
<div>
<div class="text-sm font-medium text-surface-900">允许成员自助创建项目</div>
<div class="text-xs text-surface-600">普通成员在飞书群中自助建项</div>
</div>
</div>
</div>
</div>
<div class="mb-4 flex items-end justify-between gap-3">
<div>
<h2 class="saas-section-title">用量概览</h2>
<p class="saas-muted">totals 含全部 UsageFact;分账明细见用量页。</p>
</div>
<a class="saas-btn-secondary py-1.5! text-sm" href={`/admin/usage`}>完整用量报告</a>
</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)} />
<StatCard label="无成本运行" value={fmtNum(usage.totals.runsWithoutCost)} hint="未知 $0" />
<StatCard label="输入 tokens" value={fmtNum(usage.totals.inputTokens)} />
<StatCard label="输出 tokens" value={fmtNum(usage.totals.outputTokens)} />
<StatCard label="成本 (USD)" value={fmtCost(usage.totals.costUsd)} />
</div>
{#if usage.breakdown.length > 0}
<div class="saas-card overflow-hidden mb-6">
<div class="border-b border-surface-200 px-5 py-3 flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-surface-800">消费来源(Top</h3>
<p class="saas-muted text-xs">模型完成 vs 外部能力,按成本降序前 5</p>
</div>
<a class="text-sm text-primary-700 hover:underline" href={`/admin/usage`}>查看全部分账</a>
</div>
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>类型</th>
<th>供应方</th>
<th>模型 / 能力</th>
<th>次数</th>
<th>成本</th>
</tr>
</thead>
<tbody>
{#each [...usage.breakdown].sort((a, b) => (b.costUsd ?? -1) - (a.costUsd ?? -1)).slice(0, 5) as row}
<tr>
<td class="text-sm">{row.kind === 'external_capability' ? '外部能力' : row.kind === 'model_completion' ? '模型完成' : row.kind}</td>
<td class="font-mono text-xs">{row.provider}</td>
<td class="font-mono text-xs">{row.capabilityId ?? row.model ?? '—'}</td>
<td class="tabular-nums">{fmtNum(row.factCount)}</td>
<td class="tabular-nums">{fmtCost(row.costUsd)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
<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>
</div>
{#if usage.projects.length === 0}
<div class="saas-empty">
<p class="text-sm text-surface-600">暂无项目用量数据</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>项目</th>
<th>运行</th>
<th>in / out tokens</th>
<th>成本</th>
</tr>
</thead>
<tbody>
{#each usage.projects as p}
<tr>
<td class="font-medium">
<a class="hover:text-primary-700 hover:underline" href={`/admin/projects/${p.projectId}`}>
{p.projectName}
</a>
</td>
<td class="tabular-nums">{fmtNum(p.runCount)}</td>
<td class="tabular-nums text-surface-600">{fmtNum(p.inputTokens)} / {fmtNum(p.outputTokens)}</td>
<td class="tabular-nums">{fmtCost(p.costUsd)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{:else}
<div class="saas-empty">
<p class="text-sm text-surface-600">组织数据不可用</p>
</div>
{/if}