forked from boos/BoosAPI
refactor: extract settings portal modals
This commit is contained in:
+30
-284
@@ -6,8 +6,9 @@ import { useToast } from '../components/Toast.js';
|
|||||||
import { useIsMobile } from '../components/useIsMobile.js';
|
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 { BrandGlyph, InlineBrandIcon, getBrand } from '../components/BrandIcon.js';
|
|
||||||
import ModernSelect from '../components/ModernSelect.js';
|
import ModernSelect from '../components/ModernSelect.js';
|
||||||
|
import FactoryResetModal from './settings/FactoryResetModal.js';
|
||||||
|
import RouteSelectorModal from './settings/RouteSelectorModal.js';
|
||||||
import {
|
import {
|
||||||
applyRoutingProfilePreset,
|
applyRoutingProfilePreset,
|
||||||
resolveRoutingProfilePreset,
|
resolveRoutingProfilePreset,
|
||||||
@@ -19,8 +20,6 @@ import { clearAppInstallationState } from '../appLocalState.js';
|
|||||||
import { tr } from '../i18n.js';
|
import { tr } from '../i18n.js';
|
||||||
import {
|
import {
|
||||||
isExactModelPattern,
|
isExactModelPattern,
|
||||||
resolveRouteBrand,
|
|
||||||
resolveRouteIcon,
|
|
||||||
resolveRouteTitle,
|
resolveRouteTitle,
|
||||||
} from './token-routes/utils.js';
|
} from './token-routes/utils.js';
|
||||||
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
|
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
|
||||||
@@ -1730,287 +1729,34 @@ export default function Settings() {
|
|||||||
);
|
);
|
||||||
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
||||||
})()}
|
})()}
|
||||||
{factoryResetPresence.shouldRender && (() => {
|
<FactoryResetModal
|
||||||
const confirmLabel = factoryResetting
|
presence={factoryResetPresence}
|
||||||
? '重新初始化中...'
|
factoryResetting={factoryResetting}
|
||||||
: (factoryResetSecondsLeft > 0
|
factoryResetSecondsLeft={factoryResetSecondsLeft}
|
||||||
? `确认重新初始化系统(${factoryResetSecondsLeft}s)`
|
adminToken={FACTORY_RESET_ADMIN_TOKEN}
|
||||||
: '确认重新初始化系统');
|
onClose={closeFactoryResetModal}
|
||||||
const modal = (
|
onConfirm={handleFactoryReset}
|
||||||
<div className={`modal-backdrop ${factoryResetPresence.isVisible ? '' : 'is-closing'}`.trim()} onClick={closeFactoryResetModal}>
|
/>
|
||||||
<div
|
<RouteSelectorModal
|
||||||
className={`modal-content ${factoryResetPresence.isVisible ? '' : 'is-closing'}`.trim()}
|
presence={selectorModalPresence}
|
||||||
style={{ maxWidth: 720, border: '1px solid color-mix(in srgb, var(--color-danger) 35%, var(--color-border))' }}
|
loading={selectorLoading}
|
||||||
onClick={(e) => e.stopPropagation()}
|
exactModelOptions={exactModelOptions}
|
||||||
>
|
filteredExactModelOptions={filteredExactModelOptions}
|
||||||
<div className="modal-header" style={{ color: 'var(--color-danger)' }}>确认重新初始化系统</div>
|
groupRouteOptions={groupRouteOptions}
|
||||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
filteredGroupRouteOptions={filteredGroupRouteOptions}
|
||||||
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
|
selectorModelSearch={selectorModelSearch}
|
||||||
这是不可逆操作。系统会清空当前 metapi 使用中的全部数据库内容,并在成功后立即退出当前登录状态。
|
selectorGroupSearch={selectorGroupSearch}
|
||||||
</div>
|
onSelectorModelSearchChange={setSelectorModelSearch}
|
||||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
|
onSelectorGroupSearchChange={setSelectorGroupSearch}
|
||||||
<div>• 当前若使用外部 MySQL/Postgres,也会先清空该外部库中的 metapi 数据。</div>
|
selection={{
|
||||||
<div>• 系统随后会强制切回默认 SQLite。</div>
|
selectedModels: downstreamCreate.selectedModels,
|
||||||
<div>• 管理员 Token 将重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{FACTORY_RESET_ADMIN_TOKEN}</code>。</div>
|
selectedGroupRouteIds: downstreamCreate.selectedGroupRouteIds,
|
||||||
<div>• 完成后会立即退出登录并刷新页面,回到当前首装初始状态。</div>
|
}}
|
||||||
</div>
|
onToggleModelSelection={toggleModelSelection}
|
||||||
</div>
|
onToggleGroupRouteSelection={toggleGroupRouteSelection}
|
||||||
<div className="modal-footer">
|
onClose={closeSelectorModal}
|
||||||
<button onClick={closeFactoryResetModal} disabled={factoryResetting} className="btn btn-ghost">取消</button>
|
inputStyle={inputStyle}
|
||||||
<button onClick={handleFactoryReset} disabled={factoryResetting || factoryResetSecondsLeft > 0} className="btn btn-danger">
|
/>
|
||||||
{factoryResetting
|
|
||||||
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> {confirmLabel}</>
|
|
||||||
: confirmLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
|
||||||
})()}
|
|
||||||
{selectorModalPresence.shouldRender && (() => {
|
|
||||||
const modal = (
|
|
||||||
<div className={`modal-backdrop ${selectorModalPresence.isVisible ? '' : 'is-closing'}`.trim()} onClick={closeSelectorModal}>
|
|
||||||
<div
|
|
||||||
className={`modal-content ${selectorModalPresence.isVisible ? '' : 'is-closing'}`.trim()}
|
|
||||||
style={{ maxWidth: 860 }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="modal-header">勾选模型和群组</div>
|
|
||||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
|
||||||
选择结果会保存到当前下游 API Key:精确模型用于模型白名单,群组用于路由范围限制。
|
|
||||||
</div>
|
|
||||||
{selectorLoading ? (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-muted)' }}>
|
|
||||||
<span className="spinner spinner-sm" />
|
|
||||||
加载路由中...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 12 }}>
|
|
||||||
<div style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 10 }}>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
|
|
||||||
精确模型 ({selectorModelSearch.trim()
|
|
||||||
? `${filteredExactModelOptions.length}/${exactModelOptions.length}`
|
|
||||||
: exactModelOptions.length})
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
value={selectorModelSearch}
|
|
||||||
onChange={(e) => setSelectorModelSearch(e.target.value)}
|
|
||||||
placeholder="搜索精确模型(支持模糊匹配)"
|
|
||||||
style={{ ...inputStyle, padding: '8px 10px', fontSize: 12, marginBottom: 8 }}
|
|
||||||
/>
|
|
||||||
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{exactModelOptions.length === 0 ? (
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可选精确模型</div>
|
|
||||||
) : filteredExactModelOptions.length === 0 ? (
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>没有匹配的精确模型</div>
|
|
||||||
) : filteredExactModelOptions.map((modelName) => {
|
|
||||||
const checked = downstreamCreate.selectedModels.includes(modelName);
|
|
||||||
const brand = getBrand(modelName);
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={modelName}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 10,
|
|
||||||
cursor: 'pointer',
|
|
||||||
border: `1px solid ${checked ? 'color-mix(in srgb, var(--color-primary) 45%, transparent)' : 'var(--color-border-light)'}`,
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: '8px 10px',
|
|
||||||
background: checked
|
|
||||||
? 'color-mix(in srgb, var(--color-primary) 9%, var(--color-bg-card))'
|
|
||||||
: 'var(--color-bg-card)',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => toggleModelSelection(modelName)}
|
|
||||||
style={{ width: 18, height: 18, accentColor: 'var(--color-primary)', flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
||||||
<code
|
|
||||||
style={{
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: 12,
|
|
||||||
background: 'var(--color-bg)',
|
|
||||||
padding: '4px 10px',
|
|
||||||
borderRadius: 8,
|
|
||||||
color: 'var(--color-text-primary)',
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
maxWidth: '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{brand ? (
|
|
||||||
<InlineBrandIcon model={modelName} size={18} />
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
borderRadius: 6,
|
|
||||||
background: 'var(--color-bg-card)',
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: 10,
|
|
||||||
color: 'var(--color-text-muted)',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{modelName.slice(0, 1).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
||||||
{modelName}
|
|
||||||
</span>
|
|
||||||
</code>
|
|
||||||
{brand && (
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', paddingLeft: 6 }}>
|
|
||||||
{brand.name}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 10 }}>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
|
|
||||||
群组 ({selectorGroupSearch.trim()
|
|
||||||
? `${filteredGroupRouteOptions.length}/${groupRouteOptions.length}`
|
|
||||||
: groupRouteOptions.length})
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
value={selectorGroupSearch}
|
|
||||||
onChange={(e) => setSelectorGroupSearch(e.target.value)}
|
|
||||||
placeholder="Search groups (name / pattern)"
|
|
||||||
style={{ ...inputStyle, padding: '8px 10px', fontSize: 12, marginBottom: 8 }}
|
|
||||||
/>
|
|
||||||
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{groupRouteOptions.length === 0 ? (
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可选群组</div>
|
|
||||||
) : filteredGroupRouteOptions.length === 0 ? (
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>没有匹配的群组</div>
|
|
||||||
) : filteredGroupRouteOptions.map((route) => {
|
|
||||||
const checked = downstreamCreate.selectedGroupRouteIds.includes(route.id);
|
|
||||||
const routeTitle = resolveRouteTitle(route);
|
|
||||||
const routeIcon = resolveRouteIcon(route);
|
|
||||||
const routeBrand = resolveRouteBrand(route);
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={route.id}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
gap: 10,
|
|
||||||
cursor: 'pointer',
|
|
||||||
border: `1px solid ${checked ? 'color-mix(in srgb, var(--color-primary) 45%, transparent)' : 'var(--color-border-light)'}`,
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: '8px 10px',
|
|
||||||
background: checked
|
|
||||||
? 'color-mix(in srgb, var(--color-primary) 9%, var(--color-bg-card))'
|
|
||||||
: 'var(--color-bg-card)',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
style={{ marginTop: 4, width: 18, height: 18, accentColor: 'var(--color-primary)', flexShrink: 0 }}
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => toggleGroupRouteSelection(route.id)}
|
|
||||||
/>
|
|
||||||
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
||||||
<code
|
|
||||||
style={{
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: 12,
|
|
||||||
background: 'var(--color-bg)',
|
|
||||||
padding: '4px 10px',
|
|
||||||
borderRadius: 8,
|
|
||||||
color: 'var(--color-text-primary)',
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
maxWidth: '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderRadius: 6,
|
|
||||||
background: 'var(--color-bg-card)',
|
|
||||||
flexShrink: 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
fontSize: 12,
|
|
||||||
lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{routeIcon.kind === 'brand' ? (
|
|
||||||
<BrandGlyph
|
|
||||||
icon={routeIcon.value}
|
|
||||||
alt={routeTitle}
|
|
||||||
size={18}
|
|
||||||
fallbackText={routeTitle}
|
|
||||||
/>
|
|
||||||
) : routeIcon.kind === 'text' ? (
|
|
||||||
routeIcon.value
|
|
||||||
) : routeIcon.kind === 'auto' && routeBrand ? (
|
|
||||||
<BrandGlyph brand={routeBrand} alt={routeTitle} size={18} fallbackText={routeTitle} />
|
|
||||||
) : routeIcon.kind === 'auto' ? (
|
|
||||||
<InlineBrandIcon model={route.modelPattern} size={18} />
|
|
||||||
) : routeIcon.kind === 'none' ? (
|
|
||||||
null
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
||||||
{routeTitle}
|
|
||||||
</span>
|
|
||||||
{!route.enabled && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 10,
|
|
||||||
padding: '1px 6px',
|
|
||||||
borderRadius: 999,
|
|
||||||
background: 'var(--color-danger-bg)',
|
|
||||||
color: 'var(--color-danger)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
已禁用
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</code>
|
|
||||||
<code style={{ fontSize: 11, color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', paddingLeft: 6 }}>
|
|
||||||
{route.modelPattern}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button onClick={closeSelectorModal} className="btn btn-ghost">关闭</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
describe('Settings factory reset modal extraction', () => {
|
||||||
|
it('delegates the destructive factory reset 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 FactoryResetModal from './settings/FactoryResetModal.js'");
|
||||||
|
expect(source).not.toContain('{factoryResetPresence.shouldRender && (() => {');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
describe('Settings route selector modal extraction', () => {
|
||||||
|
it('delegates the downstream route selector 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 RouteSelectorModal from './settings/RouteSelectorModal.js'");
|
||||||
|
expect(source).not.toContain('{selectorModalPresence.shouldRender && (() => {');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
type ModalPresence = {
|
||||||
|
shouldRender: boolean;
|
||||||
|
isVisible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FactoryResetModalProps = {
|
||||||
|
presence: ModalPresence;
|
||||||
|
factoryResetting: boolean;
|
||||||
|
factoryResetSecondsLeft: number;
|
||||||
|
adminToken: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FactoryResetModal({
|
||||||
|
presence,
|
||||||
|
factoryResetting,
|
||||||
|
factoryResetSecondsLeft,
|
||||||
|
adminToken,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: FactoryResetModalProps) {
|
||||||
|
if (!presence.shouldRender) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmLabel = factoryResetting
|
||||||
|
? '重新初始化中...'
|
||||||
|
: (factoryResetSecondsLeft > 0
|
||||||
|
? `确认重新初始化系统(${factoryResetSecondsLeft}s)`
|
||||||
|
: '确认重新初始化系统');
|
||||||
|
|
||||||
|
const modal = (
|
||||||
|
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||||
|
style={{ maxWidth: 720, border: '1px solid color-mix(in srgb, var(--color-danger) 35%, var(--color-border))' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="modal-header" style={{ color: 'var(--color-danger)' }}>确认重新初始化系统</div>
|
||||||
|
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
|
||||||
|
这是不可逆操作。系统会清空当前 metapi 使用中的全部数据库内容,并在成功后立即退出当前登录状态。
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
|
||||||
|
<div>• 当前若使用外部 MySQL/Postgres,也会先清空该外部库中的 metapi 数据。</div>
|
||||||
|
<div>• 系统随后会强制切回默认 SQLite。</div>
|
||||||
|
<div>• 管理员 Token 将重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{adminToken}</code>。</div>
|
||||||
|
<div>• 完成后会立即退出登录并刷新页面,回到当前首装初始状态。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button onClick={onClose} disabled={factoryResetting} className="btn btn-ghost">取消</button>
|
||||||
|
<button onClick={onConfirm} disabled={factoryResetting || factoryResetSecondsLeft > 0} className="btn btn-danger">
|
||||||
|
{factoryResetting
|
||||||
|
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> {confirmLabel}</>
|
||||||
|
: confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { BrandGlyph, InlineBrandIcon, getBrand } from '../../components/BrandIcon.js';
|
||||||
|
import {
|
||||||
|
resolveRouteBrand,
|
||||||
|
resolveRouteIcon,
|
||||||
|
resolveRouteTitle,
|
||||||
|
} from '../token-routes/utils.js';
|
||||||
|
|
||||||
|
type RouteSelectorItem = {
|
||||||
|
id: number;
|
||||||
|
modelPattern: string;
|
||||||
|
displayName?: string | null;
|
||||||
|
displayIcon?: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ModalPresence = {
|
||||||
|
shouldRender: boolean;
|
||||||
|
isVisible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DownstreamRouteSelection = {
|
||||||
|
selectedModels: string[];
|
||||||
|
selectedGroupRouteIds: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type RouteSelectorModalProps = {
|
||||||
|
presence: ModalPresence;
|
||||||
|
loading: boolean;
|
||||||
|
exactModelOptions: string[];
|
||||||
|
filteredExactModelOptions: string[];
|
||||||
|
groupRouteOptions: RouteSelectorItem[];
|
||||||
|
filteredGroupRouteOptions: RouteSelectorItem[];
|
||||||
|
selectorModelSearch: string;
|
||||||
|
selectorGroupSearch: string;
|
||||||
|
onSelectorModelSearchChange: (value: string) => void;
|
||||||
|
onSelectorGroupSearchChange: (value: string) => void;
|
||||||
|
selection: DownstreamRouteSelection;
|
||||||
|
onToggleModelSelection: (modelName: string) => void;
|
||||||
|
onToggleGroupRouteSelection: (routeId: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
inputStyle: React.CSSProperties;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RouteSelectorModal({
|
||||||
|
presence,
|
||||||
|
loading,
|
||||||
|
exactModelOptions,
|
||||||
|
filteredExactModelOptions,
|
||||||
|
groupRouteOptions,
|
||||||
|
filteredGroupRouteOptions,
|
||||||
|
selectorModelSearch,
|
||||||
|
selectorGroupSearch,
|
||||||
|
onSelectorModelSearchChange,
|
||||||
|
onSelectorGroupSearchChange,
|
||||||
|
selection,
|
||||||
|
onToggleModelSelection,
|
||||||
|
onToggleGroupRouteSelection,
|
||||||
|
onClose,
|
||||||
|
inputStyle,
|
||||||
|
}: RouteSelectorModalProps) {
|
||||||
|
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">勾选模型和群组</div>
|
||||||
|
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
|
选择结果会保存到当前下游 API Key:精确模型用于模型白名单,群组用于路由范围限制。
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-muted)' }}>
|
||||||
|
<span className="spinner spinner-sm" />
|
||||||
|
加载路由中...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 12 }}>
|
||||||
|
<div style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 10 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
|
||||||
|
精确模型 ({selectorModelSearch.trim()
|
||||||
|
? `${filteredExactModelOptions.length}/${exactModelOptions.length}`
|
||||||
|
: exactModelOptions.length})
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={selectorModelSearch}
|
||||||
|
onChange={(e) => onSelectorModelSearchChange(e.target.value)}
|
||||||
|
placeholder="搜索精确模型(支持模糊匹配)"
|
||||||
|
style={{ ...inputStyle, padding: '8px 10px', fontSize: 12, marginBottom: 8 }}
|
||||||
|
/>
|
||||||
|
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{exactModelOptions.length === 0 ? (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可选精确模型</div>
|
||||||
|
) : filteredExactModelOptions.length === 0 ? (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>没有匹配的精确模型</div>
|
||||||
|
) : filteredExactModelOptions.map((modelName) => {
|
||||||
|
const checked = selection.selectedModels.includes(modelName);
|
||||||
|
const brand = getBrand(modelName);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={modelName}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: `1px solid ${checked ? 'color-mix(in srgb, var(--color-primary) 45%, transparent)' : 'var(--color-border-light)'}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '8px 10px',
|
||||||
|
background: checked
|
||||||
|
? 'color-mix(in srgb, var(--color-primary) 9%, var(--color-bg-card))'
|
||||||
|
: 'var(--color-bg-card)',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => onToggleModelSelection(modelName)}
|
||||||
|
style={{ width: 18, height: 18, accentColor: 'var(--color-primary)', flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 12,
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
maxWidth: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{brand ? (
|
||||||
|
<InlineBrandIcon model={modelName} size={18} />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'var(--color-bg-card)',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 10,
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{modelName.slice(0, 1).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{modelName}
|
||||||
|
</span>
|
||||||
|
</code>
|
||||||
|
{brand ? (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', paddingLeft: 6 }}>
|
||||||
|
{brand.name}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 10 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
|
||||||
|
群组 ({selectorGroupSearch.trim()
|
||||||
|
? `${filteredGroupRouteOptions.length}/${groupRouteOptions.length}`
|
||||||
|
: groupRouteOptions.length})
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={selectorGroupSearch}
|
||||||
|
onChange={(e) => onSelectorGroupSearchChange(e.target.value)}
|
||||||
|
placeholder="Search groups (name / pattern)"
|
||||||
|
style={{ ...inputStyle, padding: '8px 10px', fontSize: 12, marginBottom: 8 }}
|
||||||
|
/>
|
||||||
|
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{groupRouteOptions.length === 0 ? (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可选群组</div>
|
||||||
|
) : filteredGroupRouteOptions.length === 0 ? (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>没有匹配的群组</div>
|
||||||
|
) : filteredGroupRouteOptions.map((route) => {
|
||||||
|
const checked = selection.selectedGroupRouteIds.includes(route.id);
|
||||||
|
const routeTitle = resolveRouteTitle(route);
|
||||||
|
const routeIcon = resolveRouteIcon(route);
|
||||||
|
const routeBrand = resolveRouteBrand(route);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={route.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 10,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: `1px solid ${checked ? 'color-mix(in srgb, var(--color-primary) 45%, transparent)' : 'var(--color-border-light)'}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '8px 10px',
|
||||||
|
background: checked
|
||||||
|
? 'color-mix(in srgb, var(--color-primary) 9%, var(--color-bg-card))'
|
||||||
|
: 'var(--color-bg-card)',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
style={{ marginTop: 4, width: 18, height: 18, accentColor: 'var(--color-primary)', flexShrink: 0 }}
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => onToggleGroupRouteSelection(route.id)}
|
||||||
|
/>
|
||||||
|
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 12,
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
maxWidth: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'var(--color-bg-card)',
|
||||||
|
flexShrink: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{routeIcon.kind === 'brand' ? (
|
||||||
|
<BrandGlyph
|
||||||
|
icon={routeIcon.value}
|
||||||
|
alt={routeTitle}
|
||||||
|
size={18}
|
||||||
|
fallbackText={routeTitle}
|
||||||
|
/>
|
||||||
|
) : routeIcon.kind === 'text' ? (
|
||||||
|
routeIcon.value
|
||||||
|
) : routeIcon.kind === 'auto' && routeBrand ? (
|
||||||
|
<BrandGlyph brand={routeBrand} alt={routeTitle} size={18} fallbackText={routeTitle} />
|
||||||
|
) : routeIcon.kind === 'auto' ? (
|
||||||
|
<InlineBrandIcon model={route.modelPattern} size={18} />
|
||||||
|
) : routeIcon.kind === 'none' ? null : null}
|
||||||
|
</span>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{routeTitle}
|
||||||
|
</span>
|
||||||
|
{!route.enabled ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
padding: '1px 6px',
|
||||||
|
borderRadius: 999,
|
||||||
|
background: 'var(--color-danger-bg)',
|
||||||
|
color: 'var(--color-danger)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
已禁用
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</code>
|
||||||
|
<code style={{ fontSize: 11, color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', paddingLeft: 6 }}>
|
||||||
|
{route.modelPattern}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button onClick={onClose} className="btn btn-ghost">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user