6f181b36a1
生成队列按稳定请求标识去重并保持外部幂等哈希兼容 统一拒绝参考图超限并同步前端、后端、Provider 与 OpenAPI 契约 按真实归属重建生成引用并阻止直接持久化伪造来源 锁定参考图在途上传上下文并保留批量部分成功结果 关闭内部生成 POST 自动重试并补齐回归测试与项目文档 修正最新主线开发者密钥弹窗的导入排序门禁
314 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|