forked from EduCraft/curriculum-project-hub
fix(hub): clarify BYOK rotation state
This commit is contained in:
@@ -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,36 +193,78 @@
|
|||||||
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user