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.
191 lines
7.0 KiB
Svelte
191 lines
7.0 KiB
Svelte
<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}
|