forked from boos/BoosAPI
Merge pull request #204 from Shinku-Chen/rand-sk-2
生成密钥的时候,能随机生成,同时能复制token
This commit is contained in:
@@ -7,10 +7,13 @@ import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { tr } from '../i18n.js';
|
||||
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
|
||||
|
||||
const DownstreamKeyTrendChart = lazy(() => import('../components/charts/DownstreamKeyTrendChart.js'));
|
||||
type DownstreamKeyTrendBucket = import('../components/charts/DownstreamKeyTrendChart.js').DownstreamKeyTrendBucket;
|
||||
|
||||
const PROXY_TOKEN_PREFIX = 'sk-';
|
||||
|
||||
type Range = '24h' | '7d' | 'all';
|
||||
type Status = 'all' | 'enabled' | 'disabled';
|
||||
|
||||
@@ -259,6 +262,86 @@ function tagChipStyle(kind: 'normal' | 'accent' = 'normal'): React.CSSProperties
|
||||
};
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string): Promise<void> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.left = '-9999px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
function DownstreamKeyCopyIconButton({ fullKey }: { fullKey: string | undefined }) {
|
||||
const toast = useToast();
|
||||
const [pressed, setPressed] = useState(false);
|
||||
|
||||
const disabled = !fullKey?.trim();
|
||||
const release = () => setPressed(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title="复制完整密钥"
|
||||
aria-label="复制完整密钥"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 2,
|
||||
lineHeight: 0,
|
||||
flexShrink: 0,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: disabled
|
||||
? 'var(--color-text-muted)'
|
||||
: pressed
|
||||
? 'var(--color-text-primary)'
|
||||
: 'var(--color-text-muted)',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.4 : 1,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
disabled={disabled}
|
||||
onMouseDown={() => {
|
||||
if (!disabled) setPressed(true);
|
||||
}}
|
||||
onMouseUp={release}
|
||||
onMouseLeave={release}
|
||||
onTouchStart={() => {
|
||||
if (!disabled) setPressed(true);
|
||||
}}
|
||||
onTouchEnd={release}
|
||||
onTouchCancel={release}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
const full = fullKey?.trim();
|
||||
if (!full) {
|
||||
toast.info('完整密钥暂不可用,请刷新页面后重试');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await copyToClipboard(full);
|
||||
toast.success('已复制到剪贴板');
|
||||
} catch {
|
||||
toast.error('复制失败');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function buildEditorForm(item?: ManagedItem | DownstreamApiKeyItem | null): EditorForm {
|
||||
return {
|
||||
name: item?.name || '',
|
||||
@@ -877,7 +960,22 @@ function EditorModal({
|
||||
</div>
|
||||
<div className="downstream-key-modal-field">
|
||||
<div className="downstream-key-modal-label">下游密钥</div>
|
||||
<input value={form.key} onChange={(e) => onChange((prev) => ({ ...prev, key: e.target.value }))} placeholder="sk-..." style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} />
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch', minWidth: 0 }}>
|
||||
<input
|
||||
value={form.key}
|
||||
onChange={(e) => onChange((prev) => ({ ...prev, key: e.target.value }))}
|
||||
placeholder="sk-..."
|
||||
style={{ ...inputStyle, flex: 1, minWidth: 0, fontFamily: 'var(--font-mono)' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ flexShrink: 0, whiteSpace: 'nowrap', alignSelf: 'stretch' }}
|
||||
onClick={() => onChange((prev) => ({ ...prev, key: generateDownstreamSkKey(PROXY_TOKEN_PREFIX) }))}
|
||||
>
|
||||
随机
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="downstream-key-modal-field">
|
||||
<div className="downstream-key-modal-label">主分组</div>
|
||||
@@ -1637,7 +1735,10 @@ export default function DownstreamKeys() {
|
||||
<strong style={{ color: 'var(--color-text-primary)' }}>{row.name}</strong>
|
||||
<StatusBadge enabled={row.enabled} />
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>{row.keyMasked}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', marginBottom: 4 }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--color-text-muted)' }}>{row.keyMasked}</span>
|
||||
<DownstreamKeyCopyIconButton fullKey={row.key} />
|
||||
</div>
|
||||
{row.description ? <div style={{ fontSize: 12, color: 'var(--color-text-secondary)', maxWidth: 320 }}>{row.description}</div> : null}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
||||
<span className={`badge ${row.groupName ? 'badge-info' : 'badge-muted'}`} style={{ fontSize: 11 }}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
@@ -17,6 +17,7 @@ import { clearAuthSession } from '../authSession.js';
|
||||
import { clearAppInstallationState } from '../appLocalState.js';
|
||||
import { tr } from '../i18n.js';
|
||||
import { ROUTE_ICON_NONE_VALUE } from './token-routes/utils.js';
|
||||
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
|
||||
|
||||
const PROXY_TOKEN_PREFIX = 'sk-';
|
||||
const ROUTE_BRAND_ICON_PREFIX = 'brand:';
|
||||
@@ -1164,8 +1165,20 @@ export default function Settings() {
|
||||
</code>
|
||||
<div
|
||||
style={{
|
||||
...inputStyle,
|
||||
display: 'flex',
|
||||
gap: 10,
|
||||
alignItems: 'stretch',
|
||||
marginBottom: 10,
|
||||
minWidth: 0,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
...inputStyle,
|
||||
flex: 1,
|
||||
minWidth: 200,
|
||||
marginBottom: 0,
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -1180,17 +1193,19 @@ export default function Settings() {
|
||||
fontSize: 13,
|
||||
color: 'var(--color-text-secondary)',
|
||||
userSelect: 'none',
|
||||
background: 'color-mix(in srgb, var(--color-text-muted) 6%, transparent)',
|
||||
}}
|
||||
>
|
||||
{PROXY_TOKEN_PREFIX}
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
type="text"
|
||||
value={proxyTokenSuffix}
|
||||
onChange={(e) => setProxyTokenSuffix(normalizeProxyTokenSuffix(e.target.value))}
|
||||
placeholder="请输入 sk- 后的令牌内容"
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
background: 'transparent',
|
||||
@@ -1201,6 +1216,33 @@ export default function Settings() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-soft-primary"
|
||||
aria-label="随机生成访问令牌后缀"
|
||||
title="生成高熵随机后缀(不会自动保存)"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: '10px 18px',
|
||||
fontSize: 13,
|
||||
gap: 8,
|
||||
alignSelf: 'stretch',
|
||||
}}
|
||||
onClick={() => {
|
||||
const full = generateDownstreamSkKey(PROXY_TOKEN_PREFIX);
|
||||
setProxyTokenSuffix(full.slice(PROXY_TOKEN_PREFIX.length));
|
||||
}}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
|
||||
/>
|
||||
</svg>
|
||||
随机生成
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={saveProxyToken} disabled={savingToken} className="btn btn-primary">
|
||||
{savingToken ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '更新下游访问令牌'}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export function generateDownstreamSkKey(prefix: string): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
return `${prefix}${hex}`;
|
||||
}
|
||||
Reference in New Issue
Block a user