1
0
forked from boos/BoosAPI

Merge pull request #204 from Shinku-Chen/rand-sk-2

生成密钥的时候,能随机生成,同时能复制token
This commit is contained in:
Shinku
2026-03-20 16:17:51 +08:00
committed by GitHub
parent 5e872b331e
commit e2595bcab3
3 changed files with 179 additions and 30 deletions
+103 -2
View File
@@ -7,10 +7,13 @@ import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js'; import ModernSelect from '../components/ModernSelect.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { tr } from '../i18n.js'; import { tr } from '../i18n.js';
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
const DownstreamKeyTrendChart = lazy(() => import('../components/charts/DownstreamKeyTrendChart.js')); const DownstreamKeyTrendChart = lazy(() => import('../components/charts/DownstreamKeyTrendChart.js'));
type DownstreamKeyTrendBucket = import('../components/charts/DownstreamKeyTrendChart.js').DownstreamKeyTrendBucket; type DownstreamKeyTrendBucket = import('../components/charts/DownstreamKeyTrendChart.js').DownstreamKeyTrendBucket;
const PROXY_TOKEN_PREFIX = 'sk-';
type Range = '24h' | '7d' | 'all'; type Range = '24h' | '7d' | 'all';
type Status = 'all' | 'enabled' | 'disabled'; 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 { function buildEditorForm(item?: ManagedItem | DownstreamApiKeyItem | null): EditorForm {
return { return {
name: item?.name || '', name: item?.name || '',
@@ -877,7 +960,22 @@ function EditorModal({
</div> </div>
<div className="downstream-key-modal-field"> <div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">下游密钥</div> <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>
<div className="downstream-key-modal-field"> <div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">主分组</div> <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> <strong style={{ color: 'var(--color-text-primary)' }}>{row.name}</strong>
<StatusBadge enabled={row.enabled} /> <StatusBadge enabled={row.enabled} />
</div> </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} {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 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<span className={`badge ${row.groupName ? 'badge-info' : 'badge-muted'}`} style={{ fontSize: 11 }}> <span className={`badge ${row.groupName ? 'badge-info' : 'badge-muted'}`} style={{ fontSize: 11 }}>
+45 -3
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { api } from '../api.js'; import { api } from '../api.js';
@@ -17,6 +17,7 @@ import { clearAuthSession } from '../authSession.js';
import { clearAppInstallationState } from '../appLocalState.js'; import { clearAppInstallationState } from '../appLocalState.js';
import { tr } from '../i18n.js'; import { tr } from '../i18n.js';
import { ROUTE_ICON_NONE_VALUE } from './token-routes/utils.js'; import { ROUTE_ICON_NONE_VALUE } from './token-routes/utils.js';
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
const PROXY_TOKEN_PREFIX = 'sk-'; const PROXY_TOKEN_PREFIX = 'sk-';
const ROUTE_BRAND_ICON_PREFIX = 'brand:'; const ROUTE_BRAND_ICON_PREFIX = 'brand:';
@@ -1164,8 +1165,20 @@ export default function Settings() {
</code> </code>
<div <div
style={{ style={{
...inputStyle, display: 'flex',
gap: 10,
alignItems: 'stretch',
marginBottom: 10, marginBottom: 10,
minWidth: 0,
flexWrap: 'wrap',
}}
>
<div
style={{
...inputStyle,
flex: 1,
minWidth: 200,
marginBottom: 0,
padding: 0, padding: 0,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -1180,17 +1193,19 @@ export default function Settings() {
fontSize: 13, fontSize: 13,
color: 'var(--color-text-secondary)', color: 'var(--color-text-secondary)',
userSelect: 'none', userSelect: 'none',
background: 'color-mix(in srgb, var(--color-text-muted) 6%, transparent)',
}} }}
> >
{PROXY_TOKEN_PREFIX} {PROXY_TOKEN_PREFIX}
</span> </span>
<input <input
type="password" type="text"
value={proxyTokenSuffix} value={proxyTokenSuffix}
onChange={(e) => setProxyTokenSuffix(normalizeProxyTokenSuffix(e.target.value))} onChange={(e) => setProxyTokenSuffix(normalizeProxyTokenSuffix(e.target.value))}
placeholder="请输入 sk- 后的令牌内容" placeholder="请输入 sk- 后的令牌内容"
style={{ style={{
flex: 1, flex: 1,
minWidth: 0,
border: 'none', border: 'none',
outline: 'none', outline: 'none',
background: 'transparent', background: 'transparent',
@@ -1201,6 +1216,33 @@ export default function Settings() {
}} }}
/> />
</div> </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"> <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)' }} /> 保存中...</> : '更新下游访问令牌'} {savingToken ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '更新下游访问令牌'}
</button> </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}`;
}