forked from bai/curriculum-project-hub
7f09fb1f13
Silo hostname already carries tenancy. Admin SPA routes become /admin/..., legacy bookmarks redirect, login lands on /admin. Co-authored-by: Hong Jiarong <me@jrhim.com> Co-committed-by: Hong Jiarong <me@jrhim.com>
173 lines
5.2 KiB
Svelte
173 lines
5.2 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/state';
|
|
import { api, type ProviderConnectionRow } from '$lib/api';
|
|
import { session } from '$lib/session';
|
|
import { resolveOrg } from '$lib/org';
|
|
import { fmtDate, providerModeLabel } from '$lib/format';
|
|
import { Label } from 'bits-ui';
|
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
|
import LoadingState from '$lib/components/LoadingState.svelte';
|
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
|
import { toastError, toastSuccess } from '$lib/toast';
|
|
|
|
const org = $derived(resolveOrg($session.me, page.url.search));
|
|
const slug = $derived(org?.slug ?? '');
|
|
|
|
let connections = $state<ProviderConnectionRow[]>([]);
|
|
let loading = $state(true);
|
|
let error = $state<string | null>(null);
|
|
|
|
let providerId = $state('');
|
|
let baseUrl = $state('');
|
|
let authToken = $state('');
|
|
let anthropicApiKey = $state('');
|
|
let saving = $state(false);
|
|
|
|
async function load() {
|
|
loading = true;
|
|
error = null;
|
|
try {
|
|
const res = await api.providerConnections(slug);
|
|
connections = res.connections;
|
|
} catch (err) {
|
|
error = err instanceof Error ? err.message : String(err);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
function startRotate(row: ProviderConnectionRow) {
|
|
providerId = row.providerId;
|
|
baseUrl = '';
|
|
authToken = '';
|
|
anthropicApiKey = '';
|
|
}
|
|
|
|
function resetForm() {
|
|
providerId = '';
|
|
baseUrl = '';
|
|
authToken = '';
|
|
anthropicApiKey = '';
|
|
}
|
|
|
|
async function save() {
|
|
const id = providerId.trim();
|
|
if (id === '') {
|
|
toastError('请填写供应方 ID');
|
|
return;
|
|
}
|
|
const url = baseUrl.trim();
|
|
const token = authToken.trim();
|
|
if (url === '' || token === '') {
|
|
toastError('接口地址与访问令牌均为必填');
|
|
return;
|
|
}
|
|
saving = true;
|
|
const body: { baseUrl: string; authToken: string; anthropicApiKey?: string } = {
|
|
baseUrl: url,
|
|
authToken: token,
|
|
};
|
|
const key = anthropicApiKey.trim();
|
|
if (key !== '') body.anthropicApiKey = key;
|
|
try {
|
|
await api.rotateProviderConnection(slug, id, body);
|
|
toastSuccess('凭据已轮换');
|
|
resetForm();
|
|
await load();
|
|
} catch (err) {
|
|
toastError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if (slug) load();
|
|
});
|
|
</script>
|
|
|
|
<PageHeader
|
|
title="模型供应方"
|
|
description="本组织的模型供应方连接。BYOK 由组织所有者/管理员轮换;平台托管连接由平台管理员配置。凭据按组织隔离,缺失或校验失败即拒绝运行(fail-closed)。"
|
|
/>
|
|
|
|
{#if loading}
|
|
<LoadingState />
|
|
{:else if error}
|
|
<ErrorBanner message={error} onretry={load} />
|
|
{:else}
|
|
<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>
|
|
</div>
|
|
{#if connections.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>状态</th>
|
|
<th>版本</th>
|
|
<th>更新于</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each connections as row}
|
|
<tr>
|
|
<td class="font-mono text-sm">{row.providerId}</td>
|
|
<td>{providerModeLabel(row.mode)}</td>
|
|
<td>{row.status}</td>
|
|
<td class="tabular-nums">{row.activeVersion ?? '—'}</td>
|
|
<td class="text-surface-600">{fmtDate(row.updatedAt)}</td>
|
|
<td>
|
|
{#if row.mode === 'BYOK'}
|
|
<button class="saas-btn-ghost px-2! py-1! text-xs" onclick={() => startRotate(row)}>轮换</button>
|
|
{:else}
|
|
<span class="text-xs text-surface-500">平台管理</span>
|
|
{/if}
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="saas-card-pad">
|
|
<h3 class="saas-section-title mb-1">轮换 BYOK 凭据</h3>
|
|
<p class="saas-muted mb-4">
|
|
密钥仅写入新版本,旧版本归档;保存时需重新填写接口地址与访问令牌。平台托管连接不在此处管理。
|
|
</p>
|
|
<div class="grid gap-5">
|
|
<div>
|
|
<Label.Root class="saas-label" for="provider-id">供应方 ID</Label.Root>
|
|
<input id="provider-id" class="saas-input font-mono text-sm" bind:value={providerId} placeholder="openrouter" />
|
|
</div>
|
|
<div>
|
|
<Label.Root class="saas-label" for="base-url">接口地址</Label.Root>
|
|
<input id="base-url" class="saas-input" placeholder="https://openrouter.ai/api" bind:value={baseUrl} />
|
|
</div>
|
|
<div>
|
|
<Label.Root class="saas-label" for="auth-token">访问令牌</Label.Root>
|
|
<input id="auth-token" class="saas-input" type="password" bind:value={authToken} />
|
|
</div>
|
|
<div>
|
|
<Label.Root class="saas-label" for="anthropic-key">Anthropic API Key(可选)</Label.Root>
|
|
<input id="anthropic-key" class="saas-input" type="password" bind:value={anthropicApiKey} />
|
|
</div>
|
|
</div>
|
|
<div class="mt-6 flex items-center gap-3 border-t border-surface-100 pt-4">
|
|
<div class="flex-1"></div>
|
|
<button class="saas-btn-ghost" onclick={resetForm} disabled={saving}>清空</button>
|
|
<button class="saas-btn-primary" onclick={save} disabled={saving}>
|
|
{saving ? '保存中…' : '保存'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|