fix(hub): clarify BYOK rotation state

This commit is contained in:
2026-07-24 16:32:19 +08:00
parent 3708162fa9
commit 4087f09994
@@ -11,6 +11,11 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastInfo, toastSuccess } from '$lib/toast'; import { toastError, toastInfo, toastSuccess } from '$lib/toast';
type FormState =
| { kind: 'new'; error?: string }
| { kind: 'rotate'; providerId: string; error?: string }
| { kind: 'saving'; intent: 'new' | 'rotate'; providerId?: string };
const org = $derived(resolveOrg($session.me, page.url.search)); const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? ''); const slug = $derived(org?.slug ?? '');
@@ -18,12 +23,23 @@
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let formState = $state<FormState>({ kind: 'new' });
let providerId = $state(''); let providerId = $state('');
let rotatingId = $state<string | null>(null);
let baseUrl = $state(''); let baseUrl = $state('');
let authToken = $state(''); let authToken = $state('');
let anthropicApiKey = $state(''); let anthropicApiKey = $state('');
let saving = $state(false);
const rotationId = $derived(
formState.kind === 'rotate'
? formState.providerId
: formState.kind === 'saving' && formState.intent === 'rotate'
? (formState.providerId ?? null)
: null
);
const saving = $derived(formState.kind === 'saving');
const formError = $derived(
formState.kind === 'new' || formState.kind === 'rotate' ? (formState.error ?? null) : null
);
async function load() { async function load() {
loading = true; loading = true;
@@ -39,8 +55,8 @@
} }
async function startRotate(row: ProviderConnectionRow) { async function startRotate(row: ProviderConnectionRow) {
formState = { kind: 'rotate', providerId: row.providerId };
providerId = row.providerId; providerId = row.providerId;
rotatingId = row.providerId;
baseUrl = ''; baseUrl = '';
authToken = ''; authToken = '';
anthropicApiKey = ''; anthropicApiKey = '';
@@ -48,45 +64,67 @@
const el = document.getElementById('base-url'); const el = document.getElementById('base-url');
el?.scrollIntoView({ behavior: 'smooth', block: 'center' }); el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
el?.focus(); el?.focus();
toastInfo(`已填入供应方 ${row.providerId},请在下方填写新接口地址与访问令牌`); toastInfo(`已开始轮换 ${row.providerId},请填写新接口地址与访问令牌`);
} }
function resetForm() { function resetForm() {
rotatingId = null; formState = { kind: 'new' };
providerId = ''; providerId = '';
baseUrl = ''; baseUrl = '';
authToken = ''; authToken = '';
anthropicApiKey = ''; anthropicApiKey = '';
} }
function cancelOrClear() {
const wasRotating = rotationId !== null;
resetForm();
if (wasRotating) toastInfo('已取消轮换');
}
function showFormError(message: string, targetProviderId: string | null) {
formState =
targetProviderId === null
? { kind: 'new', error: message }
: { kind: 'rotate', providerId: targetProviderId, error: message };
toastError(message);
}
async function save() { async function save() {
const targetProviderId = formState.kind === 'rotate' ? formState.providerId : null;
const intent = targetProviderId === null ? 'new' : 'rotate';
const id = providerId.trim(); const id = providerId.trim();
if (id === '') { if (id === '') {
toastError('请填写供应方 ID'); showFormError('请填写供应方 ID', targetProviderId);
return; return;
} }
const url = baseUrl.trim(); const url = baseUrl.trim();
const token = authToken.trim(); const token = authToken.trim();
if (url === '' || token === '') { if (url === '' || token === '') {
toastError('接口地址与访问令牌均为必填'); showFormError('接口地址与访问令牌均为必填', targetProviderId);
return; return;
} }
saving = true;
const body: { baseUrl: string; authToken: string; anthropicApiKey?: string } = { const body: { baseUrl: string; authToken: string; anthropicApiKey?: string } = {
baseUrl: url, baseUrl: url,
authToken: token, authToken: token,
}; };
const key = anthropicApiKey.trim(); const key = anthropicApiKey.trim();
if (key !== '') body.anthropicApiKey = key; if (key !== '') body.anthropicApiKey = key;
formState =
intent === 'rotate'
? { kind: 'saving', intent, providerId: id }
: { kind: 'saving', intent };
try { try {
const saved = await api.rotateProviderConnection(slug, id, body); const saved = await api.rotateProviderConnection(slug, id, body);
toastSuccess(saved.activeVersion === 1 ? '已创建 BYOK 连接' : '凭据已轮换');
resetForm(); resetForm();
toastSuccess(saved.activeVersion === 1 ? '已创建 BYOK 连接' : '凭据已轮换');
await load(); await load();
} catch (err) { } catch (err) {
toastError(err instanceof Error ? err.message : String(err)); const message = err instanceof Error ? err.message : String(err);
} finally { formState =
saving = false; intent === 'rotate'
? { kind: 'rotate', providerId: id, error: message }
: { kind: 'new', error: message };
toastError(message);
} }
} }
@@ -134,7 +172,14 @@
<td class="text-surface-600">{fmtDate(row.updatedAt)}</td> <td class="text-surface-600">{fmtDate(row.updatedAt)}</td>
<td> <td>
{#if row.mode === 'BYOK'} {#if row.mode === 'BYOK'}
<button class="saas-btn-ghost px-2! py-1! text-xs" onclick={() => startRotate(row)}>轮换</button> <button
class="saas-btn-ghost px-2! py-1! text-xs"
onclick={() => startRotate(row)}
disabled={saving}
aria-label={`开始轮换供应方 ${row.providerId}`}
>
开始轮换
</button>
{:else} {:else}
<span class="text-xs text-surface-500">平台管理</span> <span class="text-xs text-surface-500">平台管理</span>
{/if} {/if}
@@ -148,37 +193,79 @@
</div> </div>
<div class="saas-card-pad"> <div class="saas-card-pad">
<h3 class="saas-section-title mb-1">{rotatingId ? `轮换凭据 · ${rotatingId}` : '新建 / 轮换 BYOK 凭据'}</h3> <h3 class="saas-section-title mb-1">
<p class="saas-muted mb-4"> {rotationId ? `轮换凭据 · ${rotationId}` : '新建 BYOK 凭据'}
密钥仅写入新版本,旧版本归档;保存时需重新填写接口地址与访问令牌。平台托管连接不在此处管理。 </h3>
<p class="saas-muted mb-4" role="status">
{#if saving}
正在验证新凭据;验证通过后才会切换版本,请勿重复提交。
{:else if rotationId}
已选择供应方 {rotationId}。点击“开始轮换”只打开此表单;填写新接口地址和访问令牌后,点击“验证并保存”才会生效。
{:else}
填写供应方 ID、接口地址和访问令牌,保存前会先验证凭据。平台托管连接不在此处管理。
{/if}
</p> </p>
<div class="grid gap-5"> <div class="grid gap-5">
<div> <div>
<Label.Root class="saas-label" for="provider-id">供应方 ID</Label.Root> <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" readonly={rotatingId !== null} /> <input
{#if rotatingId} id="provider-id"
<p class="mt-1 text-xs text-surface-500">轮换中:ID 已锁定。如需新建其他供应方,点"清空"后填写新 ID。</p> class="saas-input font-mono text-sm"
bind:value={providerId}
placeholder="openrouter"
readonly={rotationId !== null}
disabled={saving}
/>
{#if rotationId}
<p class="mt-1 text-xs text-surface-500">
轮换目标已锁定为 {rotationId}。如需新建其他供应方,请先取消轮换。
</p>
{/if} {/if}
</div> </div>
<div> <div>
<Label.Root class="saas-label" for="base-url">接口地址</Label.Root> <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} /> <input
id="base-url"
class="saas-input"
placeholder="https://openrouter.ai/api"
bind:value={baseUrl}
disabled={saving}
/>
</div> </div>
<div> <div>
<Label.Root class="saas-label" for="auth-token">访问令牌</Label.Root> <Label.Root class="saas-label" for="auth-token">访问令牌</Label.Root>
<input id="auth-token" class="saas-input" type="password" bind:value={authToken} /> <input
id="auth-token"
class="saas-input"
type="password"
bind:value={authToken}
disabled={saving}
/>
</div> </div>
<div> <div>
<Label.Root class="saas-label" for="anthropic-key">Anthropic API Key(可选)</Label.Root> <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} /> <input
id="anthropic-key"
class="saas-input"
type="password"
bind:value={anthropicApiKey}
disabled={saving}
/>
</div> </div>
</div> </div>
{#if formError}
<div role="alert" class="border border-error-200 bg-error-50 px-4 py-3 text-sm text-error-700">
{formError}
</div>
{/if}
<div class="mt-6 flex items-center gap-3 border-t border-surface-100 pt-4"> <div class="mt-6 flex items-center gap-3 border-t border-surface-100 pt-4">
<div class="flex-1"></div> <div class="flex-1"></div>
<button class="saas-btn-ghost" onclick={resetForm} disabled={saving}>清空</button> <button class="saas-btn-ghost" onclick={cancelOrClear} disabled={saving}>
{rotationId ? '取消轮换' : '清空'}
</button>
<button class="saas-btn-primary" onclick={save} disabled={saving}> <button class="saas-btn-primary" onclick={save} disabled={saving}>
{saving ? '保存中…' : '保存'} {saving ? '验证并保存中…' : rotationId ? '验证并保存' : '验证并创建'}
</button> </button>
</div> </div>
</div> </div>
{/if} {/if}