refactor: extract remaining settings modals
This commit is contained in:
+17
-100
@@ -1,5 +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 { useNavigate } from 'react-router-dom';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
@@ -7,6 +6,7 @@ import { useIsMobile } from '../components/useIsMobile.js';
|
|||||||
import ChangeKeyModal from '../components/ChangeKeyModal.js';
|
import ChangeKeyModal from '../components/ChangeKeyModal.js';
|
||||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||||
import ModernSelect from '../components/ModernSelect.js';
|
import ModernSelect from '../components/ModernSelect.js';
|
||||||
|
import DownstreamApiKeyModal from './settings/DownstreamApiKeyModal.js';
|
||||||
import FactoryResetModal from './settings/FactoryResetModal.js';
|
import FactoryResetModal from './settings/FactoryResetModal.js';
|
||||||
import RouteSelectorModal from './settings/RouteSelectorModal.js';
|
import RouteSelectorModal from './settings/RouteSelectorModal.js';
|
||||||
import {
|
import {
|
||||||
@@ -1630,105 +1630,22 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{downstreamModalPresence.shouldRender && (() => {
|
<DownstreamApiKeyModal
|
||||||
const modal = (
|
presence={downstreamModalPresence}
|
||||||
<div className={`modal-backdrop ${downstreamModalPresence.isVisible ? '' : 'is-closing'}`.trim()} onClick={closeDownstreamModal}>
|
editingDownstreamId={editingDownstreamId}
|
||||||
<div
|
downstreamCreate={downstreamCreate}
|
||||||
className={`modal-content ${downstreamModalPresence.isVisible ? '' : 'is-closing'}`.trim()}
|
downstreamSaving={downstreamSaving}
|
||||||
style={{ maxWidth: 860 }}
|
inputStyle={inputStyle}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onChange={(updater) => setDownstreamCreate((prev) => updater(prev))}
|
||||||
>
|
onOpenSelector={async () => {
|
||||||
<div className="modal-header">
|
if (selectorRoutes.length === 0) await loadRouteSelectorRoutes();
|
||||||
{editingDownstreamId ? '编辑下游 API Key' : '新增下游 API Key'}
|
setSelectorModelSearch('');
|
||||||
</div>
|
setSelectorGroupSearch('');
|
||||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
setSelectorOpen(true);
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 10 }}>
|
}}
|
||||||
<input
|
onClose={closeDownstreamModal}
|
||||||
value={downstreamCreate.name}
|
onSave={saveDownstreamKey}
|
||||||
onChange={(e) => setDownstreamCreate((prev) => ({ ...prev, name: e.target.value }))}
|
/>
|
||||||
placeholder="Name (e.g. cc-project)"
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={downstreamCreate.key}
|
|
||||||
onChange={(e) => setDownstreamCreate((prev) => ({ ...prev, key: e.target.value.trim() }))}
|
|
||||||
placeholder="sk-xxxx"
|
|
||||||
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={downstreamCreate.maxCost}
|
|
||||||
onChange={(e) => setDownstreamCreate((prev) => ({ ...prev, maxCost: e.target.value }))}
|
|
||||||
placeholder="最大费用(可选)"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step={0.000001}
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={downstreamCreate.maxRequests}
|
|
||||||
onChange={(e) => setDownstreamCreate((prev) => ({ ...prev, maxRequests: e.target.value }))}
|
|
||||||
placeholder="最大请求数(可选)"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step={1}
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={downstreamCreate.expiresAt}
|
|
||||||
onChange={(e) => setDownstreamCreate((prev) => ({ ...prev, expiresAt: e.target.value }))}
|
|
||||||
type="datetime-local"
|
|
||||||
placeholder="过期时间(可选)"
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={downstreamCreate.description}
|
|
||||||
onChange={(e) => setDownstreamCreate((prev) => ({ ...prev, description: e.target.value }))}
|
|
||||||
placeholder="备注(可选)"
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
|
||||||
已选模型 {downstreamCreate.selectedModels.length} 个,已选群组 {downstreamCreate.selectedGroupRouteIds.length} 个
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
if (selectorRoutes.length === 0) await loadRouteSelectorRoutes();
|
|
||||||
setSelectorModelSearch('');
|
|
||||||
setSelectorGroupSearch('');
|
|
||||||
setSelectorOpen(true);
|
|
||||||
}}
|
|
||||||
className="btn btn-ghost"
|
|
||||||
style={{ border: '1px solid var(--color-border)' }}
|
|
||||||
>
|
|
||||||
勾选模型和群组
|
|
||||||
</button>
|
|
||||||
{(downstreamCreate.selectedModels.length > 0 || downstreamCreate.selectedGroupRouteIds.length > 0) && (
|
|
||||||
<button
|
|
||||||
onClick={() => setDownstreamCreate((prev) => ({ ...prev, selectedModels: [], selectedGroupRouteIds: [] }))}
|
|
||||||
className="btn btn-link btn-link-warning"
|
|
||||||
>
|
|
||||||
清空选择
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button onClick={closeDownstreamModal} className="btn btn-ghost">取消</button>
|
|
||||||
<button onClick={saveDownstreamKey} disabled={downstreamSaving} className="btn btn-primary">
|
|
||||||
{downstreamSaving
|
|
||||||
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</>
|
|
||||||
: (editingDownstreamId ? '更新 API Key' : '新增 API Key')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
|
||||||
})()}
|
|
||||||
<FactoryResetModal
|
<FactoryResetModal
|
||||||
presence={factoryResetPresence}
|
presence={factoryResetPresence}
|
||||||
factoryResetting={factoryResetting}
|
factoryResetting={factoryResetting}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
describe('Settings downstream modal extraction', () => {
|
||||||
|
it('delegates the downstream API key portal to a dedicated settings component', () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Settings.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
||||||
|
|
||||||
|
expect(source).toContain("import DownstreamApiKeyModal from './settings/DownstreamApiKeyModal.js'");
|
||||||
|
expect(source).not.toContain('{downstreamModalPresence.shouldRender && (() => {');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
type ModalPresence = {
|
||||||
|
shouldRender: boolean;
|
||||||
|
isVisible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DownstreamCreateForm = {
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
maxCost: string;
|
||||||
|
maxRequests: string;
|
||||||
|
expiresAt: string;
|
||||||
|
selectedModels: string[];
|
||||||
|
selectedGroupRouteIds: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type DownstreamApiKeyModalProps = {
|
||||||
|
presence: ModalPresence;
|
||||||
|
editingDownstreamId: number | null;
|
||||||
|
downstreamCreate: DownstreamCreateForm;
|
||||||
|
downstreamSaving: boolean;
|
||||||
|
inputStyle: React.CSSProperties;
|
||||||
|
onChange: (updater: (prev: DownstreamCreateForm) => DownstreamCreateForm) => void;
|
||||||
|
onOpenSelector: () => Promise<void> | void;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DownstreamApiKeyModal({
|
||||||
|
presence,
|
||||||
|
editingDownstreamId,
|
||||||
|
downstreamCreate,
|
||||||
|
downstreamSaving,
|
||||||
|
inputStyle,
|
||||||
|
onChange,
|
||||||
|
onOpenSelector,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: DownstreamApiKeyModalProps) {
|
||||||
|
if (!presence.shouldRender) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = (
|
||||||
|
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||||
|
style={{ maxWidth: 860 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="modal-header">
|
||||||
|
{editingDownstreamId ? '编辑下游 API Key' : '新增下游 API Key'}
|
||||||
|
</div>
|
||||||
|
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 10 }}>
|
||||||
|
<input
|
||||||
|
value={downstreamCreate.name}
|
||||||
|
onChange={(e) => onChange((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
|
placeholder="Name (e.g. cc-project)"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={downstreamCreate.key}
|
||||||
|
onChange={(e) => onChange((prev) => ({ ...prev, key: e.target.value.trim() }))}
|
||||||
|
placeholder="sk-xxxx"
|
||||||
|
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={downstreamCreate.maxCost}
|
||||||
|
onChange={(e) => onChange((prev) => ({ ...prev, maxCost: e.target.value }))}
|
||||||
|
placeholder="最大费用(可选)"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.000001}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={downstreamCreate.maxRequests}
|
||||||
|
onChange={(e) => onChange((prev) => ({ ...prev, maxRequests: e.target.value }))}
|
||||||
|
placeholder="最大请求数(可选)"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={downstreamCreate.expiresAt}
|
||||||
|
onChange={(e) => onChange((prev) => ({ ...prev, expiresAt: e.target.value }))}
|
||||||
|
type="datetime-local"
|
||||||
|
placeholder="过期时间(可选)"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={downstreamCreate.description}
|
||||||
|
onChange={(e) => onChange((prev) => ({ ...prev, description: e.target.value }))}
|
||||||
|
placeholder="备注(可选)"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
|
已选模型 {downstreamCreate.selectedModels.length} 个,已选群组 {downstreamCreate.selectedGroupRouteIds.length} 个
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => void onOpenSelector()}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
|
>
|
||||||
|
勾选模型和群组
|
||||||
|
</button>
|
||||||
|
{(downstreamCreate.selectedModels.length > 0 || downstreamCreate.selectedGroupRouteIds.length > 0) ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onChange((prev) => ({ ...prev, selectedModels: [], selectedGroupRouteIds: [] }))}
|
||||||
|
className="btn btn-link btn-link-warning"
|
||||||
|
>
|
||||||
|
清空选择
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button onClick={onClose} className="btn btn-ghost">取消</button>
|
||||||
|
<button onClick={() => void onSave()} disabled={downstreamSaving} className="btn btn-primary">
|
||||||
|
{downstreamSaving
|
||||||
|
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</>
|
||||||
|
: (editingDownstreamId ? '更新 API Key' : '新增 API Key')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user