Files
Genarrative/src/components/platform-entry/PlatformProfileApiKeysModal.tsx
T
kdletters 6f181b36a1
Project CI / Frontend tests (push) Failing after 22s
Project CI / Repository checks (push) Successful in 58s
Project CI / Backend tests (push) Successful in 3m30s
Project CI / Native shell tests (push) Successful in 12m34s
修复编辑器生成幂等与参考图契约
生成队列按稳定请求标识去重并保持外部幂等哈希兼容
统一拒绝参考图超限并同步前端、后端、Provider 与 OpenAPI 契约
按真实归属重建生成引用并阻止直接持久化伪造来源
锁定参考图在途上传上下文并保留批量部分成功结果
关闭内部生成 POST 自动重试并补齐回归测试与项目文档
修正最新主线开发者密钥弹窗的导入排序门禁
2026-08-05 23:31:16 +08:00

314 lines
11 KiB
TypeScript

import { KeyRound, ShieldCheck, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type {
ExternalApiKeyCreateResponse,
ExternalApiKeyProfile,
} from '../../../packages/shared/src/contracts/runtime';
import {
createPlatformProfileExternalApiKey,
listPlatformProfileExternalApiKeys,
revokePlatformProfileExternalApiKey,
} from '../../services/platform-entry/platformProfileClient';
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformProfileContentRow } from '../common/PlatformProfileContentRow';
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
import { PlatformProfileSummaryHeader } from '../common/PlatformProfileSummaryHeader';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { useCopyFeedback } from '../common/useCopyFeedback';
import { formatPlatformProfileTime } from './platformProfileFundsModel';
import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell';
type PlatformProfileApiKeysModalProps = {
onClose: () => void;
};
function isActiveExternalApiKey(key: ExternalApiKeyProfile) {
return !key.revokedAt;
}
function buildApiKeyTimeLabel(value: string | null) {
if (!value) {
return '尚未使用';
}
return formatPlatformProfileTime(value);
}
function buildApiKeyScopeLabel(scopes: string[]) {
return scopes.length > 0 ? scopes.join(' / ') : '默认权限';
}
/**
* 开发者 API Key 管理弹窗。
* 明文 Key 只保留在本次创建后的组件状态里,关闭弹窗后即丢弃。
*/
export function PlatformProfileApiKeysModal({
onClose,
}: PlatformProfileApiKeysModalProps) {
const [keys, setKeys] = useState<ExternalApiKeyProfile[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [nameInput, setNameInput] = useState('外部 API Key');
const [createdKey, setCreatedKey] =
useState<ExternalApiKeyCreateResponse | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [revokingKeyId, setRevokingKeyId] = useState<string | null>(null);
const { copyState, copyText } = useCopyFeedback();
const activeKeys = useMemo(() => keys.filter(isActiveExternalApiKey), [keys]);
const shouldShowBlockingError = Boolean(
error && !isLoading && keys.length === 0,
);
const loadKeys = useCallback(() => {
setIsLoading(true);
setError(null);
void listPlatformProfileExternalApiKeys()
.then((response) => {
setKeys(response.keys);
})
.catch((loadError: unknown) => {
setError(
loadError instanceof Error ? loadError.message : '读取 API Key 失败',
);
})
.finally(() => setIsLoading(false));
}, []);
useEffect(() => {
loadKeys();
}, [loadKeys]);
const createKey = useCallback(() => {
if (isCreating) {
return;
}
setIsCreating(true);
setError(null);
setCreatedKey(null);
void createPlatformProfileExternalApiKey(nameInput)
.then((response) => {
setCreatedKey(response);
setKeys((current) => [
response.key,
...current.filter((key) => key.keyId !== response.key.keyId),
]);
setNameInput('外部 API Key');
})
.catch((createError: unknown) => {
setError(
createError instanceof Error
? createError.message
: '创建 API Key 失败',
);
})
.finally(() => setIsCreating(false));
}, [isCreating, nameInput]);
const revokeKey = useCallback((keyId: string) => {
setRevokingKeyId(keyId);
setError(null);
void revokePlatformProfileExternalApiKey(keyId)
.then((response) => {
setKeys((current) =>
current.map((key) =>
key.keyId === response.key.keyId ? response.key : key,
),
);
setCreatedKey((current) =>
current?.key.keyId === response.key.keyId ? null : current,
);
})
.catch((revokeError: unknown) => {
setError(
revokeError instanceof Error
? revokeError.message
: '撤销 API Key 失败',
);
})
.finally(() => setRevokingKeyId(null));
}, []);
return (
<PlatformProfileSecondaryModalShell
title="开发者 API Key"
onClose={onClose}
closeLabel="关闭开发者 API Key"
closeButtonClassName="bg-white/80"
panelClassName="relative !max-h-[min(92vh,44rem)] !max-w-[34rem] bg-[linear-gradient(180deg,#f8fbff_0%,#ffffff_42%,#f8fafc_100%)] text-zinc-950 shadow-2xl !rounded-[1.35rem] sm:!rounded-[1.35rem]"
contentClassName="relative max-h-[min(92vh,44rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5"
>
<PlatformProfileSummaryHeader
kicker="OPENAPI"
title="开发者 API Key"
badge={
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/76 px-3 py-1 text-xs font-black text-sky-700 shadow-sm ring-1 ring-sky-100">
<ShieldCheck className="h-3.5 w-3.5" />
{activeKeys.length} 个可用
</span>
}
/>
<div className="mt-5 rounded-[1.1rem] border border-sky-100 bg-white/82 p-3 shadow-sm">
<div className="flex items-end gap-2">
<label className="min-w-0 flex-1">
<span className="mb-1.5 block text-xs font-black text-zinc-500">
名称
</span>
<PlatformTextField
value={nameInput}
density="compact"
tone="sky"
onChange={(event) => setNameInput(event.target.value)}
/>
</label>
<PlatformActionButton
surface="profile"
size="sm"
className="shrink-0"
disabled={isCreating}
onClick={createKey}
>
<KeyRound className="h-4 w-4" />
{isCreating ? '创建中' : '创建'}
</PlatformActionButton>
</div>
</div>
{createdKey ? (
<PlatformStatusMessage
tone="success"
surface="profile"
size="sm"
className="mt-4 rounded-2xl font-semibold"
>
<div className="text-xs font-black text-emerald-700"> Key</div>
<div className="mt-2 break-all rounded-xl bg-white/80 px-3 py-2 font-mono text-[12px] text-zinc-800">
{createdKey.apiKey}
</div>
<CopyFeedbackButton
state={copyState}
idleLabel="复制 Key"
actionSurface="profile"
actionTone="primary"
actionSize="xs"
className="mt-3"
onClick={() => void copyText(createdKey.apiKey)}
/>
</PlatformStatusMessage>
) : null}
<PlatformAsyncStatePanel
errorState={
shouldShowBlockingError ? (
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="mt-4 rounded-2xl font-semibold"
>
<div>{error}</div>
<PlatformActionButton
surface="profile"
size="xs"
className="mt-3"
onClick={loadKeys}
>
重新加载
</PlatformActionButton>
</PlatformStatusMessage>
) : null
}
isLoading={isLoading}
loadingState={
<PlatformProfileSkeletonList
count={3}
containerClassName="mt-5 space-y-3"
itemClassName="h-20 rounded-2xl bg-white/70"
/>
}
isEmpty={keys.length === 0}
emptyState={
<PlatformEmptyState
surface="subpanel"
size="inline"
className="mt-5 py-8"
>
暂无 API Key
</PlatformEmptyState>
}
>
{error ? (
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="mb-3 rounded-2xl font-semibold"
>
{error}
</PlatformStatusMessage>
) : null}
<div className="mt-5 space-y-2.5">
{keys.map((key) => {
const isActive = isActiveExternalApiKey(key);
const isRevoking = revokingKeyId === key.keyId;
return (
<PlatformProfileContentRow
key={key.keyId}
surface="flat"
radius="xs"
padding="none"
className="flex items-start justify-between gap-3 px-3 py-3 shadow-sm"
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-black text-zinc-900">
{key.name}
</span>
<span
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-black ${
isActive
? 'bg-emerald-50 text-emerald-700'
: 'bg-zinc-100 text-zinc-500'
}`}
>
{isActive ? '可用' : '已撤销'}
</span>
</div>
<div className="mt-1 font-mono text-xs font-semibold text-zinc-500">
{key.keyPrefix}...
</div>
<div className="mt-2 text-[11px] font-semibold text-zinc-400">
{buildApiKeyScopeLabel(key.scopes)}
</div>
<div className="mt-1 text-[11px] font-semibold text-zinc-400">
创建 {buildApiKeyTimeLabel(key.createdAt)} · 最近使用{' '}
{buildApiKeyTimeLabel(key.lastUsedAt)}
</div>
</div>
{isActive ? (
<PlatformActionButton
surface="profile"
tone="danger"
size="xs"
className="shrink-0"
disabled={isRevoking}
onClick={() => revokeKey(key.keyId)}
>
<Trash2 className="h-3.5 w-3.5" />
{isRevoking ? '撤销中' : '撤销'}
</PlatformActionButton>
) : null}
</PlatformProfileContentRow>
);
})}
</div>
</PlatformAsyncStatePanel>
</PlatformProfileSecondaryModalShell>
);
}