refactor: extract remaining settings modals

This commit is contained in:
Cita
2026-03-23 13:29:39 +08:00
parent 62eca41bc2
commit c3e3985b6e
3 changed files with 169 additions and 100 deletions
+17 -100
View File
@@ -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;
}