Merge upstream/main: 合并上游更新,保留本地 Kiro 功能
This commit is contained in:
@@ -43,7 +43,11 @@ export function ConfirmationModal() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={isOpen} onClose={handleCancel} title={title} closeDisabled={isLoading}>
|
<Modal open={isOpen} onClose={handleCancel} title={title} closeDisabled={isLoading}>
|
||||||
<p style={{ margin: '1rem 0' }}>{message}</p>
|
{typeof message === 'string' ? (
|
||||||
|
<p style={{ margin: '1rem 0' }}>{message}</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ margin: '1rem 0' }}>{message}</div>
|
||||||
|
)}
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '1rem', marginTop: '2rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '1rem', marginTop: '2rem' }}>
|
||||||
<Button variant="ghost" onClick={handleCancel} disabled={isLoading}>
|
<Button variant="ghost" onClick={handleCancel} disabled={isLoading}>
|
||||||
{cancelText || t('common.cancel')}
|
{cancelText || t('common.cancel')}
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { ReactNode, useCallback, useLayoutEffect, useRef, useState } from 'react';
|
import {
|
||||||
|
ReactNode,
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import { useLocation, type Location } from 'react-router-dom';
|
import { useLocation, type Location } from 'react-router-dom';
|
||||||
import gsap from 'gsap';
|
import gsap from 'gsap';
|
||||||
import './PageTransition.scss';
|
import './PageTransition.scss';
|
||||||
@@ -31,6 +39,16 @@ type TransitionDirection = 'forward' | 'backward';
|
|||||||
|
|
||||||
type TransitionVariant = 'vertical' | 'ios';
|
type TransitionVariant = 'vertical' | 'ios';
|
||||||
|
|
||||||
|
type PageTransitionLayerContextValue = {
|
||||||
|
status: LayerStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PageTransitionLayerContext = createContext<PageTransitionLayerContextValue | null>(null);
|
||||||
|
|
||||||
|
export function usePageTransitionLayer() {
|
||||||
|
return useContext(PageTransitionLayerContext);
|
||||||
|
}
|
||||||
|
|
||||||
export function PageTransition({
|
export function PageTransition({
|
||||||
render,
|
render,
|
||||||
getRouteOrder,
|
getRouteOrder,
|
||||||
@@ -363,7 +381,9 @@ export function PageTransition({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{render(layer.location)}
|
<PageTransitionLayerContext.Provider value={{ status: layer.status }}>
|
||||||
|
{render(layer.location)}
|
||||||
|
</PageTransitionLayerContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
359
src/components/modelAlias/ModelMappingDiagram.module.scss
Normal file
359
src/components/modelAlias/ModelMappingDiagram.module.scss
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.scrollContainer {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tapHint {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 3;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: inline-flex;
|
||||||
|
position: relative;
|
||||||
|
min-width: 100%;
|
||||||
|
min-height: 300px;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 0;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
// Give mobile extra horizontal room to reduce line overlap; users can swipe to scroll.
|
||||||
|
min-width: max(100%, 960px);
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVG layer for connection lines (behind columns so links are visible)
|
||||||
|
.connections {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
path {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
z-index: 2;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
|
||||||
|
&.providers {
|
||||||
|
align-items: flex-end;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sources {
|
||||||
|
align-items: flex-start;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.aliases {
|
||||||
|
align-items: flex-start;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.columnHeader {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 280px;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dropTarget {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mindmap-style provider branch (root node)
|
||||||
|
.providerItem {
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
padding-left: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.providerLabel {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapseBtn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: background-color 0.15s, color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevronDown,
|
||||||
|
.chevronRight {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevronDown {
|
||||||
|
border-width: 5px 4px 0 4px;
|
||||||
|
border-color: currentColor transparent transparent transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevronRight {
|
||||||
|
border-width: 4px 0 4px 5px;
|
||||||
|
border-color: transparent transparent transparent currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerGroup {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceItem,
|
||||||
|
.aliasItem {
|
||||||
|
cursor: grab;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
margin-top: -3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.dotLeft {
|
||||||
|
left: -3px;
|
||||||
|
background: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceItem .dot {
|
||||||
|
right: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerBadge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-right: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemName {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemCount {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-left: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contextMenu {
|
||||||
|
position: fixed;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 9999;
|
||||||
|
min-width: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 4px 0;
|
||||||
|
|
||||||
|
.menuItem {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.1s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
color: var(--error-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bg-error-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuDivider {
|
||||||
|
height: 1px;
|
||||||
|
margin: 4px 0;
|
||||||
|
background: var(--border-color);
|
||||||
|
padding: 0;
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsEmpty {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
padding: $spacing-lg 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(200px, 1fr) auto;
|
||||||
|
gap: $spacing-md;
|
||||||
|
align-items: center;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsNames {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsSource,
|
||||||
|
.settingsAlias {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsArrow {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsDelete {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--error-color);
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-error-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
659
src/components/modelAlias/ModelMappingDiagram.tsx
Normal file
659
src/components/modelAlias/ModelMappingDiagram.tsx
Normal file
@@ -0,0 +1,659 @@
|
|||||||
|
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent as ReactMouseEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { OAuthModelAliasEntry } from '@/types';
|
||||||
|
import { useThemeStore } from '@/stores';
|
||||||
|
import { AliasColumn, ProviderColumn, SourceColumn } from './ModelMappingDiagramColumns';
|
||||||
|
import { DiagramContextMenu } from './ModelMappingDiagramContextMenu';
|
||||||
|
import {
|
||||||
|
AddAliasModal,
|
||||||
|
RenameAliasModal,
|
||||||
|
SettingsAliasModal,
|
||||||
|
SettingsSourceModal
|
||||||
|
} from './ModelMappingDiagramModals';
|
||||||
|
import type {
|
||||||
|
AliasNode,
|
||||||
|
AuthFileModelItem,
|
||||||
|
ContextMenuState,
|
||||||
|
DiagramLine,
|
||||||
|
SourceNode
|
||||||
|
} from './ModelMappingDiagramTypes';
|
||||||
|
import styles from './ModelMappingDiagram.module.scss';
|
||||||
|
|
||||||
|
export interface ModelMappingDiagramProps {
|
||||||
|
modelAlias: Record<string, OAuthModelAliasEntry[]>;
|
||||||
|
allProviderModels?: Record<string, AuthFileModelItem[]>;
|
||||||
|
onUpdate?: (provider: string, sourceModel: string, newAlias: string) => void;
|
||||||
|
onDeleteLink?: (provider: string, sourceModel: string, alias: string) => void;
|
||||||
|
onToggleFork?: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
|
||||||
|
onRenameAlias?: (oldAlias: string, newAlias: string) => void;
|
||||||
|
onDeleteAlias?: (alias: string) => void;
|
||||||
|
onEditProvider?: (provider: string) => void;
|
||||||
|
onDeleteProvider?: (provider: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDER_COLORS = [
|
||||||
|
'#3b82f6', '#10b981', '#f59e0b', '#ef4444',
|
||||||
|
'#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'
|
||||||
|
];
|
||||||
|
|
||||||
|
function getProviderColor(provider: string): string {
|
||||||
|
const hash = provider.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
||||||
|
return PROVIDER_COLORS[hash % PROVIDER_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelMappingDiagramRef {
|
||||||
|
collapseAll: () => void;
|
||||||
|
refreshLayout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModelMappingDiagram = forwardRef<ModelMappingDiagramRef, ModelMappingDiagramProps>(function ModelMappingDiagram({
|
||||||
|
modelAlias,
|
||||||
|
allProviderModels = {},
|
||||||
|
onUpdate,
|
||||||
|
onDeleteLink,
|
||||||
|
onToggleFork,
|
||||||
|
onRenameAlias,
|
||||||
|
onDeleteAlias,
|
||||||
|
onEditProvider,
|
||||||
|
onDeleteProvider,
|
||||||
|
className
|
||||||
|
}, ref) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
|
const isDark = resolvedTheme === 'dark';
|
||||||
|
const enableTapLinking = useMemo(() => {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia === 'undefined') return false;
|
||||||
|
return (
|
||||||
|
window.matchMedia('(any-pointer: coarse)').matches &&
|
||||||
|
!window.matchMedia('(any-pointer: fine)').matches
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [lines, setLines] = useState<DiagramLine[]>([]);
|
||||||
|
const [draggedSource, setDraggedSource] = useState<SourceNode | null>(null);
|
||||||
|
const [draggedAlias, setDraggedAlias] = useState<string | null>(null);
|
||||||
|
const [dropTargetAlias, setDropTargetAlias] = useState<string | null>(null);
|
||||||
|
const [dropTargetSource, setDropTargetSource] = useState<string | null>(null);
|
||||||
|
const [tapSourceId, setTapSourceId] = useState<string | null>(null);
|
||||||
|
const [tapAlias, setTapAlias] = useState<string | null>(null);
|
||||||
|
const [extraAliases, setExtraAliases] = useState<string[]>([]);
|
||||||
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||||
|
const [collapsedProviders, setCollapsedProviders] = useState<Set<string>>(new Set());
|
||||||
|
const [providerGroupHeights, setProviderGroupHeights] = useState<Record<string, number>>({});
|
||||||
|
const [renameState, setRenameState] = useState<{ oldAlias: string } | null>(null);
|
||||||
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
const [renameError, setRenameError] = useState('');
|
||||||
|
const [addAliasOpen, setAddAliasOpen] = useState(false);
|
||||||
|
const [addAliasValue, setAddAliasValue] = useState('');
|
||||||
|
const [addAliasError, setAddAliasError] = useState('');
|
||||||
|
const [settingsAlias, setSettingsAlias] = useState<string | null>(null);
|
||||||
|
const [settingsSourceId, setSettingsSourceId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Parse data: each source model (provider+name) and each alias is distinct by id; 1 source -> many aliases.
|
||||||
|
const { aliasNodes, providerNodes } = useMemo(() => {
|
||||||
|
const sourceMap = new Map<
|
||||||
|
string,
|
||||||
|
{ provider: string; name: string; aliases: Map<string, boolean> }
|
||||||
|
>();
|
||||||
|
const aliasSet = new Set<string>();
|
||||||
|
|
||||||
|
// 1. Existing mappings: group by (provider, name), each source has a set of aliases
|
||||||
|
Object.entries(modelAlias).forEach(([provider, mappings]) => {
|
||||||
|
(mappings ?? []).forEach((m) => {
|
||||||
|
const name = (m?.name || '').trim();
|
||||||
|
const alias = (m?.alias || '').trim();
|
||||||
|
if (!name || !alias) return;
|
||||||
|
|
||||||
|
const pk = `${provider.toLowerCase()}::${name.toLowerCase()}`;
|
||||||
|
if (!sourceMap.has(pk)) {
|
||||||
|
sourceMap.set(pk, { provider, name, aliases: new Map() });
|
||||||
|
}
|
||||||
|
sourceMap.get(pk)!.aliases.set(alias, m?.fork === true);
|
||||||
|
aliasSet.add(alias);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Unmapped models from allProviderModels (no mapping yet)
|
||||||
|
Object.entries(allProviderModels).forEach(([provider, models]) => {
|
||||||
|
(models ?? []).forEach((m) => {
|
||||||
|
const name = (m.id || '').trim();
|
||||||
|
if (!name) return;
|
||||||
|
const pk = `${provider.toLowerCase()}::${name.toLowerCase()}`;
|
||||||
|
if (sourceMap.has(pk)) {
|
||||||
|
// Already in sourceMap from mappings; keep provider from mapping for correct grouping.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sourceMap.set(pk, { provider, name, aliases: new Map() });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Source nodes: distinct by id = provider::name
|
||||||
|
const sources: SourceNode[] = Array.from(sourceMap.entries())
|
||||||
|
.map(([id, v]) => ({
|
||||||
|
id,
|
||||||
|
provider: v.provider,
|
||||||
|
name: v.name,
|
||||||
|
aliases: Array.from(v.aliases.entries()).map(([alias, fork]) => ({ alias, fork }))
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Extra aliases (no mapping yet)
|
||||||
|
extraAliases.forEach((alias) => aliasSet.add(alias));
|
||||||
|
|
||||||
|
// 5. Alias nodes: distinct by id = alias; sources = SourceNodes that have this alias in their aliases
|
||||||
|
const aliasNodesList: AliasNode[] = Array.from(aliasSet)
|
||||||
|
.map((alias) => ({
|
||||||
|
id: alias,
|
||||||
|
alias,
|
||||||
|
sources: sources.filter((s) => s.aliases.some((entry) => entry.alias === alias))
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (b.sources.length !== a.sources.length) return b.sources.length - a.sources.length;
|
||||||
|
return a.alias.localeCompare(b.alias);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Group sources by provider
|
||||||
|
const providerMap = new Map<string, SourceNode[]>();
|
||||||
|
sources.forEach((s) => {
|
||||||
|
if (!providerMap.has(s.provider)) providerMap.set(s.provider, []);
|
||||||
|
providerMap.get(s.provider)!.push(s);
|
||||||
|
});
|
||||||
|
const providerNodesList = Array.from(providerMap.entries())
|
||||||
|
.map(([provider, providerSources]) => ({ provider, sources: providerSources }))
|
||||||
|
.sort((a, b) => a.provider.localeCompare(b.provider));
|
||||||
|
|
||||||
|
return { aliasNodes: aliasNodesList, providerNodes: providerNodesList };
|
||||||
|
}, [modelAlias, allProviderModels, extraAliases]);
|
||||||
|
|
||||||
|
// Track element positions
|
||||||
|
const providerRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
|
const sourceRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
|
const aliasRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
|
|
||||||
|
const toggleProviderCollapse = (provider: string) => {
|
||||||
|
setCollapsedProviders((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(provider)) next.delete(provider);
|
||||||
|
else next.add(provider);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate lines: provider→source, source→alias (when expanded); midpoint + linkData for source→alias
|
||||||
|
const updateLines = useCallback(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||||||
|
const newLines: { path: string; color: string; id: string }[] = [];
|
||||||
|
const nextProviderGroupHeights: Record<string, number> = {};
|
||||||
|
|
||||||
|
const bezier = (
|
||||||
|
x1: number, y1: number,
|
||||||
|
x2: number, y2: number
|
||||||
|
) => {
|
||||||
|
const cpx1 = x1 + (x2 - x1) * 0.5;
|
||||||
|
const cpx2 = x2 - (x2 - x1) * 0.5;
|
||||||
|
return `M ${x1} ${y1} C ${cpx1} ${y1}, ${cpx2} ${y2}, ${x2} ${y2}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
providerNodes.forEach(({ provider, sources }) => {
|
||||||
|
const collapsed = collapsedProviders.has(provider);
|
||||||
|
if (collapsed) return;
|
||||||
|
|
||||||
|
if (sources.length > 0) {
|
||||||
|
const firstEl = sourceRefs.current.get(sources[0].id);
|
||||||
|
const lastEl = sourceRefs.current.get(sources[sources.length - 1].id);
|
||||||
|
if (firstEl && lastEl) {
|
||||||
|
const height = Math.max(0, Math.round(lastEl.getBoundingClientRect().bottom - firstEl.getBoundingClientRect().top));
|
||||||
|
if (height > 0) nextProviderGroupHeights[provider] = height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerEl = providerRefs.current.get(provider);
|
||||||
|
if (!providerEl) return;
|
||||||
|
const providerRect = providerEl.getBoundingClientRect();
|
||||||
|
const px = providerRect.right - containerRect.left;
|
||||||
|
const py = providerRect.top + providerRect.height / 2 - containerRect.top;
|
||||||
|
const color = getProviderColor(provider);
|
||||||
|
|
||||||
|
// Provider → Source (branch link, no dot)
|
||||||
|
sources.forEach((source) => {
|
||||||
|
const sourceEl = sourceRefs.current.get(source.id);
|
||||||
|
if (!sourceEl) return;
|
||||||
|
const sourceRect = sourceEl.getBoundingClientRect();
|
||||||
|
const sx = sourceRect.left - containerRect.left;
|
||||||
|
const sy = sourceRect.top + sourceRect.height / 2 - containerRect.top;
|
||||||
|
newLines.push({
|
||||||
|
id: `provider-${provider}-source-${source.id}`,
|
||||||
|
path: bezier(px, py, sx, sy),
|
||||||
|
color
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Source → Alias: one line per alias
|
||||||
|
sources.forEach((source) => {
|
||||||
|
if (!source.aliases || source.aliases.length === 0) return;
|
||||||
|
|
||||||
|
source.aliases.forEach((aliasEntry) => {
|
||||||
|
const sourceEl = sourceRefs.current.get(source.id);
|
||||||
|
const aliasEl = aliasRefs.current.get(aliasEntry.alias);
|
||||||
|
if (!sourceEl || !aliasEl) return;
|
||||||
|
|
||||||
|
const sourceRect = sourceEl.getBoundingClientRect();
|
||||||
|
const aliasRect = aliasEl.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Calculate coordinates relative to the container
|
||||||
|
const x1 = sourceRect.right - containerRect.left;
|
||||||
|
const y1 = sourceRect.top + sourceRect.height / 2 - containerRect.top;
|
||||||
|
const x2 = aliasRect.left - containerRect.left;
|
||||||
|
const y2 = aliasRect.top + aliasRect.height / 2 - containerRect.top;
|
||||||
|
|
||||||
|
newLines.push({
|
||||||
|
id: `${source.id}-${aliasEntry.alias}`,
|
||||||
|
path: bezier(x1, y1, x2, y2),
|
||||||
|
color
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setLines(newLines);
|
||||||
|
setProviderGroupHeights((prev) => {
|
||||||
|
const prevKeys = Object.keys(prev);
|
||||||
|
const nextKeys = Object.keys(nextProviderGroupHeights);
|
||||||
|
if (prevKeys.length !== nextKeys.length) return nextProviderGroupHeights;
|
||||||
|
for (const key of nextKeys) {
|
||||||
|
if (!(key in prev) || prev[key] !== nextProviderGroupHeights[key]) {
|
||||||
|
return nextProviderGroupHeights;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, [providerNodes, collapsedProviders]);
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
collapseAll: () => setCollapsedProviders(new Set(providerNodes.map((p) => p.provider))),
|
||||||
|
refreshLayout: () => updateLines()
|
||||||
|
}),
|
||||||
|
[providerNodes, updateLines]
|
||||||
|
);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
// updateLines is called after layout is calculated, ensuring elements are in place.
|
||||||
|
updateLines();
|
||||||
|
const raf = requestAnimationFrame(updateLines);
|
||||||
|
window.addEventListener('resize', updateLines);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
window.removeEventListener('resize', updateLines);
|
||||||
|
};
|
||||||
|
}, [updateLines, aliasNodes]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
updateLines();
|
||||||
|
const raf = requestAnimationFrame(updateLines);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [providerGroupHeights, updateLines]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current || typeof ResizeObserver === 'undefined') return;
|
||||||
|
const observer = new ResizeObserver(() => updateLines());
|
||||||
|
observer.observe(containerRef.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [updateLines]);
|
||||||
|
|
||||||
|
// Drag and Drop handlers
|
||||||
|
// 1. Source -> Alias
|
||||||
|
const handleDragStart = (e: DragEvent, source: SourceNode) => {
|
||||||
|
setTapSourceId(null);
|
||||||
|
setTapAlias(null);
|
||||||
|
setDraggedSource(source);
|
||||||
|
e.dataTransfer.setData('text/plain', source.id);
|
||||||
|
e.dataTransfer.effectAllowed = 'link';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: DragEvent, alias: string) => {
|
||||||
|
if (!draggedSource || draggedSource.aliases.some((entry) => entry.alias === alias)) return;
|
||||||
|
e.preventDefault(); // Allow drop
|
||||||
|
e.dataTransfer.dropEffect = 'link';
|
||||||
|
setDropTargetAlias(alias);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
setDropTargetAlias(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: DragEvent, alias: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (draggedSource && !draggedSource.aliases.some((entry) => entry.alias === alias) && onUpdate) {
|
||||||
|
onUpdate(draggedSource.provider, draggedSource.name, alias);
|
||||||
|
}
|
||||||
|
setDraggedSource(null);
|
||||||
|
setDropTargetAlias(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Alias -> Source
|
||||||
|
const handleDragStartAlias = (e: DragEvent, alias: string) => {
|
||||||
|
setTapSourceId(null);
|
||||||
|
setTapAlias(null);
|
||||||
|
setDraggedAlias(alias);
|
||||||
|
e.dataTransfer.setData('text/plain', alias);
|
||||||
|
e.dataTransfer.effectAllowed = 'link';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOverSource = (e: DragEvent, source: SourceNode) => {
|
||||||
|
if (!draggedAlias || source.aliases.some((entry) => entry.alias === draggedAlias)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'link';
|
||||||
|
setDropTargetSource(source.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeaveSource = () => {
|
||||||
|
setDropTargetSource(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDropOnSource = (e: DragEvent, source: SourceNode) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (draggedAlias && !source.aliases.some((entry) => entry.alias === draggedAlias) && onUpdate) {
|
||||||
|
onUpdate(source.provider, source.name, draggedAlias);
|
||||||
|
}
|
||||||
|
setDraggedAlias(null);
|
||||||
|
setDropTargetSource(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContextMenu = (
|
||||||
|
e: ReactMouseEvent,
|
||||||
|
type: 'alias' | 'background' | 'provider' | 'source',
|
||||||
|
data?: string
|
||||||
|
) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setContextMenu({
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
type,
|
||||||
|
data
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeContextMenu = () => setContextMenu(null);
|
||||||
|
|
||||||
|
const resolveSourceById = useCallback(
|
||||||
|
(id: string | null) => {
|
||||||
|
if (!id) return null;
|
||||||
|
for (const { sources } of providerNodes) {
|
||||||
|
const found = sources.find((source) => source.id === id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[providerNodes]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTapSelectSource = (source: SourceNode) => {
|
||||||
|
if (!onUpdate) return;
|
||||||
|
if (tapSourceId === source.id) {
|
||||||
|
setTapSourceId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tapAlias) {
|
||||||
|
onUpdate(source.provider, source.name, tapAlias);
|
||||||
|
setTapSourceId(null);
|
||||||
|
setTapAlias(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTapSourceId(source.id);
|
||||||
|
setTapAlias(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTapSelectAlias = (alias: string) => {
|
||||||
|
if (!onUpdate) return;
|
||||||
|
if (tapAlias === alias) {
|
||||||
|
setTapAlias(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tapSourceId) {
|
||||||
|
const source = resolveSourceById(tapSourceId);
|
||||||
|
if (source) {
|
||||||
|
onUpdate(source.provider, source.name, alias);
|
||||||
|
}
|
||||||
|
setTapSourceId(null);
|
||||||
|
setTapAlias(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTapAlias(alias);
|
||||||
|
setTapSourceId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnlinkSource = (provider: string, sourceModel: string, alias: string) => {
|
||||||
|
if (onDeleteLink) onDeleteLink(provider, sourceModel, alias);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleFork = (
|
||||||
|
provider: string,
|
||||||
|
sourceModel: string,
|
||||||
|
alias: string,
|
||||||
|
value: boolean
|
||||||
|
) => {
|
||||||
|
if (onToggleFork) onToggleFork(provider, sourceModel, alias, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddAlias = () => {
|
||||||
|
closeContextMenu();
|
||||||
|
setAddAliasOpen(true);
|
||||||
|
setAddAliasValue('');
|
||||||
|
setAddAliasError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddAliasSubmit = () => {
|
||||||
|
const trimmed = addAliasValue.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setAddAliasError(t('oauth_model_alias.diagram_please_enter_alias'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (aliasNodes.some(a => a.alias === trimmed)) {
|
||||||
|
setAddAliasError(t('oauth_model_alias.diagram_alias_exists'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExtraAliases(prev => [...prev, trimmed]);
|
||||||
|
setAddAliasOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRenameClick = (oldAlias: string) => {
|
||||||
|
closeContextMenu();
|
||||||
|
setRenameState({ oldAlias });
|
||||||
|
setRenameValue(oldAlias);
|
||||||
|
setRenameError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRenameSubmit = () => {
|
||||||
|
const trimmed = renameValue.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setRenameError(t('oauth_model_alias.diagram_please_enter_alias'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trimmed === renameState?.oldAlias) {
|
||||||
|
setRenameState(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (aliasNodes.some(a => a.alias === trimmed)) {
|
||||||
|
setRenameError(t('oauth_model_alias.diagram_alias_exists'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (onRenameAlias && renameState) onRenameAlias(renameState.oldAlias, trimmed);
|
||||||
|
if (extraAliases.includes(renameState?.oldAlias ?? '')) {
|
||||||
|
setExtraAliases(prev => prev.map(a => a === renameState?.oldAlias ? trimmed : a));
|
||||||
|
}
|
||||||
|
setRenameState(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (alias: string) => {
|
||||||
|
closeContextMenu();
|
||||||
|
const node = aliasNodes.find(n => n.alias === alias);
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
if (node.sources.length === 0) {
|
||||||
|
setExtraAliases(prev => prev.filter(a => a !== alias));
|
||||||
|
} else {
|
||||||
|
if (onDeleteAlias) onDeleteAlias(alias);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={[styles.scrollContainer, className].filter(Boolean).join(' ')}>
|
||||||
|
{enableTapLinking && onUpdate && (
|
||||||
|
<div className={styles.tapHint}>{t('oauth_model_alias.diagram_tap_hint')}</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={styles.container}
|
||||||
|
ref={containerRef}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleContextMenu(e, 'background');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg className={styles.connections}>
|
||||||
|
{lines.map((line) => (
|
||||||
|
<path
|
||||||
|
key={line.id}
|
||||||
|
d={line.path}
|
||||||
|
stroke={line.color}
|
||||||
|
strokeOpacity={isDark ? 0.4 : 0.3}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<ProviderColumn
|
||||||
|
providerNodes={providerNodes}
|
||||||
|
collapsedProviders={collapsedProviders}
|
||||||
|
getProviderColor={getProviderColor}
|
||||||
|
providerGroupHeights={providerGroupHeights}
|
||||||
|
providerRefs={providerRefs}
|
||||||
|
onToggleCollapse={toggleProviderCollapse}
|
||||||
|
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
|
||||||
|
label={t('oauth_model_alias.diagram_providers')}
|
||||||
|
expandLabel={t('oauth_model_alias.diagram_expand')}
|
||||||
|
collapseLabel={t('oauth_model_alias.diagram_collapse')}
|
||||||
|
/>
|
||||||
|
<SourceColumn
|
||||||
|
providerNodes={providerNodes}
|
||||||
|
collapsedProviders={collapsedProviders}
|
||||||
|
sourceRefs={sourceRefs}
|
||||||
|
getProviderColor={getProviderColor}
|
||||||
|
selectedSourceId={enableTapLinking ? tapSourceId : null}
|
||||||
|
onSelectSource={enableTapLinking ? handleTapSelectSource : undefined}
|
||||||
|
draggedSource={draggedSource}
|
||||||
|
dropTargetSource={dropTargetSource}
|
||||||
|
draggable={!!onUpdate}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={() => {
|
||||||
|
setDraggedSource(null);
|
||||||
|
setDropTargetAlias(null);
|
||||||
|
}}
|
||||||
|
onDragOver={handleDragOverSource}
|
||||||
|
onDragLeave={handleDragLeaveSource}
|
||||||
|
onDrop={handleDropOnSource}
|
||||||
|
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
|
||||||
|
label={t('oauth_model_alias.diagram_source_models')}
|
||||||
|
/>
|
||||||
|
<AliasColumn
|
||||||
|
aliasNodes={aliasNodes}
|
||||||
|
aliasRefs={aliasRefs}
|
||||||
|
dropTargetAlias={dropTargetAlias}
|
||||||
|
draggedAlias={draggedAlias}
|
||||||
|
selectedAlias={enableTapLinking ? tapAlias : null}
|
||||||
|
onSelectAlias={enableTapLinking ? handleTapSelectAlias : undefined}
|
||||||
|
draggable={!!onUpdate}
|
||||||
|
onDragStart={handleDragStartAlias}
|
||||||
|
onDragEnd={() => {
|
||||||
|
setDraggedAlias(null);
|
||||||
|
setDropTargetSource(null);
|
||||||
|
}}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
|
||||||
|
label={t('oauth_model_alias.diagram_aliases')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DiagramContextMenu
|
||||||
|
contextMenu={contextMenu}
|
||||||
|
t={t}
|
||||||
|
onRequestClose={() => setContextMenu(null)}
|
||||||
|
onAddAlias={handleAddAlias}
|
||||||
|
onRenameAlias={handleRenameClick}
|
||||||
|
onOpenAliasSettings={(alias) => {
|
||||||
|
setContextMenu(null);
|
||||||
|
setSettingsAlias(alias);
|
||||||
|
}}
|
||||||
|
onDeleteAlias={handleDeleteClick}
|
||||||
|
onEditProvider={(provider) => {
|
||||||
|
setContextMenu(null);
|
||||||
|
onEditProvider?.(provider);
|
||||||
|
}}
|
||||||
|
onDeleteProvider={(provider) => {
|
||||||
|
setContextMenu(null);
|
||||||
|
onDeleteProvider?.(provider);
|
||||||
|
}}
|
||||||
|
onOpenSourceSettings={(sourceId) => {
|
||||||
|
setContextMenu(null);
|
||||||
|
setSettingsSourceId(sourceId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RenameAliasModal
|
||||||
|
open={!!renameState}
|
||||||
|
t={t}
|
||||||
|
value={renameValue}
|
||||||
|
error={renameError}
|
||||||
|
onChange={(value) => {
|
||||||
|
setRenameValue(value);
|
||||||
|
setRenameError('');
|
||||||
|
}}
|
||||||
|
onClose={() => setRenameState(null)}
|
||||||
|
onSubmit={handleRenameSubmit}
|
||||||
|
/>
|
||||||
|
<AddAliasModal
|
||||||
|
open={addAliasOpen}
|
||||||
|
t={t}
|
||||||
|
value={addAliasValue}
|
||||||
|
error={addAliasError}
|
||||||
|
onChange={(value) => {
|
||||||
|
setAddAliasValue(value);
|
||||||
|
setAddAliasError('');
|
||||||
|
}}
|
||||||
|
onClose={() => setAddAliasOpen(false)}
|
||||||
|
onSubmit={handleAddAliasSubmit}
|
||||||
|
/>
|
||||||
|
<SettingsAliasModal
|
||||||
|
open={Boolean(settingsAlias)}
|
||||||
|
t={t}
|
||||||
|
alias={settingsAlias}
|
||||||
|
aliasNodes={aliasNodes}
|
||||||
|
onClose={() => setSettingsAlias(null)}
|
||||||
|
onToggleFork={handleToggleFork}
|
||||||
|
onUnlink={handleUnlinkSource}
|
||||||
|
/>
|
||||||
|
<SettingsSourceModal
|
||||||
|
open={Boolean(settingsSourceId)}
|
||||||
|
t={t}
|
||||||
|
source={resolveSourceById(settingsSourceId)}
|
||||||
|
onClose={() => setSettingsSourceId(null)}
|
||||||
|
onToggleFork={handleToggleFork}
|
||||||
|
onUnlink={handleUnlinkSource}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
251
src/components/modelAlias/ModelMappingDiagramColumns.tsx
Normal file
251
src/components/modelAlias/ModelMappingDiagramColumns.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import type { DragEvent, MouseEvent as ReactMouseEvent, RefObject } from 'react';
|
||||||
|
import type { AliasNode, ProviderNode, SourceNode } from './ModelMappingDiagramTypes';
|
||||||
|
import styles from './ModelMappingDiagram.module.scss';
|
||||||
|
|
||||||
|
interface ProviderColumnProps {
|
||||||
|
providerNodes: ProviderNode[];
|
||||||
|
collapsedProviders: Set<string>;
|
||||||
|
getProviderColor: (provider: string) => string;
|
||||||
|
providerGroupHeights?: Record<string, number>;
|
||||||
|
providerRefs: RefObject<Map<string, HTMLDivElement>>;
|
||||||
|
onToggleCollapse: (provider: string) => void;
|
||||||
|
onContextMenu: (e: ReactMouseEvent, type: 'provider' | 'background', data?: string) => void;
|
||||||
|
label: string;
|
||||||
|
expandLabel: string;
|
||||||
|
collapseLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderColumn({
|
||||||
|
providerNodes,
|
||||||
|
collapsedProviders,
|
||||||
|
getProviderColor,
|
||||||
|
providerGroupHeights = {},
|
||||||
|
providerRefs,
|
||||||
|
onToggleCollapse,
|
||||||
|
onContextMenu,
|
||||||
|
label,
|
||||||
|
expandLabel,
|
||||||
|
collapseLabel
|
||||||
|
}: ProviderColumnProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.column} ${styles.providers}`}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onContextMenu(e, 'background');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.columnHeader}>{label}</div>
|
||||||
|
{providerNodes.map(({ provider, sources }) => {
|
||||||
|
const collapsed = collapsedProviders.has(provider);
|
||||||
|
const groupHeight = collapsed ? undefined : providerGroupHeights[provider];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={provider}
|
||||||
|
className={styles.providerGroup}
|
||||||
|
style={groupHeight ? { height: groupHeight } : undefined}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) providerRefs.current?.set(provider, el);
|
||||||
|
else providerRefs.current?.delete(provider);
|
||||||
|
}}
|
||||||
|
className={`${styles.item} ${styles.providerItem}`}
|
||||||
|
style={{ borderLeftColor: getProviderColor(provider) }}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onContextMenu(e, 'provider', provider);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.collapseBtn}
|
||||||
|
onClick={() => onToggleCollapse(provider)}
|
||||||
|
aria-label={collapsed ? expandLabel : collapseLabel}
|
||||||
|
title={collapsed ? expandLabel : collapseLabel}
|
||||||
|
>
|
||||||
|
<span className={collapsed ? styles.chevronRight : styles.chevronDown} />
|
||||||
|
</button>
|
||||||
|
<span className={styles.providerLabel} style={{ color: getProviderColor(provider) }}>
|
||||||
|
{provider}
|
||||||
|
</span>
|
||||||
|
<span className={styles.itemCount}>{sources.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SourceColumnProps {
|
||||||
|
providerNodes: ProviderNode[];
|
||||||
|
collapsedProviders: Set<string>;
|
||||||
|
sourceRefs: RefObject<Map<string, HTMLDivElement>>;
|
||||||
|
getProviderColor: (provider: string) => string;
|
||||||
|
selectedSourceId?: string | null;
|
||||||
|
onSelectSource?: (source: SourceNode) => void;
|
||||||
|
draggedSource: SourceNode | null;
|
||||||
|
dropTargetSource: string | null;
|
||||||
|
draggable: boolean;
|
||||||
|
onDragStart: (e: DragEvent, source: SourceNode) => void;
|
||||||
|
onDragEnd: () => void;
|
||||||
|
onDragOver: (e: DragEvent, source: SourceNode) => void;
|
||||||
|
onDragLeave: () => void;
|
||||||
|
onDrop: (e: DragEvent, source: SourceNode) => void;
|
||||||
|
onContextMenu: (e: ReactMouseEvent, type: 'source' | 'background', data?: string) => void;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourceColumn({
|
||||||
|
providerNodes,
|
||||||
|
collapsedProviders,
|
||||||
|
sourceRefs,
|
||||||
|
getProviderColor,
|
||||||
|
selectedSourceId,
|
||||||
|
onSelectSource,
|
||||||
|
draggedSource,
|
||||||
|
dropTargetSource,
|
||||||
|
draggable,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
onDrop,
|
||||||
|
onContextMenu,
|
||||||
|
label
|
||||||
|
}: SourceColumnProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.column} ${styles.sources}`}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onContextMenu(e, 'background');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.columnHeader}>{label}</div>
|
||||||
|
{providerNodes.flatMap(({ provider, sources }) => {
|
||||||
|
if (collapsedProviders.has(provider)) return [];
|
||||||
|
return sources.map((source) => (
|
||||||
|
<div
|
||||||
|
key={source.id}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) sourceRefs.current?.set(source.id, el);
|
||||||
|
else sourceRefs.current?.delete(source.id);
|
||||||
|
}}
|
||||||
|
className={`${styles.item} ${styles.sourceItem} ${
|
||||||
|
draggedSource?.id === source.id ? styles.dragging : ''
|
||||||
|
} ${dropTargetSource === source.id ? styles.dropTarget : ''} ${
|
||||||
|
selectedSourceId === source.id ? styles.selected : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelectSource?.(source)}
|
||||||
|
draggable={draggable}
|
||||||
|
onDragStart={(e) => onDragStart(e, source)}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onDragOver={(e) => onDragOver(e, source)}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={(e) => onDrop(e, source)}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onContextMenu(e, 'source', source.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={styles.itemName} title={source.name}>
|
||||||
|
{source.name}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className={styles.dot}
|
||||||
|
style={{
|
||||||
|
background: getProviderColor(source.provider),
|
||||||
|
opacity: source.aliases.length > 0 ? 1 : 0.3
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AliasColumnProps {
|
||||||
|
aliasNodes: AliasNode[];
|
||||||
|
aliasRefs: RefObject<Map<string, HTMLDivElement>>;
|
||||||
|
dropTargetAlias: string | null;
|
||||||
|
draggedAlias: string | null;
|
||||||
|
selectedAlias?: string | null;
|
||||||
|
onSelectAlias?: (alias: string) => void;
|
||||||
|
draggable: boolean;
|
||||||
|
onDragStart: (e: DragEvent, alias: string) => void;
|
||||||
|
onDragEnd: () => void;
|
||||||
|
onDragOver: (e: DragEvent, alias: string) => void;
|
||||||
|
onDragLeave: () => void;
|
||||||
|
onDrop: (e: DragEvent, alias: string) => void;
|
||||||
|
onContextMenu: (e: ReactMouseEvent, type: 'alias' | 'background', data?: string) => void;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AliasColumn({
|
||||||
|
aliasNodes,
|
||||||
|
aliasRefs,
|
||||||
|
dropTargetAlias,
|
||||||
|
draggedAlias,
|
||||||
|
selectedAlias,
|
||||||
|
onSelectAlias,
|
||||||
|
draggable,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
onDrop,
|
||||||
|
onContextMenu,
|
||||||
|
label
|
||||||
|
}: AliasColumnProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.column} ${styles.aliases}`}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onContextMenu(e, 'background');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.columnHeader}>{label}</div>
|
||||||
|
{aliasNodes.map((node) => (
|
||||||
|
<div
|
||||||
|
key={node.id}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) aliasRefs.current?.set(node.id, el);
|
||||||
|
else aliasRefs.current?.delete(node.id);
|
||||||
|
}}
|
||||||
|
className={`${styles.item} ${styles.aliasItem} ${
|
||||||
|
dropTargetAlias === node.alias ? styles.dropTarget : ''
|
||||||
|
} ${draggedAlias === node.alias ? styles.dragging : ''} ${
|
||||||
|
selectedAlias === node.alias ? styles.selected : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelectAlias?.(node.alias)}
|
||||||
|
draggable={draggable}
|
||||||
|
onDragStart={(e) => onDragStart(e, node.alias)}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onDragOver={(e) => onDragOver(e, node.alias)}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={(e) => onDrop(e, node.alias)}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onContextMenu(e, 'alias', node.alias);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={`${styles.dot} ${styles.dotLeft}`} />
|
||||||
|
<span className={styles.itemName} title={node.alias}>
|
||||||
|
{node.alias}
|
||||||
|
</span>
|
||||||
|
<span className={styles.itemCount}>{node.sources.length}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/components/modelAlias/ModelMappingDiagramContextMenu.tsx
Normal file
111
src/components/modelAlias/ModelMappingDiagramContextMenu.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import type { ContextMenuState } from './ModelMappingDiagramTypes';
|
||||||
|
import styles from './ModelMappingDiagram.module.scss';
|
||||||
|
|
||||||
|
interface DiagramContextMenuProps {
|
||||||
|
contextMenu: ContextMenuState | null;
|
||||||
|
t: TFunction;
|
||||||
|
onRequestClose: () => void;
|
||||||
|
onAddAlias: () => void;
|
||||||
|
onRenameAlias: (alias: string) => void;
|
||||||
|
onOpenAliasSettings: (alias: string) => void;
|
||||||
|
onDeleteAlias: (alias: string) => void;
|
||||||
|
onEditProvider: (provider: string) => void;
|
||||||
|
onDeleteProvider: (provider: string) => void;
|
||||||
|
onOpenSourceSettings: (sourceId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiagramContextMenu({
|
||||||
|
contextMenu,
|
||||||
|
t,
|
||||||
|
onRequestClose,
|
||||||
|
onAddAlias,
|
||||||
|
onRenameAlias,
|
||||||
|
onOpenAliasSettings,
|
||||||
|
onDeleteAlias,
|
||||||
|
onEditProvider,
|
||||||
|
onDeleteProvider,
|
||||||
|
onOpenSourceSettings
|
||||||
|
}: DiagramContextMenuProps) {
|
||||||
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contextMenu) return;
|
||||||
|
const handleClick = (event: globalThis.MouseEvent) => {
|
||||||
|
if (!menuRef.current?.contains(event.target as Node)) {
|
||||||
|
onRequestClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
}, [contextMenu, onRequestClose]);
|
||||||
|
|
||||||
|
if (!contextMenu) return null;
|
||||||
|
|
||||||
|
const { type, data } = contextMenu;
|
||||||
|
|
||||||
|
const renderBackground = () => (
|
||||||
|
<div className={styles.menuItem} onClick={onAddAlias}>
|
||||||
|
<span>{t('oauth_model_alias.diagram_add_alias')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderAlias = () => {
|
||||||
|
if (!data) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.menuItem} onClick={() => onRenameAlias(data)}>
|
||||||
|
<span>{t('oauth_model_alias.diagram_rename')}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.menuItem} onClick={() => onOpenAliasSettings(data)}>
|
||||||
|
<span>{t('oauth_model_alias.diagram_settings')}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.menuDivider} />
|
||||||
|
<div className={`${styles.menuItem} ${styles.danger}`} onClick={() => onDeleteAlias(data)}>
|
||||||
|
<span>{t('oauth_model_alias.diagram_delete_alias')}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderProvider = () => {
|
||||||
|
if (!data) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.menuItem} onClick={() => onEditProvider(data)}>
|
||||||
|
<span>{t('common.edit')}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.menuDivider} />
|
||||||
|
<div className={`${styles.menuItem} ${styles.danger}`} onClick={() => onDeleteProvider(data)}>
|
||||||
|
<span>{t('oauth_model_alias.delete')}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSource = () => {
|
||||||
|
if (!data) return null;
|
||||||
|
return (
|
||||||
|
<div className={styles.menuItem} onClick={() => onOpenSourceSettings(data)}>
|
||||||
|
<span>{t('oauth_model_alias.diagram_settings')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className={styles.contextMenu}
|
||||||
|
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{type === 'background' && renderBackground()}
|
||||||
|
{type === 'alias' && renderAlias()}
|
||||||
|
{type === 'provider' && renderProvider()}
|
||||||
|
{type === 'source' && renderSource()}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
267
src/components/modelAlias/ModelMappingDiagramModals.tsx
Normal file
267
src/components/modelAlias/ModelMappingDiagramModals.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import type { KeyboardEvent } from 'react';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
|
import { IconTrash2 } from '@/components/ui/icons';
|
||||||
|
import type { AliasNode, SourceNode } from './ModelMappingDiagramTypes';
|
||||||
|
import styles from './ModelMappingDiagram.module.scss';
|
||||||
|
|
||||||
|
interface RenameAliasModalProps {
|
||||||
|
open: boolean;
|
||||||
|
t: TFunction;
|
||||||
|
value: string;
|
||||||
|
error: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RenameAliasModal({
|
||||||
|
open,
|
||||||
|
t,
|
||||||
|
value,
|
||||||
|
error,
|
||||||
|
onChange,
|
||||||
|
onClose,
|
||||||
|
onSubmit
|
||||||
|
}: RenameAliasModalProps) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t('oauth_model_alias.diagram_rename_alias_title')}
|
||||||
|
width={400}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onSubmit}>{t('oauth_model_alias.diagram_rename_btn')}</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
label={t('oauth_model_alias.diagram_rename_alias_label')}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') onSubmit();
|
||||||
|
}}
|
||||||
|
error={error}
|
||||||
|
placeholder={t('oauth_model_alias.diagram_rename_placeholder')}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddAliasModalProps {
|
||||||
|
open: boolean;
|
||||||
|
t: TFunction;
|
||||||
|
value: string;
|
||||||
|
error: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddAliasModal({
|
||||||
|
open,
|
||||||
|
t,
|
||||||
|
value,
|
||||||
|
error,
|
||||||
|
onChange,
|
||||||
|
onClose,
|
||||||
|
onSubmit
|
||||||
|
}: AddAliasModalProps) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t('oauth_model_alias.diagram_add_alias_title')}
|
||||||
|
width={400}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onSubmit}>{t('oauth_model_alias.diagram_add_btn')}</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
label={t('oauth_model_alias.diagram_add_alias_label')}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') onSubmit();
|
||||||
|
}}
|
||||||
|
error={error}
|
||||||
|
placeholder={t('oauth_model_alias.diagram_add_placeholder')}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsAliasModalProps {
|
||||||
|
open: boolean;
|
||||||
|
t: TFunction;
|
||||||
|
alias: string | null;
|
||||||
|
aliasNodes: AliasNode[];
|
||||||
|
onClose: () => void;
|
||||||
|
onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
|
||||||
|
onUnlink: (provider: string, sourceModel: string, alias: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsAliasModal({
|
||||||
|
open,
|
||||||
|
t,
|
||||||
|
alias,
|
||||||
|
aliasNodes,
|
||||||
|
onClose,
|
||||||
|
onToggleFork,
|
||||||
|
onUnlink
|
||||||
|
}: SettingsAliasModalProps) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t('oauth_model_alias.diagram_settings_title', { alias: alias ?? '' })}
|
||||||
|
width={720}
|
||||||
|
footer={
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
{t('common.close')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{alias ? (
|
||||||
|
(() => {
|
||||||
|
const node = aliasNodes.find((n) => n.alias === alias);
|
||||||
|
if (!node || node.sources.length === 0) {
|
||||||
|
return <div className={styles.settingsEmpty}>{t('oauth_model_alias.diagram_settings_empty')}</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={styles.settingsList}>
|
||||||
|
{node.sources.map((source) => {
|
||||||
|
const entry = source.aliases.find((item) => item.alias === alias);
|
||||||
|
const forkEnabled = entry?.fork === true;
|
||||||
|
return (
|
||||||
|
<div key={source.id} className={styles.settingsRow}>
|
||||||
|
<div className={styles.settingsNames}>
|
||||||
|
<span className={styles.settingsSource}>{source.name}</span>
|
||||||
|
<span className={styles.settingsArrow}>→</span>
|
||||||
|
<span className={styles.settingsAlias}>{alias}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.settingsActions}>
|
||||||
|
<span className={styles.settingsLabel}>
|
||||||
|
{t('oauth_model_alias.alias_fork_label')}
|
||||||
|
</span>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={forkEnabled}
|
||||||
|
onChange={(value) => onToggleFork(source.provider, source.name, alias, value)}
|
||||||
|
ariaLabel={t('oauth_model_alias.alias_fork_label')}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.settingsDelete}
|
||||||
|
onClick={() => onUnlink(source.provider, source.name, alias)}
|
||||||
|
aria-label={t('oauth_model_alias.diagram_delete_link', {
|
||||||
|
provider: source.provider,
|
||||||
|
name: source.name
|
||||||
|
})}
|
||||||
|
title={t('oauth_model_alias.diagram_delete_link', {
|
||||||
|
provider: source.provider,
|
||||||
|
name: source.name
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<IconTrash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsSourceModalProps {
|
||||||
|
open: boolean;
|
||||||
|
t: TFunction;
|
||||||
|
source: SourceNode | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
|
||||||
|
onUnlink: (provider: string, sourceModel: string, alias: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsSourceModal({
|
||||||
|
open,
|
||||||
|
t,
|
||||||
|
source,
|
||||||
|
onClose,
|
||||||
|
onToggleFork,
|
||||||
|
onUnlink
|
||||||
|
}: SettingsSourceModalProps) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t('oauth_model_alias.diagram_settings_source_title')}
|
||||||
|
width={720}
|
||||||
|
footer={
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
{t('common.close')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{source ? (
|
||||||
|
source.aliases.length === 0 ? (
|
||||||
|
<div className={styles.settingsEmpty}>{t('oauth_model_alias.diagram_settings_empty')}</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.settingsList}>
|
||||||
|
{source.aliases.map((entry) => (
|
||||||
|
<div key={`${source.id}-${entry.alias}`} className={styles.settingsRow}>
|
||||||
|
<div className={styles.settingsNames}>
|
||||||
|
<span className={styles.settingsSource}>{source.name}</span>
|
||||||
|
<span className={styles.settingsArrow}>→</span>
|
||||||
|
<span className={styles.settingsAlias}>{entry.alias}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.settingsActions}>
|
||||||
|
<span className={styles.settingsLabel}>
|
||||||
|
{t('oauth_model_alias.alias_fork_label')}
|
||||||
|
</span>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={entry.fork === true}
|
||||||
|
onChange={(value) => onToggleFork(source.provider, source.name, entry.alias, value)}
|
||||||
|
ariaLabel={t('oauth_model_alias.alias_fork_label')}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.settingsDelete}
|
||||||
|
onClick={() => onUnlink(source.provider, source.name, entry.alias)}
|
||||||
|
aria-label={t('oauth_model_alias.diagram_delete_link', {
|
||||||
|
provider: source.provider,
|
||||||
|
name: source.name
|
||||||
|
})}
|
||||||
|
title={t('oauth_model_alias.diagram_delete_link', {
|
||||||
|
provider: source.provider,
|
||||||
|
name: source.name
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<IconTrash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/modelAlias/ModelMappingDiagramTypes.ts
Normal file
33
src/components/modelAlias/ModelMappingDiagramTypes.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export interface AuthFileModelItem {
|
||||||
|
id: string;
|
||||||
|
display_name?: string;
|
||||||
|
type?: string;
|
||||||
|
owned_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceNode {
|
||||||
|
id: string; // unique: provider::name
|
||||||
|
provider: string;
|
||||||
|
name: string;
|
||||||
|
aliases: { alias: string; fork: boolean }[]; // all aliases this source maps to
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AliasNode {
|
||||||
|
id: string; // alias
|
||||||
|
alias: string;
|
||||||
|
sources: SourceNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderNode {
|
||||||
|
provider: string;
|
||||||
|
sources: SourceNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
type: 'alias' | 'background' | 'provider' | 'source';
|
||||||
|
data?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DiagramLine = { path: string; color: string; id: string };
|
||||||
2
src/components/modelAlias/index.ts
Normal file
2
src/components/modelAlias/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { ModelMappingDiagram } from './ModelMappingDiagram';
|
||||||
|
export type { ModelMappingDiagramProps, ModelMappingDiagramRef } from './ModelMappingDiagram';
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import { CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { usePageTransitionLayer } from '@/components/common/PageTransition';
|
||||||
import { useThemeStore } from '@/stores';
|
import { useThemeStore } from '@/stores';
|
||||||
import iconGemini from '@/assets/icons/gemini.svg';
|
import iconGemini from '@/assets/icons/gemini.svg';
|
||||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||||
@@ -34,6 +35,8 @@ type ScrollContainer = HTMLElement | (Window & typeof globalThis);
|
|||||||
|
|
||||||
export function ProviderNav() {
|
export function ProviderNav() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const pageTransitionLayer = usePageTransitionLayer();
|
||||||
|
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
|
||||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
const [activeProvider, setActiveProvider] = useState<ProviderId | null>(null);
|
const [activeProvider, setActiveProvider] = useState<ProviderId | null>(null);
|
||||||
const contentScrollerRef = useRef<HTMLElement | null>(null);
|
const contentScrollerRef = useRef<HTMLElement | null>(null);
|
||||||
@@ -62,7 +65,7 @@ export function ProviderNav() {
|
|||||||
location.pathname.length > 1 && location.pathname.endsWith('/')
|
location.pathname.length > 1 && location.pathname.endsWith('/')
|
||||||
? location.pathname.slice(0, -1)
|
? location.pathname.slice(0, -1)
|
||||||
: location.pathname;
|
: location.pathname;
|
||||||
const shouldShow = normalizedPathname === '/ai-providers';
|
const shouldShow = isCurrentLayer && normalizedPathname === '/ai-providers';
|
||||||
|
|
||||||
const getHeaderHeight = useCallback(() => {
|
const getHeaderHeight = useCallback(() => {
|
||||||
const header = document.querySelector('.main-header') as HTMLElement | null;
|
const header = document.querySelector('.main-header') as HTMLElement | null;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
AntigravityModelsPayload,
|
AntigravityModelsPayload,
|
||||||
AntigravityQuotaState,
|
AntigravityQuotaState,
|
||||||
AuthFileItem,
|
AuthFileItem,
|
||||||
|
CodexRateLimitInfo,
|
||||||
CodexQuotaState,
|
CodexQuotaState,
|
||||||
CodexUsageWindow,
|
CodexUsageWindow,
|
||||||
CodexQuotaWindow,
|
CodexQuotaWindow,
|
||||||
@@ -17,7 +18,7 @@ import type {
|
|||||||
GeminiCliParsedBucket,
|
GeminiCliParsedBucket,
|
||||||
GeminiCliQuotaBucketState,
|
GeminiCliQuotaBucketState,
|
||||||
GeminiCliQuotaState,
|
GeminiCliQuotaState,
|
||||||
KiroQuotaState
|
KiroQuotaState,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
|
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
|
||||||
import {
|
import {
|
||||||
@@ -52,7 +53,7 @@ import {
|
|||||||
isDisabledAuthFile,
|
isDisabledAuthFile,
|
||||||
isGeminiCliFile,
|
isGeminiCliFile,
|
||||||
isKiroFile,
|
isKiroFile,
|
||||||
isRuntimeOnlyAuthFile
|
isRuntimeOnlyAuthFile,
|
||||||
} from '@/utils/quota';
|
} from '@/utils/quota';
|
||||||
import type { QuotaRenderHelpers } from './QuotaCard';
|
import type { QuotaRenderHelpers } from './QuotaCard';
|
||||||
import styles from '@/pages/QuotaPage.module.scss';
|
import styles from '@/pages/QuotaPage.module.scss';
|
||||||
@@ -149,7 +150,7 @@ const fetchAntigravityQuota = async (
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
url,
|
url,
|
||||||
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
|
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
|
||||||
data: requestBody
|
data: requestBody,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
@@ -196,6 +197,15 @@ const fetchAntigravityQuota = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): CodexQuotaWindow[] => {
|
const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): CodexQuotaWindow[] => {
|
||||||
|
const FIVE_HOUR_SECONDS = 18000;
|
||||||
|
const WEEK_SECONDS = 604800;
|
||||||
|
const WINDOW_META = {
|
||||||
|
codeFiveHour: { id: 'five-hour', labelKey: 'codex_quota.primary_window' },
|
||||||
|
codeWeekly: { id: 'weekly', labelKey: 'codex_quota.secondary_window' },
|
||||||
|
codeReviewFiveHour: { id: 'code-review-five-hour', labelKey: 'codex_quota.code_review_primary_window' },
|
||||||
|
codeReviewWeekly: { id: 'code-review-weekly', labelKey: 'codex_quota.code_review_secondary_window' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
|
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
|
||||||
const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
|
const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
|
||||||
const windows: CodexQuotaWindow[] = [];
|
const windows: CodexQuotaWindow[] = [];
|
||||||
@@ -217,30 +227,74 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
|
|||||||
label: t(labelKey),
|
label: t(labelKey),
|
||||||
labelKey,
|
labelKey,
|
||||||
usedPercent,
|
usedPercent,
|
||||||
resetLabel
|
resetLabel,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getWindowSeconds = (window?: CodexUsageWindow | null): number | null => {
|
||||||
|
if (!window) return null;
|
||||||
|
return normalizeNumberValue(window.limit_window_seconds ?? window.limitWindowSeconds);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rawLimitReached = rateLimit?.limit_reached ?? rateLimit?.limitReached;
|
||||||
|
const rawAllowed = rateLimit?.allowed;
|
||||||
|
|
||||||
|
const pickClassifiedWindows = (
|
||||||
|
limitInfo?: CodexRateLimitInfo | null
|
||||||
|
): { fiveHourWindow: CodexUsageWindow | null; weeklyWindow: CodexUsageWindow | null } => {
|
||||||
|
const rawWindows = [
|
||||||
|
limitInfo?.primary_window ?? limitInfo?.primaryWindow ?? null,
|
||||||
|
limitInfo?.secondary_window ?? limitInfo?.secondaryWindow ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
let fiveHourWindow: CodexUsageWindow | null = null;
|
||||||
|
let weeklyWindow: CodexUsageWindow | null = null;
|
||||||
|
|
||||||
|
for (const window of rawWindows) {
|
||||||
|
if (!window) continue;
|
||||||
|
const seconds = getWindowSeconds(window);
|
||||||
|
if (seconds === FIVE_HOUR_SECONDS && !fiveHourWindow) {
|
||||||
|
fiveHourWindow = window;
|
||||||
|
} else if (seconds === WEEK_SECONDS && !weeklyWindow) {
|
||||||
|
weeklyWindow = window;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fiveHourWindow, weeklyWindow };
|
||||||
|
};
|
||||||
|
|
||||||
|
const rateWindows = pickClassifiedWindows(rateLimit);
|
||||||
addWindow(
|
addWindow(
|
||||||
'primary',
|
WINDOW_META.codeFiveHour.id,
|
||||||
'codex_quota.primary_window',
|
WINDOW_META.codeFiveHour.labelKey,
|
||||||
rateLimit?.primary_window ?? rateLimit?.primaryWindow,
|
rateWindows.fiveHourWindow,
|
||||||
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
rawLimitReached,
|
||||||
rateLimit?.allowed
|
rawAllowed
|
||||||
);
|
);
|
||||||
addWindow(
|
addWindow(
|
||||||
'secondary',
|
WINDOW_META.codeWeekly.id,
|
||||||
'codex_quota.secondary_window',
|
WINDOW_META.codeWeekly.labelKey,
|
||||||
rateLimit?.secondary_window ?? rateLimit?.secondaryWindow,
|
rateWindows.weeklyWindow,
|
||||||
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
rawLimitReached,
|
||||||
rateLimit?.allowed
|
rawAllowed
|
||||||
|
);
|
||||||
|
|
||||||
|
const codeReviewWindows = pickClassifiedWindows(codeReviewLimit);
|
||||||
|
const codeReviewLimitReached = codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached;
|
||||||
|
const codeReviewAllowed = codeReviewLimit?.allowed;
|
||||||
|
addWindow(
|
||||||
|
WINDOW_META.codeReviewFiveHour.id,
|
||||||
|
WINDOW_META.codeReviewFiveHour.labelKey,
|
||||||
|
codeReviewWindows.fiveHourWindow,
|
||||||
|
codeReviewLimitReached,
|
||||||
|
codeReviewAllowed
|
||||||
);
|
);
|
||||||
addWindow(
|
addWindow(
|
||||||
'code-review',
|
WINDOW_META.codeReviewWeekly.id,
|
||||||
'codex_quota.code_review_window',
|
WINDOW_META.codeReviewWeekly.labelKey,
|
||||||
codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow,
|
codeReviewWindows.weeklyWindow,
|
||||||
codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached,
|
codeReviewLimitReached,
|
||||||
codeReviewLimit?.allowed
|
codeReviewAllowed
|
||||||
);
|
);
|
||||||
|
|
||||||
return windows;
|
return windows;
|
||||||
@@ -264,14 +318,14 @@ const fetchCodexQuota = async (
|
|||||||
|
|
||||||
const requestHeader: Record<string, string> = {
|
const requestHeader: Record<string, string> = {
|
||||||
...CODEX_REQUEST_HEADERS,
|
...CODEX_REQUEST_HEADERS,
|
||||||
'Chatgpt-Account-Id': accountId
|
'Chatgpt-Account-Id': accountId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await apiCallApi.request({
|
const result = await apiCallApi.request({
|
||||||
authIndex,
|
authIndex,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: CODEX_USAGE_URL,
|
url: CODEX_USAGE_URL,
|
||||||
header: requestHeader
|
header: requestHeader,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
@@ -308,7 +362,7 @@ const fetchGeminiCliQuota = async (
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: GEMINI_CLI_QUOTA_URL,
|
url: GEMINI_CLI_QUOTA_URL,
|
||||||
header: { ...GEMINI_CLI_REQUEST_HEADERS },
|
header: { ...GEMINI_CLI_REQUEST_HEADERS },
|
||||||
data: JSON.stringify({ project: projectId })
|
data: JSON.stringify({ project: projectId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
@@ -327,7 +381,9 @@ const fetchGeminiCliQuota = async (
|
|||||||
const remainingFractionRaw = normalizeQuotaFraction(
|
const remainingFractionRaw = normalizeQuotaFraction(
|
||||||
bucket.remainingFraction ?? bucket.remaining_fraction
|
bucket.remainingFraction ?? bucket.remaining_fraction
|
||||||
);
|
);
|
||||||
const remainingAmount = normalizeNumberValue(bucket.remainingAmount ?? bucket.remaining_amount);
|
const remainingAmount = normalizeNumberValue(
|
||||||
|
bucket.remainingAmount ?? bucket.remaining_amount
|
||||||
|
);
|
||||||
const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined;
|
const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined;
|
||||||
let fallbackFraction: number | null = null;
|
let fallbackFraction: number | null = null;
|
||||||
if (remainingAmount !== null) {
|
if (remainingAmount !== null) {
|
||||||
@@ -341,7 +397,7 @@ const fetchGeminiCliQuota = async (
|
|||||||
tokenType,
|
tokenType,
|
||||||
remainingFraction,
|
remainingFraction,
|
||||||
remainingAmount,
|
remainingAmount,
|
||||||
resetTime
|
resetTime,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
|
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
|
||||||
@@ -373,11 +429,7 @@ const renderAntigravityItems = (
|
|||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
{ className: styleMap.quotaRowHeader },
|
{ className: styleMap.quotaRowHeader },
|
||||||
h(
|
h('span', { className: styleMap.quotaModel, title: group.models.join(', ') }, group.label),
|
||||||
'span',
|
|
||||||
{ className: styleMap.quotaModel, title: group.models.join(', ') },
|
|
||||||
group.label
|
|
||||||
),
|
|
||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
{ className: styleMap.quotaMeta },
|
{ className: styleMap.quotaMeta },
|
||||||
@@ -410,7 +462,6 @@ const renderCodexItems = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const planLabel = getPlanLabel(planType);
|
const planLabel = getPlanLabel(planType);
|
||||||
const isFreePlan = normalizePlanType(planType) === 'free';
|
|
||||||
const nodes: ReactNode[] = [];
|
const nodes: ReactNode[] = [];
|
||||||
|
|
||||||
if (planLabel) {
|
if (planLabel) {
|
||||||
@@ -424,17 +475,6 @@ const renderCodexItems = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFreePlan) {
|
|
||||||
nodes.push(
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ key: 'warning', className: styleMap.quotaWarning },
|
|
||||||
t('codex_quota.no_access')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return h(Fragment, null, ...nodes);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (windows.length === 0) {
|
if (windows.length === 0) {
|
||||||
nodes.push(
|
nodes.push(
|
||||||
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('codex_quota.empty_windows'))
|
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('codex_quota.empty_windows'))
|
||||||
@@ -494,7 +534,7 @@ const renderGeminiCliItems = (
|
|||||||
bucket.remainingAmount === null || bucket.remainingAmount === undefined
|
bucket.remainingAmount === null || bucket.remainingAmount === undefined
|
||||||
? null
|
? null
|
||||||
: t('gemini_cli_quota.remaining_amount', {
|
: t('gemini_cli_quota.remaining_amount', {
|
||||||
count: bucket.remainingAmount
|
count: bucket.remainingAmount,
|
||||||
});
|
});
|
||||||
const titleBase =
|
const titleBase =
|
||||||
bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label;
|
bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label;
|
||||||
@@ -537,13 +577,13 @@ export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQ
|
|||||||
status: 'error',
|
status: 'error',
|
||||||
groups: [],
|
groups: [],
|
||||||
error: message,
|
error: message,
|
||||||
errorStatus: status
|
errorStatus: status,
|
||||||
}),
|
}),
|
||||||
cardClassName: styles.antigravityCard,
|
cardClassName: styles.antigravityCard,
|
||||||
controlsClassName: styles.antigravityControls,
|
controlsClassName: styles.antigravityControls,
|
||||||
controlClassName: styles.antigravityControl,
|
controlClassName: styles.antigravityControl,
|
||||||
gridClassName: styles.antigravityGrid,
|
gridClassName: styles.antigravityGrid,
|
||||||
renderQuotaItems: renderAntigravityItems
|
renderQuotaItems: renderAntigravityItems,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CODEX_CONFIG: QuotaConfig<
|
export const CODEX_CONFIG: QuotaConfig<
|
||||||
@@ -560,19 +600,19 @@ export const CODEX_CONFIG: QuotaConfig<
|
|||||||
buildSuccessState: (data) => ({
|
buildSuccessState: (data) => ({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
windows: data.windows,
|
windows: data.windows,
|
||||||
planType: data.planType
|
planType: data.planType,
|
||||||
}),
|
}),
|
||||||
buildErrorState: (message, status) => ({
|
buildErrorState: (message, status) => ({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
windows: [],
|
windows: [],
|
||||||
error: message,
|
error: message,
|
||||||
errorStatus: status
|
errorStatus: status,
|
||||||
}),
|
}),
|
||||||
cardClassName: styles.codexCard,
|
cardClassName: styles.codexCard,
|
||||||
controlsClassName: styles.codexControls,
|
controlsClassName: styles.codexControls,
|
||||||
controlClassName: styles.codexControl,
|
controlClassName: styles.codexControl,
|
||||||
gridClassName: styles.codexGrid,
|
gridClassName: styles.codexGrid,
|
||||||
renderQuotaItems: renderCodexItems
|
renderQuotaItems: renderCodexItems,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
|
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
|
||||||
@@ -589,13 +629,13 @@ export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaB
|
|||||||
status: 'error',
|
status: 'error',
|
||||||
buckets: [],
|
buckets: [],
|
||||||
error: message,
|
error: message,
|
||||||
errorStatus: status
|
errorStatus: status,
|
||||||
}),
|
}),
|
||||||
cardClassName: styles.geminiCliCard,
|
cardClassName: styles.geminiCliCard,
|
||||||
controlsClassName: styles.geminiCliControls,
|
controlsClassName: styles.geminiCliControls,
|
||||||
controlClassName: styles.geminiCliControl,
|
controlClassName: styles.geminiCliControl,
|
||||||
gridClassName: styles.geminiCliGrid,
|
gridClassName: styles.geminiCliGrid,
|
||||||
renderQuotaItems: renderGeminiCliItems
|
renderQuotaItems: renderGeminiCliItems,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Kiro quota data structure from API
|
// Kiro quota data structure from API
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface ModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
width?: number | string;
|
width?: number | string;
|
||||||
|
className?: string;
|
||||||
closeDisabled?: boolean;
|
closeDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ export function Modal({
|
|||||||
onClose,
|
onClose,
|
||||||
footer,
|
footer,
|
||||||
width = 520,
|
width = 520,
|
||||||
|
className,
|
||||||
closeDisabled = false,
|
closeDisabled = false,
|
||||||
children
|
children
|
||||||
}: PropsWithChildren<ModalProps>) {
|
}: PropsWithChildren<ModalProps>) {
|
||||||
@@ -110,7 +112,7 @@ export function Modal({
|
|||||||
if (!open && !isVisible) return null;
|
if (!open && !isVisible) return null;
|
||||||
|
|
||||||
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
|
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
|
||||||
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}`;
|
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}${className ? ` ${className}` : ''}`;
|
||||||
|
|
||||||
const modalContent = (
|
const modalContent = (
|
||||||
<div className={overlayClass}>
|
<div className={overlayClass}>
|
||||||
|
|||||||
58
src/components/ui/ToggleSwitch.module.scss
Normal file
58
src/components/ui/ToggleSwitch.module.scss
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
.root {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.labelLeft {
|
||||||
|
.label {
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root input {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: $radius-full;
|
||||||
|
position: relative;
|
||||||
|
transition: background $transition-fast;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
transition: transform $transition-fast;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root input:checked + .track {
|
||||||
|
background: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root input:checked + .track .thumb {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ChangeEvent, ReactNode } from 'react';
|
import type { ChangeEvent, ReactNode } from 'react';
|
||||||
|
import styles from './ToggleSwitch.module.scss';
|
||||||
|
|
||||||
interface ToggleSwitchProps {
|
interface ToggleSwitchProps {
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
@@ -21,7 +22,11 @@ export function ToggleSwitch({
|
|||||||
onChange(event.target.checked);
|
onChange(event.target.checked);
|
||||||
};
|
};
|
||||||
|
|
||||||
const className = ['switch', labelPosition === 'left' ? 'switch-label-left' : '']
|
const className = [
|
||||||
|
styles.root,
|
||||||
|
labelPosition === 'left' ? styles.labelLeft : '',
|
||||||
|
disabled ? styles.disabled : '',
|
||||||
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
|
|
||||||
@@ -34,10 +39,10 @@ export function ToggleSwitch({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
/>
|
/>
|
||||||
<span className="track">
|
<span className={styles.track}>
|
||||||
<span className="thumb" />
|
<span className={styles.thumb} />
|
||||||
</span>
|
</span>
|
||||||
{label && <span className="label">{label}</span>}
|
{label && <span className={styles.label}>{label}</span>}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -422,7 +422,12 @@
|
|||||||
"prefix_placeholder": "",
|
"prefix_placeholder": "",
|
||||||
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||||
"prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.",
|
"prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.",
|
||||||
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully"
|
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully",
|
||||||
|
"card_tools_title": "Tools",
|
||||||
|
"quota_refresh_single": "Refresh quota",
|
||||||
|
"quota_refresh_hint": "Refresh quota for this credential only",
|
||||||
|
"quota_refresh_success": "Quota refreshed for \"{{name}}\"",
|
||||||
|
"quota_refresh_failed": "Failed to refresh quota for \"{{name}}\": {{message}}"
|
||||||
},
|
},
|
||||||
"antigravity_quota": {
|
"antigravity_quota": {
|
||||||
"title": "Antigravity Quota",
|
"title": "Antigravity Quota",
|
||||||
@@ -451,7 +456,8 @@
|
|||||||
"fetch_all": "Fetch All",
|
"fetch_all": "Fetch All",
|
||||||
"primary_window": "5-hour limit",
|
"primary_window": "5-hour limit",
|
||||||
"secondary_window": "Weekly limit",
|
"secondary_window": "Weekly limit",
|
||||||
"code_review_window": "Code review limit",
|
"code_review_primary_window": "Code review 5-hour limit",
|
||||||
|
"code_review_secondary_window": "Code review weekly limit",
|
||||||
"plan_label": "Plan",
|
"plan_label": "Plan",
|
||||||
"plan_plus": "Plus",
|
"plan_plus": "Plus",
|
||||||
"plan_team": "Team",
|
"plan_team": "Team",
|
||||||
@@ -566,11 +572,43 @@
|
|||||||
"save_failed": "Failed to update model aliases",
|
"save_failed": "Failed to update model aliases",
|
||||||
"delete": "Delete Provider",
|
"delete": "Delete Provider",
|
||||||
"delete_confirm": "Delete model aliases for {{provider}}?",
|
"delete_confirm": "Delete model aliases for {{provider}}?",
|
||||||
|
"delete_link_title": "Unlink mapping",
|
||||||
|
"delete_link_confirm": "Unlink mapping from <code>{{sourceModel}}</code> ({{provider}}) to alias <code>{{alias}}</code>?",
|
||||||
|
"delete_alias_title": "Delete Alias",
|
||||||
|
"delete_alias_confirm": "Delete alias <code>{{alias}}</code> and unmap all associated models?",
|
||||||
"delete_success": "Model aliases removed",
|
"delete_success": "Model aliases removed",
|
||||||
"delete_failed": "Failed to delete model aliases",
|
"delete_failed": "Failed to delete model aliases",
|
||||||
"no_models": "No model aliases",
|
"no_models": "No model aliases",
|
||||||
"model_count": "{{count}} aliases",
|
"model_count": "{{count}} aliases",
|
||||||
"list_empty_all": "No model aliases yet—use “Add Alias” to create one.",
|
"list_empty_all": "No model aliases yet—use “Add Alias” to create one.",
|
||||||
|
"chart_title": "All mappings overview",
|
||||||
|
"diagram_providers": "Providers",
|
||||||
|
"diagram_source_models": "Source Models",
|
||||||
|
"diagram_aliases": "Aliases",
|
||||||
|
"diagram_expand": "Expand",
|
||||||
|
"diagram_collapse": "Collapse",
|
||||||
|
"diagram_add_alias": "Add Alias",
|
||||||
|
"diagram_rename": "Rename",
|
||||||
|
"diagram_rename_alias_title": "Rename alias",
|
||||||
|
"diagram_rename_alias_label": "New alias name",
|
||||||
|
"diagram_rename_placeholder": "Enter alias name...",
|
||||||
|
"diagram_delete_link": "Unlink from {{provider}} / {{name}}",
|
||||||
|
"diagram_delete_alias": "Delete alias",
|
||||||
|
"diagram_please_enter_alias": "Please enter an alias name.",
|
||||||
|
"diagram_alias_exists": "This alias already exists.",
|
||||||
|
"diagram_add_alias_title": "Add alias",
|
||||||
|
"diagram_add_alias_label": "Alias name",
|
||||||
|
"diagram_add_placeholder": "Enter new alias name...",
|
||||||
|
"diagram_rename_btn": "Rename",
|
||||||
|
"diagram_add_btn": "Add",
|
||||||
|
"diagram_settings": "Settings",
|
||||||
|
"diagram_settings_title": "Alias settings — {{alias}}",
|
||||||
|
"diagram_settings_source_title": "Source model settings",
|
||||||
|
"diagram_settings_empty": "No mappings for this alias yet.",
|
||||||
|
"diagram_tap_hint": "On touch devices: tap a source model, then tap an alias to link.",
|
||||||
|
"view_mode": "View mode",
|
||||||
|
"view_mode_diagram": "Diagram",
|
||||||
|
"view_mode_list": "List",
|
||||||
"provider_required": "Please enter a provider first",
|
"provider_required": "Please enter a provider first",
|
||||||
"upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.",
|
"upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.",
|
||||||
"upgrade_required_title": "Please upgrade CLI Proxy API",
|
"upgrade_required_title": "Please upgrade CLI Proxy API",
|
||||||
|
|||||||
@@ -422,7 +422,12 @@
|
|||||||
"prefix_placeholder": "",
|
"prefix_placeholder": "",
|
||||||
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||||
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。",
|
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。",
|
||||||
"prefix_proxy_saved_success": "已更新 \"{{name}}\""
|
"prefix_proxy_saved_success": "已更新 \"{{name}}\"",
|
||||||
|
"card_tools_title": "配置管理",
|
||||||
|
"quota_refresh_single": "刷新额度",
|
||||||
|
"quota_refresh_hint": "仅刷新当前凭证的额度数据",
|
||||||
|
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
|
||||||
|
"quota_refresh_failed": "刷新 \"{{name}}\" 的额度失败:{{message}}"
|
||||||
},
|
},
|
||||||
"antigravity_quota": {
|
"antigravity_quota": {
|
||||||
"title": "Antigravity 额度",
|
"title": "Antigravity 额度",
|
||||||
@@ -451,7 +456,8 @@
|
|||||||
"fetch_all": "获取全部",
|
"fetch_all": "获取全部",
|
||||||
"primary_window": "5 小时限额",
|
"primary_window": "5 小时限额",
|
||||||
"secondary_window": "周限额",
|
"secondary_window": "周限额",
|
||||||
"code_review_window": "代码审查限额",
|
"code_review_primary_window": "代码审查 5 小时限额",
|
||||||
|
"code_review_secondary_window": "代码审查周限额",
|
||||||
"plan_label": "套餐",
|
"plan_label": "套餐",
|
||||||
"plan_plus": "Plus",
|
"plan_plus": "Plus",
|
||||||
"plan_team": "Team",
|
"plan_team": "Team",
|
||||||
@@ -566,11 +572,43 @@
|
|||||||
"save_failed": "更新模型别名失败",
|
"save_failed": "更新模型别名失败",
|
||||||
"delete": "删除提供商",
|
"delete": "删除提供商",
|
||||||
"delete_confirm": "确定要删除 {{provider}} 的模型别名吗?",
|
"delete_confirm": "确定要删除 {{provider}} 的模型别名吗?",
|
||||||
|
"delete_link_title": "取消链接",
|
||||||
|
"delete_link_confirm": "确定取消 <code>{{sourceModel}}</code>({{provider}})到别名 <code>{{alias}}</code> 的映射?",
|
||||||
|
"delete_alias_title": "删除别名",
|
||||||
|
"delete_alias_confirm": "确定删除别名 <code>{{alias}}</code> 并取消所有关联模型的映射?",
|
||||||
"delete_success": "已删除该提供商的模型别名",
|
"delete_success": "已删除该提供商的模型别名",
|
||||||
"delete_failed": "删除模型别名失败",
|
"delete_failed": "删除模型别名失败",
|
||||||
"no_models": "未配置模型别名",
|
"no_models": "未配置模型别名",
|
||||||
"model_count": "{{count}} 条别名",
|
"model_count": "{{count}} 条别名",
|
||||||
"list_empty_all": "暂无任何提供商的模型别名,点击“新增别名”创建。",
|
"list_empty_all": "暂无任何提供商的模型别名,点击“新增别名”创建。",
|
||||||
|
"chart_title": "全部映射概览",
|
||||||
|
"diagram_providers": "提供商",
|
||||||
|
"diagram_source_models": "源模型",
|
||||||
|
"diagram_aliases": "别名",
|
||||||
|
"diagram_expand": "展开",
|
||||||
|
"diagram_collapse": "收起",
|
||||||
|
"diagram_add_alias": "添加别名",
|
||||||
|
"diagram_rename": "重命名",
|
||||||
|
"diagram_rename_alias_title": "重命名别名",
|
||||||
|
"diagram_rename_alias_label": "新别名名称",
|
||||||
|
"diagram_rename_placeholder": "输入别名名称...",
|
||||||
|
"diagram_delete_link": "取消链接 {{provider}} / {{name}}",
|
||||||
|
"diagram_delete_alias": "删除别名",
|
||||||
|
"diagram_please_enter_alias": "请输入别名名称。",
|
||||||
|
"diagram_alias_exists": "该别名已存在。",
|
||||||
|
"diagram_add_alias_title": "添加别名",
|
||||||
|
"diagram_add_alias_label": "别名名称",
|
||||||
|
"diagram_add_placeholder": "输入新别名名称...",
|
||||||
|
"diagram_rename_btn": "重命名",
|
||||||
|
"diagram_add_btn": "添加",
|
||||||
|
"diagram_settings": "设置",
|
||||||
|
"diagram_settings_title": "别名设置 — {{alias}}",
|
||||||
|
"diagram_settings_source_title": "源模型设置",
|
||||||
|
"diagram_settings_empty": "该别名暂无映射。",
|
||||||
|
"diagram_tap_hint": "触摸设备上:先点选源模型,再点选别名即可建立映射。",
|
||||||
|
"view_mode": "视图模式",
|
||||||
|
"view_mode_diagram": "概览",
|
||||||
|
"view_mode_list": "管理",
|
||||||
"provider_required": "请先填写提供商名称",
|
"provider_required": "请先填写提供商名称",
|
||||||
"upgrade_required": "当前 CPA 版本不支持模型别名功能,请升级 CPA 版本",
|
"upgrade_required": "当前 CPA 版本不支持模型别名功能,请升级 CPA 版本",
|
||||||
"upgrade_required_title": "需要升级 CPA 版本",
|
"upgrade_required_title": "需要升级 CPA 版本",
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const buildEmptyMappingEntry = (): OAuthModelMappingFormEntry => ({
|
|||||||
id: generateId(),
|
id: generateId(),
|
||||||
name: '',
|
name: '',
|
||||||
alias: '',
|
alias: '',
|
||||||
fork: false,
|
fork: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const normalizeMappingEntries = (
|
const normalizeMappingEntries = (
|
||||||
|
|||||||
@@ -184,6 +184,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fileGridQuotaManaged {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(520px, 1fr));
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.antigravityGrid {
|
.antigravityGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: $spacing-md;
|
gap: $spacing-md;
|
||||||
@@ -469,6 +481,66 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fileCardLayout {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileCardLayoutQuota {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 156px;
|
||||||
|
gap: $spacing-md;
|
||||||
|
align-items: stretch;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileCardMain {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileCardSidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding-left: $spacing-md;
|
||||||
|
border-left: 1px dashed var(--border-color);
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px dashed var(--border-color);
|
||||||
|
padding-left: 0;
|
||||||
|
padding-top: $spacing-md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileCardSidebarHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileCardSidebarTitle {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileCardSidebarHint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.cardHeader {
|
.cardHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -843,6 +915,49 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OAuth 模型别名 - 映射概览
|
||||||
|
.aliasChartSection {
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
padding-bottom: $spacing-lg;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aliasChartHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aliasChartTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aliasChart {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardExtraButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewModeSwitch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
// OAuth 模型映射表单
|
// OAuth 模型映射表单
|
||||||
.mappingRow {
|
.mappingRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
import { useEffect, useMemo, useRef, useState, useCallback, type ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useInterval } from '@/hooks/useInterval';
|
import { useInterval } from '@/hooks/useInterval';
|
||||||
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||||
@@ -10,17 +10,23 @@ import { Input } from '@/components/ui/Input';
|
|||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { EmptyState } from '@/components/ui/EmptyState';
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
|
import { ModelMappingDiagram, type ModelMappingDiagramRef } from '@/components/modelAlias';
|
||||||
import {
|
import {
|
||||||
IconBot,
|
IconBot,
|
||||||
IconCode,
|
IconCode,
|
||||||
|
IconChevronUp,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconInfo,
|
IconInfo,
|
||||||
|
IconRefreshCw,
|
||||||
IconTrash2,
|
IconTrash2,
|
||||||
} from '@/components/ui/icons';
|
} from '@/components/ui/icons';
|
||||||
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
|
import type { TFunction } from 'i18next';
|
||||||
|
import { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from '@/components/quota';
|
||||||
|
import { useAuthStore, useNotificationStore, useQuotaStore, useThemeStore } from '@/stores';
|
||||||
import { authFilesApi, usageApi } from '@/services/api';
|
import { authFilesApi, usageApi } from '@/services/api';
|
||||||
import { apiClient } from '@/services/api/client';
|
import { apiClient } from '@/services/api/client';
|
||||||
import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
|
import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
|
||||||
|
import { getStatusFromError, resolveAuthProvider } from '@/utils/quota';
|
||||||
import {
|
import {
|
||||||
calculateStatusBarData,
|
calculateStatusBarData,
|
||||||
collectUsageDetails,
|
collectUsageDetails,
|
||||||
@@ -89,6 +95,49 @@ const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState';
|
|||||||
const clampCardPageSize = (value: number) =>
|
const clampCardPageSize = (value: number) =>
|
||||||
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
|
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
|
||||||
|
|
||||||
|
type QuotaProviderType = 'antigravity' | 'codex' | 'gemini-cli';
|
||||||
|
|
||||||
|
const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>(['antigravity', 'codex', 'gemini-cli']);
|
||||||
|
|
||||||
|
const resolveQuotaErrorMessage = (
|
||||||
|
t: TFunction,
|
||||||
|
status: number | undefined,
|
||||||
|
fallback: string
|
||||||
|
): string => {
|
||||||
|
if (status === 404) return t('common.quota_update_required');
|
||||||
|
if (status === 403) return t('common.quota_check_credential');
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QuotaProgressBarProps = {
|
||||||
|
percent: number | null;
|
||||||
|
highThreshold: number;
|
||||||
|
mediumThreshold: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function QuotaProgressBar({ percent, highThreshold, mediumThreshold }: QuotaProgressBarProps) {
|
||||||
|
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||||
|
const normalized = percent === null ? null : clamp(percent, 0, 100);
|
||||||
|
const fillClass =
|
||||||
|
normalized === null
|
||||||
|
? styles.quotaBarFillMedium
|
||||||
|
: normalized >= highThreshold
|
||||||
|
? styles.quotaBarFillHigh
|
||||||
|
: normalized >= mediumThreshold
|
||||||
|
? styles.quotaBarFillMedium
|
||||||
|
: styles.quotaBarFillLow;
|
||||||
|
const widthPercent = Math.round(normalized ?? 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.quotaBar}>
|
||||||
|
<div
|
||||||
|
className={`${styles.quotaBarFill} ${fillClass}`}
|
||||||
|
style={{ width: `${widthPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type AuthFilesUiState = {
|
type AuthFilesUiState = {
|
||||||
filter?: string;
|
filter?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
@@ -193,6 +242,12 @@ export function AuthFilesPage() {
|
|||||||
const { showNotification, showConfirmation } = useNotificationStore();
|
const { showNotification, showConfirmation } = useNotificationStore();
|
||||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
|
const antigravityQuota = useQuotaStore((state) => state.antigravityQuota);
|
||||||
|
const codexQuota = useQuotaStore((state) => state.codexQuota);
|
||||||
|
const geminiCliQuota = useQuotaStore((state) => state.geminiCliQuota);
|
||||||
|
const setAntigravityQuota = useQuotaStore((state) => state.setAntigravityQuota);
|
||||||
|
const setCodexQuota = useQuotaStore((state) => state.setCodexQuota);
|
||||||
|
const setGeminiCliQuota = useQuotaStore((state) => state.setGeminiCliQuota);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
||||||
@@ -230,6 +285,10 @@ export function AuthFilesPage() {
|
|||||||
// OAuth 模型映射相关
|
// OAuth 模型映射相关
|
||||||
const [modelAlias, setModelAlias] = useState<Record<string, OAuthModelAliasEntry[]>>({});
|
const [modelAlias, setModelAlias] = useState<Record<string, OAuthModelAliasEntry[]>>({});
|
||||||
const [modelAliasError, setModelAliasError] = useState<'unsupported' | null>(null);
|
const [modelAliasError, setModelAliasError] = useState<'unsupported' | null>(null);
|
||||||
|
const [allProviderModels, setAllProviderModels] = useState<Record<string, AuthFileModelItem[]>>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
|
||||||
|
|
||||||
const [prefixProxyEditor, setPrefixProxyEditor] = useState<PrefixProxyEditorState | null>(null);
|
const [prefixProxyEditor, setPrefixProxyEditor] = useState<PrefixProxyEditorState | null>(null);
|
||||||
|
|
||||||
@@ -237,10 +296,81 @@ export function AuthFilesPage() {
|
|||||||
const loadingKeyStatsRef = useRef(false);
|
const loadingKeyStatsRef = useRef(false);
|
||||||
const excludedUnsupportedRef = useRef(false);
|
const excludedUnsupportedRef = useRef(false);
|
||||||
const mappingsUnsupportedRef = useRef(false);
|
const mappingsUnsupportedRef = useRef(false);
|
||||||
|
const diagramRef = useRef<ModelMappingDiagramRef | null>(null);
|
||||||
|
|
||||||
const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
|
const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
|
||||||
|
|
||||||
const disableControls = connectionStatus !== 'connected';
|
const disableControls = connectionStatus !== 'connected';
|
||||||
|
const normalizedFilter = normalizeProviderKey(String(filter));
|
||||||
|
const quotaFilterType: QuotaProviderType | null = QUOTA_PROVIDER_TYPES.has(
|
||||||
|
normalizedFilter as QuotaProviderType
|
||||||
|
)
|
||||||
|
? (normalizedFilter as QuotaProviderType)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const providerList = useMemo(() => {
|
||||||
|
const providers = new Set<string>();
|
||||||
|
|
||||||
|
Object.keys(modelAlias).forEach((provider) => {
|
||||||
|
const key = provider.trim().toLowerCase();
|
||||||
|
if (key) providers.add(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
if (typeof file.type === 'string') {
|
||||||
|
const key = file.type.trim().toLowerCase();
|
||||||
|
if (key) providers.add(key);
|
||||||
|
}
|
||||||
|
if (typeof file.provider === 'string') {
|
||||||
|
const key = file.provider.trim().toLowerCase();
|
||||||
|
if (key) providers.add(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(providers);
|
||||||
|
}, [files, modelAlias]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewMode !== 'diagram') return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadAllModels = async () => {
|
||||||
|
if (providerList.length === 0) {
|
||||||
|
if (!cancelled) setAllProviderModels({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
providerList.map(async (provider) => {
|
||||||
|
try {
|
||||||
|
const models = await authFilesApi.getModelDefinitions(provider);
|
||||||
|
return { provider, models };
|
||||||
|
} catch {
|
||||||
|
return { provider, models: [] };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const nextModels: Record<string, AuthFileModelItem[]> = {};
|
||||||
|
results.forEach(({ provider, models }) => {
|
||||||
|
if (models.length > 0) {
|
||||||
|
nextModels[provider] = models;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setAllProviderModels(nextModels);
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadAllModels();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [providerList, viewMode]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const persisted = readAuthFilesUiState();
|
const persisted = readAuthFilesUiState();
|
||||||
@@ -603,7 +733,9 @@ export function AuthFilesPage() {
|
|||||||
setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file)));
|
setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file)));
|
||||||
} else {
|
} else {
|
||||||
// 删除筛选类型的文件
|
// 删除筛选类型的文件
|
||||||
const filesToDelete = files.filter((f) => f.type === filter && !isRuntimeOnlyAuthFile(f));
|
const filesToDelete = files.filter(
|
||||||
|
(f) => f.type === filter && !isRuntimeOnlyAuthFile(f)
|
||||||
|
);
|
||||||
|
|
||||||
if (filesToDelete.length === 0) {
|
if (filesToDelete.length === 0) {
|
||||||
showNotification(t('auth_files.delete_filtered_none', { type: typeLabel }), 'info');
|
showNotification(t('auth_files.delete_filtered_none', { type: typeLabel }), 'info');
|
||||||
@@ -991,6 +1123,247 @@ export function AuthFilesPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMappingUpdate = async (provider: string, sourceModel: string, newAlias: string) => {
|
||||||
|
if (!provider || !sourceModel || !newAlias) return;
|
||||||
|
const normalizedProvider = normalizeProviderKey(provider);
|
||||||
|
if (!normalizedProvider) return;
|
||||||
|
|
||||||
|
const providerKey = Object.keys(modelAlias).find(
|
||||||
|
(key) => normalizeProviderKey(key) === normalizedProvider
|
||||||
|
);
|
||||||
|
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
|
||||||
|
|
||||||
|
const nameTrim = sourceModel.trim();
|
||||||
|
const aliasTrim = newAlias.trim();
|
||||||
|
const nameKey = nameTrim.toLowerCase();
|
||||||
|
const aliasKey = aliasTrim.toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentMappings.some(
|
||||||
|
(m) =>
|
||||||
|
(m.name ?? '').trim().toLowerCase() === nameKey &&
|
||||||
|
(m.alias ?? '').trim().toLowerCase() === aliasKey
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMappings: OAuthModelAliasEntry[] = [
|
||||||
|
...currentMappings,
|
||||||
|
{ name: nameTrim, alias: aliasTrim, fork: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
|
||||||
|
await loadModelAlias();
|
||||||
|
showNotification(t('oauth_model_alias.save_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteLink = (provider: string, sourceModel: string, alias: string) => {
|
||||||
|
const nameTrim = sourceModel.trim();
|
||||||
|
const aliasTrim = alias.trim();
|
||||||
|
if (!provider || !nameTrim || !aliasTrim) return;
|
||||||
|
|
||||||
|
showConfirmation({
|
||||||
|
title: t('oauth_model_alias.delete_link_title', { defaultValue: 'Unlink mapping' }),
|
||||||
|
message: (
|
||||||
|
<Trans
|
||||||
|
i18nKey="oauth_model_alias.delete_link_confirm"
|
||||||
|
values={{ provider, sourceModel: nameTrim, alias: aliasTrim }}
|
||||||
|
components={{ code: <code /> }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
variant: 'danger',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
const normalizedProvider = normalizeProviderKey(provider);
|
||||||
|
const providerKey = Object.keys(modelAlias).find(
|
||||||
|
(key) => normalizeProviderKey(key) === normalizedProvider
|
||||||
|
);
|
||||||
|
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
|
||||||
|
const nameKey = nameTrim.toLowerCase();
|
||||||
|
const aliasKey = aliasTrim.toLowerCase();
|
||||||
|
const nextMappings = currentMappings.filter(
|
||||||
|
(m) =>
|
||||||
|
(m.name ?? '').trim().toLowerCase() !== nameKey ||
|
||||||
|
(m.alias ?? '').trim().toLowerCase() !== aliasKey
|
||||||
|
);
|
||||||
|
if (nextMappings.length === currentMappings.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (nextMappings.length === 0) {
|
||||||
|
await authFilesApi.deleteOauthModelAlias(normalizedProvider);
|
||||||
|
} else {
|
||||||
|
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
|
||||||
|
}
|
||||||
|
await loadModelAlias();
|
||||||
|
showNotification(t('oauth_model_alias.save_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleFork = async (
|
||||||
|
provider: string,
|
||||||
|
sourceModel: string,
|
||||||
|
alias: string,
|
||||||
|
fork: boolean
|
||||||
|
) => {
|
||||||
|
const normalizedProvider = normalizeProviderKey(provider);
|
||||||
|
if (!normalizedProvider) return;
|
||||||
|
|
||||||
|
const providerKey = Object.keys(modelAlias).find(
|
||||||
|
(key) => normalizeProviderKey(key) === normalizedProvider
|
||||||
|
);
|
||||||
|
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
|
||||||
|
const nameKey = sourceModel.trim().toLowerCase();
|
||||||
|
const aliasKey = alias.trim().toLowerCase();
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
const nextMappings = currentMappings.map((m) => {
|
||||||
|
const mName = (m.name ?? '').trim().toLowerCase();
|
||||||
|
const mAlias = (m.alias ?? '').trim().toLowerCase();
|
||||||
|
if (mName === nameKey && mAlias === aliasKey) {
|
||||||
|
changed = true;
|
||||||
|
return fork ? { ...m, fork: true } : { name: m.name, alias: m.alias };
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!changed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
|
||||||
|
await loadModelAlias();
|
||||||
|
showNotification(t('oauth_model_alias.save_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRenameAlias = async (oldAlias: string, newAlias: string) => {
|
||||||
|
const oldTrim = oldAlias.trim();
|
||||||
|
const newTrim = newAlias.trim();
|
||||||
|
if (!oldTrim || !newTrim || oldTrim === newTrim) return;
|
||||||
|
|
||||||
|
const oldKey = oldTrim.toLowerCase();
|
||||||
|
const providersToUpdate = Object.entries(modelAlias).filter(([_, mappings]) =>
|
||||||
|
mappings.some((m) => (m.alias ?? '').trim().toLowerCase() === oldKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (providersToUpdate.length === 0) return;
|
||||||
|
|
||||||
|
let hadFailure = false;
|
||||||
|
let failureMessage = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
providersToUpdate.map(([provider, mappings]) => {
|
||||||
|
const nextMappings = mappings.map((m) =>
|
||||||
|
(m.alias ?? '').trim().toLowerCase() === oldKey ? { ...m, alias: newTrim } : m
|
||||||
|
);
|
||||||
|
return authFilesApi.saveOauthModelAlias(provider, nextMappings);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const failures = results.filter(
|
||||||
|
(result): result is PromiseRejectedResult => result.status === 'rejected'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
hadFailure = true;
|
||||||
|
const reason = failures[0].reason;
|
||||||
|
failureMessage = reason instanceof Error ? reason.message : String(reason ?? '');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await loadModelAlias();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hadFailure) {
|
||||||
|
showNotification(
|
||||||
|
failureMessage
|
||||||
|
? `${t('oauth_model_alias.save_failed')}: ${failureMessage}`
|
||||||
|
: t('oauth_model_alias.save_failed'),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showNotification(t('oauth_model_alias.save_success'), 'success');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAlias = (aliasName: string) => {
|
||||||
|
const aliasTrim = aliasName.trim();
|
||||||
|
if (!aliasTrim) return;
|
||||||
|
const aliasKey = aliasTrim.toLowerCase();
|
||||||
|
const providersToUpdate = Object.entries(modelAlias).filter(([_, mappings]) =>
|
||||||
|
mappings.some((m) => (m.alias ?? '').trim().toLowerCase() === aliasKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (providersToUpdate.length === 0) return;
|
||||||
|
|
||||||
|
showConfirmation({
|
||||||
|
title: t('oauth_model_alias.delete_alias_title', { defaultValue: 'Delete Alias' }),
|
||||||
|
message: (
|
||||||
|
<Trans
|
||||||
|
i18nKey="oauth_model_alias.delete_alias_confirm"
|
||||||
|
values={{ alias: aliasTrim }}
|
||||||
|
components={{ code: <code /> }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
variant: 'danger',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
let hadFailure = false;
|
||||||
|
let failureMessage = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
providersToUpdate.map(([provider, mappings]) => {
|
||||||
|
const nextMappings = mappings.filter(
|
||||||
|
(m) => (m.alias ?? '').trim().toLowerCase() !== aliasKey
|
||||||
|
);
|
||||||
|
if (nextMappings.length === 0) {
|
||||||
|
return authFilesApi.deleteOauthModelAlias(provider);
|
||||||
|
}
|
||||||
|
return authFilesApi.saveOauthModelAlias(provider, nextMappings);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const failures = results.filter(
|
||||||
|
(result): result is PromiseRejectedResult => result.status === 'rejected'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
hadFailure = true;
|
||||||
|
const reason = failures[0].reason;
|
||||||
|
failureMessage = reason instanceof Error ? reason.message : String(reason ?? '');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await loadModelAlias();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hadFailure) {
|
||||||
|
showNotification(
|
||||||
|
failureMessage
|
||||||
|
? `${t('oauth_model_alias.delete_failed')}: ${failureMessage}`
|
||||||
|
: t('oauth_model_alias.delete_failed'),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showNotification(t('oauth_model_alias.delete_success'), 'success');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 渲染标签筛选器
|
// 渲染标签筛选器
|
||||||
const renderFilterTags = () => (
|
const renderFilterTags = () => (
|
||||||
<div className={styles.filterTags}>
|
<div className={styles.filterTags}>
|
||||||
@@ -1083,128 +1456,293 @@ export function AuthFilesPage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => {
|
||||||
|
const provider = resolveAuthProvider(file);
|
||||||
|
if (!QUOTA_PROVIDER_TYPES.has(provider as QuotaProviderType)) return null;
|
||||||
|
return provider as QuotaProviderType;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getQuotaConfig = (type: QuotaProviderType) => {
|
||||||
|
if (type === 'antigravity') return ANTIGRAVITY_CONFIG;
|
||||||
|
if (type === 'codex') return CODEX_CONFIG;
|
||||||
|
return GEMINI_CLI_CONFIG;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getQuotaState = (type: QuotaProviderType, fileName: string) => {
|
||||||
|
if (type === 'antigravity') return antigravityQuota[fileName];
|
||||||
|
if (type === 'codex') return codexQuota[fileName];
|
||||||
|
return geminiCliQuota[fileName];
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateQuotaState = useCallback(
|
||||||
|
(
|
||||||
|
type: QuotaProviderType,
|
||||||
|
updater: (prev: Record<string, unknown>) => Record<string, unknown>
|
||||||
|
) => {
|
||||||
|
if (type === 'antigravity') {
|
||||||
|
setAntigravityQuota(updater as never);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'codex') {
|
||||||
|
setCodexQuota(updater as never);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setGeminiCliQuota(updater as never);
|
||||||
|
},
|
||||||
|
[setAntigravityQuota, setCodexQuota, setGeminiCliQuota]
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshQuotaForFile = useCallback(
|
||||||
|
async (file: AuthFileItem, quotaType: QuotaProviderType) => {
|
||||||
|
if (disableControls) return;
|
||||||
|
if (isRuntimeOnlyAuthFile(file)) return;
|
||||||
|
if (file.disabled) return;
|
||||||
|
|
||||||
|
const currentState = getQuotaState(quotaType, file.name);
|
||||||
|
if (currentState?.status === 'loading') return;
|
||||||
|
|
||||||
|
const config = getQuotaConfig(quotaType) as unknown as {
|
||||||
|
i18nPrefix: string;
|
||||||
|
fetchQuota: (file: AuthFileItem, t: TFunction) => Promise<unknown>;
|
||||||
|
buildLoadingState: () => unknown;
|
||||||
|
buildSuccessState: (data: unknown) => unknown;
|
||||||
|
buildErrorState: (message: string, status?: number) => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
updateQuotaState(quotaType, (prev) => ({
|
||||||
|
...prev,
|
||||||
|
[file.name]: config.buildLoadingState()
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await config.fetchQuota(file, t);
|
||||||
|
updateQuotaState(quotaType, (prev) => ({
|
||||||
|
...prev,
|
||||||
|
[file.name]: config.buildSuccessState(data)
|
||||||
|
}));
|
||||||
|
showNotification(t('auth_files.quota_refresh_success', { name: file.name }), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : t('common.unknown_error');
|
||||||
|
const status = getStatusFromError(err);
|
||||||
|
updateQuotaState(quotaType, (prev) => ({
|
||||||
|
...prev,
|
||||||
|
[file.name]: config.buildErrorState(message, status)
|
||||||
|
}));
|
||||||
|
showNotification(
|
||||||
|
t('auth_files.quota_refresh_failed', { name: file.name, message }),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[disableControls, getQuotaState, showNotification, t, updateQuotaState]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderQuotaSection = (item: AuthFileItem, quotaType: QuotaProviderType) => {
|
||||||
|
const config = getQuotaConfig(quotaType) as unknown as {
|
||||||
|
i18nPrefix: string;
|
||||||
|
renderQuotaItems: (quota: unknown, t: TFunction, helpers: unknown) => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const quota = getQuotaState(quotaType, item.name) as
|
||||||
|
| { status?: string; error?: string; errorStatus?: number }
|
||||||
|
| undefined;
|
||||||
|
const quotaStatus = quota?.status ?? 'idle';
|
||||||
|
const quotaErrorMessage = resolveQuotaErrorMessage(
|
||||||
|
t,
|
||||||
|
quota?.errorStatus,
|
||||||
|
quota?.error || t('common.unknown_error')
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.quotaSection}>
|
||||||
|
{quotaStatus === 'loading' ? (
|
||||||
|
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.loading`)}</div>
|
||||||
|
) : quotaStatus === 'idle' ? (
|
||||||
|
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.idle`)}</div>
|
||||||
|
) : quotaStatus === 'error' ? (
|
||||||
|
<div className={styles.quotaError}>
|
||||||
|
{t(`${config.i18nPrefix}.load_failed`, {
|
||||||
|
message: quotaErrorMessage
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : quota ? (
|
||||||
|
(config.renderQuotaItems(quota, t, { styles, QuotaProgressBar }) as ReactNode)
|
||||||
|
) : (
|
||||||
|
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.idle`)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 渲染单个认证文件卡片
|
// 渲染单个认证文件卡片
|
||||||
const renderFileCard = (item: AuthFileItem) => {
|
const renderFileCard = (item: AuthFileItem) => {
|
||||||
const fileStats = resolveAuthFileStats(item, keyStats);
|
const fileStats = resolveAuthFileStats(item, keyStats);
|
||||||
const isRuntimeOnly = isRuntimeOnlyAuthFile(item);
|
const isRuntimeOnly = isRuntimeOnlyAuthFile(item);
|
||||||
const isAistudio = (item.type || '').toLowerCase() === 'aistudio';
|
const isAistudio = (item.type || '').toLowerCase() === 'aistudio';
|
||||||
const showModelsButton = !isRuntimeOnly || isAistudio;
|
const showModelsButton = !isRuntimeOnly || isAistudio;
|
||||||
const typeColor = getTypeColor(item.type || 'unknown');
|
const typeColor = getTypeColor(item.type || 'unknown');
|
||||||
|
|
||||||
return (
|
const quotaType =
|
||||||
<div
|
quotaFilterType && resolveQuotaType(item) === quotaFilterType ? quotaFilterType : null;
|
||||||
key={item.name}
|
|
||||||
className={`${styles.fileCard} ${item.disabled ? styles.fileCardDisabled : ''}`}
|
|
||||||
>
|
|
||||||
<div className={styles.cardHeader}>
|
|
||||||
<span
|
|
||||||
className={styles.typeBadge}
|
|
||||||
style={{
|
|
||||||
backgroundColor: typeColor.bg,
|
|
||||||
color: typeColor.text,
|
|
||||||
...(typeColor.border ? { border: typeColor.border } : {}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getTypeLabel(item.type || 'unknown')}
|
|
||||||
</span>
|
|
||||||
<span className={styles.fileName}>{item.name}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.cardMeta}>
|
const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly;
|
||||||
<span>
|
const quotaState = quotaType ? getQuotaState(quotaType, item.name) : undefined;
|
||||||
{t('auth_files.file_size')}: {item.size ? formatFileSize(item.size) : '-'}
|
const quotaRefreshing = quotaState?.status === 'loading';
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{t('auth_files.file_modified')}: {formatModified(item)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.cardStats}>
|
const providerCardClass =
|
||||||
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
quotaType === 'antigravity'
|
||||||
{t('stats.success')}: {fileStats.success}
|
? styles.antigravityCard
|
||||||
</span>
|
: quotaType === 'codex'
|
||||||
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
? styles.codexCard
|
||||||
{t('stats.failure')}: {fileStats.failure}
|
: quotaType === 'gemini-cli'
|
||||||
</span>
|
? styles.geminiCliCard
|
||||||
</div>
|
: '';
|
||||||
|
|
||||||
{/* 状态监测栏 */}
|
return (
|
||||||
{renderStatusBar(item)}
|
<div
|
||||||
|
key={item.name}
|
||||||
<div className={styles.cardActions}>
|
className={`${styles.fileCard} ${providerCardClass} ${item.disabled ? styles.fileCardDisabled : ''}`}
|
||||||
{showModelsButton && (
|
>
|
||||||
<Button
|
<div
|
||||||
variant="secondary"
|
className={`${styles.fileCardLayout} ${showQuotaLayout ? styles.fileCardLayoutQuota : ''}`}
|
||||||
size="sm"
|
>
|
||||||
onClick={() => showModels(item)}
|
<div className={styles.fileCardMain}>
|
||||||
className={styles.iconButton}
|
<div className={styles.cardHeader}>
|
||||||
title={t('auth_files.models_button', { defaultValue: '模型' })}
|
<span
|
||||||
disabled={disableControls}
|
className={styles.typeBadge}
|
||||||
>
|
style={{
|
||||||
<IconBot className={styles.actionIcon} size={16} />
|
backgroundColor: typeColor.bg,
|
||||||
</Button>
|
color: typeColor.text,
|
||||||
)}
|
...(typeColor.border ? { border: typeColor.border } : {}),
|
||||||
{!isRuntimeOnly && (
|
}}
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => showDetails(item)}
|
|
||||||
className={styles.iconButton}
|
|
||||||
title={t('common.info', { defaultValue: '关于' })}
|
|
||||||
disabled={disableControls}
|
|
||||||
>
|
>
|
||||||
<IconInfo className={styles.actionIcon} size={16} />
|
{getTypeLabel(item.type || 'unknown')}
|
||||||
</Button>
|
</span>
|
||||||
<Button
|
<span className={styles.fileName}>{item.name}</span>
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDownload(item.name)}
|
|
||||||
className={styles.iconButton}
|
|
||||||
title={t('auth_files.download_button')}
|
|
||||||
disabled={disableControls}
|
|
||||||
>
|
|
||||||
<IconDownload className={styles.actionIcon} size={16} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void openPrefixProxyEditor(item.name)}
|
|
||||||
className={styles.iconButton}
|
|
||||||
title={t('auth_files.prefix_proxy_button')}
|
|
||||||
disabled={disableControls}
|
|
||||||
>
|
|
||||||
<IconCode className={styles.actionIcon} size={16} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDelete(item.name)}
|
|
||||||
className={styles.iconButton}
|
|
||||||
title={t('auth_files.delete_button')}
|
|
||||||
disabled={disableControls || deleting === item.name}
|
|
||||||
>
|
|
||||||
{deleting === item.name ? (
|
|
||||||
<LoadingSpinner size={14} />
|
|
||||||
) : (
|
|
||||||
<IconTrash2 className={styles.actionIcon} size={16} />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!isRuntimeOnly && (
|
|
||||||
<div className={styles.statusToggle}>
|
|
||||||
<ToggleSwitch
|
|
||||||
ariaLabel={t('auth_files.status_toggle_label')}
|
|
||||||
checked={!item.disabled}
|
|
||||||
disabled={disableControls || statusUpdating[item.name] === true}
|
|
||||||
onChange={(value) => void handleStatusToggle(item, value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{isRuntimeOnly && (
|
<div className={styles.cardMeta}>
|
||||||
<div className={styles.virtualBadge}>
|
<span>
|
||||||
{t('auth_files.type_virtual') || '虚拟认证文件'}
|
{t('auth_files.file_size')}: {item.size ? formatFileSize(item.size) : '-'}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t('auth_files.file_modified')}: {formatModified(item)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.cardStats}>
|
||||||
|
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||||
|
{t('stats.success')}: {fileStats.success}
|
||||||
|
</span>
|
||||||
|
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
||||||
|
{t('stats.failure')}: {fileStats.failure}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 状态监测栏 */}
|
||||||
|
{renderStatusBar(item)}
|
||||||
|
|
||||||
|
{showQuotaLayout && quotaType && renderQuotaSection(item, quotaType)}
|
||||||
|
|
||||||
|
<div className={styles.cardActions}>
|
||||||
|
{showModelsButton && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => showModels(item)}
|
||||||
|
className={styles.iconButton}
|
||||||
|
title={t('auth_files.models_button', { defaultValue: '模型' })}
|
||||||
|
disabled={disableControls}
|
||||||
|
>
|
||||||
|
<IconBot className={styles.actionIcon} size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!isRuntimeOnly && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => showDetails(item)}
|
||||||
|
className={styles.iconButton}
|
||||||
|
title={t('common.info', { defaultValue: '关于' })}
|
||||||
|
disabled={disableControls}
|
||||||
|
>
|
||||||
|
<IconInfo className={styles.actionIcon} size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDownload(item.name)}
|
||||||
|
className={styles.iconButton}
|
||||||
|
title={t('auth_files.download_button')}
|
||||||
|
disabled={disableControls}
|
||||||
|
>
|
||||||
|
<IconDownload className={styles.actionIcon} size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void openPrefixProxyEditor(item.name)}
|
||||||
|
className={styles.iconButton}
|
||||||
|
title={t('auth_files.prefix_proxy_button')}
|
||||||
|
disabled={disableControls}
|
||||||
|
>
|
||||||
|
<IconCode className={styles.actionIcon} size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(item.name)}
|
||||||
|
className={styles.iconButton}
|
||||||
|
title={t('auth_files.delete_button')}
|
||||||
|
disabled={disableControls || deleting === item.name}
|
||||||
|
>
|
||||||
|
{deleting === item.name ? (
|
||||||
|
<LoadingSpinner size={14} />
|
||||||
|
) : (
|
||||||
|
<IconTrash2 className={styles.actionIcon} size={16} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isRuntimeOnly && (
|
||||||
|
<div className={styles.statusToggle}>
|
||||||
|
<ToggleSwitch
|
||||||
|
ariaLabel={t('auth_files.status_toggle_label')}
|
||||||
|
checked={!item.disabled}
|
||||||
|
disabled={disableControls || statusUpdating[item.name] === true}
|
||||||
|
onChange={(value) => void handleStatusToggle(item, value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isRuntimeOnly && (
|
||||||
|
<div className={styles.virtualBadge}>
|
||||||
|
{t('auth_files.type_virtual') || '虚拟认证文件'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showQuotaLayout && quotaType && (
|
||||||
|
<div className={styles.fileCardSidebar}>
|
||||||
|
<div className={styles.fileCardSidebarHeader}>
|
||||||
|
<span className={styles.fileCardSidebarTitle}>
|
||||||
|
{t('auth_files.card_tools_title')}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className={styles.iconButton}
|
||||||
|
onClick={() => void refreshQuotaForFile(item, quotaType)}
|
||||||
|
disabled={disableControls || item.disabled}
|
||||||
|
loading={quotaRefreshing}
|
||||||
|
title={t('auth_files.quota_refresh_single')}
|
||||||
|
aria-label={t('auth_files.quota_refresh_single')}
|
||||||
|
>
|
||||||
|
{!quotaRefreshing && <IconRefreshCw className={styles.actionIcon} size={16} />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.fileCardSidebarHint}>{t('auth_files.quota_refresh_hint')}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1311,7 +1849,11 @@ export function AuthFilesPage() {
|
|||||||
description={t('auth_files.search_empty_desc')}
|
description={t('auth_files.search_empty_desc')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.fileGrid}>{pageItems.map(renderFileCard)}</div>
|
<div
|
||||||
|
className={`${styles.fileGrid} ${quotaFilterType ? styles.fileGridQuotaManaged : ''}`}
|
||||||
|
>
|
||||||
|
{pageItems.map(renderFileCard)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 分页 */}
|
{/* 分页 */}
|
||||||
@@ -1377,7 +1919,11 @@ export function AuthFilesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.excludedActions}>
|
<div className={styles.excludedActions}>
|
||||||
<Button variant="secondary" size="sm" onClick={() => openExcludedEditor(provider)}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openExcludedEditor(provider)}
|
||||||
|
>
|
||||||
{t('common.edit')}
|
{t('common.edit')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="danger" size="sm" onClick={() => deleteExcluded(provider)}>
|
<Button variant="danger" size="sm" onClick={() => deleteExcluded(provider)}>
|
||||||
@@ -1394,13 +1940,33 @@ export function AuthFilesPage() {
|
|||||||
<Card
|
<Card
|
||||||
title={t('oauth_model_alias.title')}
|
title={t('oauth_model_alias.title')}
|
||||||
extra={
|
extra={
|
||||||
<Button
|
<div className={styles.cardExtraButtons}>
|
||||||
size="sm"
|
<div className={styles.viewModeSwitch}>
|
||||||
onClick={() => openModelAliasEditor()}
|
<Button
|
||||||
disabled={disableControls || modelAliasError === 'unsupported'}
|
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
|
||||||
>
|
size="sm"
|
||||||
{t('oauth_model_alias.add')}
|
onClick={() => setViewMode('list')}
|
||||||
</Button>
|
disabled={disableControls || modelAliasError === 'unsupported'}
|
||||||
|
>
|
||||||
|
{t('oauth_model_alias.view_mode_list')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'diagram' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode('diagram')}
|
||||||
|
disabled={disableControls || modelAliasError === 'unsupported'}
|
||||||
|
>
|
||||||
|
{t('oauth_model_alias.view_mode_diagram')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openModelAliasEditor()}
|
||||||
|
disabled={disableControls || modelAliasError === 'unsupported'}
|
||||||
|
>
|
||||||
|
{t('oauth_model_alias.add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{modelAliasError === 'unsupported' ? (
|
{modelAliasError === 'unsupported' ? (
|
||||||
@@ -1408,6 +1974,39 @@ export function AuthFilesPage() {
|
|||||||
title={t('oauth_model_alias.upgrade_required_title')}
|
title={t('oauth_model_alias.upgrade_required_title')}
|
||||||
description={t('oauth_model_alias.upgrade_required_desc')}
|
description={t('oauth_model_alias.upgrade_required_desc')}
|
||||||
/>
|
/>
|
||||||
|
) : viewMode === 'diagram' ? (
|
||||||
|
Object.keys(modelAlias).length === 0 ? (
|
||||||
|
<EmptyState title={t('oauth_model_alias.list_empty_all')} />
|
||||||
|
) : (
|
||||||
|
<div className={styles.aliasChartSection}>
|
||||||
|
<div className={styles.aliasChartHeader}>
|
||||||
|
<h4 className={styles.aliasChartTitle}>{t('oauth_model_alias.chart_title')}</h4>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => diagramRef.current?.collapseAll()}
|
||||||
|
disabled={disableControls || modelAliasError === 'unsupported'}
|
||||||
|
title={t('oauth_model_alias.diagram_collapse')}
|
||||||
|
aria-label={t('oauth_model_alias.diagram_collapse')}
|
||||||
|
>
|
||||||
|
<IconChevronUp size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ModelMappingDiagram
|
||||||
|
ref={diagramRef}
|
||||||
|
modelAlias={modelAlias}
|
||||||
|
allProviderModels={allProviderModels}
|
||||||
|
onUpdate={handleMappingUpdate}
|
||||||
|
onDeleteLink={handleDeleteLink}
|
||||||
|
onToggleFork={handleToggleFork}
|
||||||
|
onRenameAlias={handleRenameAlias}
|
||||||
|
onDeleteAlias={handleDeleteAlias}
|
||||||
|
onEditProvider={openModelAliasEditor}
|
||||||
|
onDeleteProvider={deleteModelAlias}
|
||||||
|
className={styles.aliasChart}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : Object.keys(modelAlias).length === 0 ? (
|
) : Object.keys(modelAlias).length === 0 ? (
|
||||||
<EmptyState title={t('oauth_model_alias.list_empty_all')} />
|
<EmptyState title={t('oauth_model_alias.list_empty_all')} />
|
||||||
) : (
|
) : (
|
||||||
@@ -1625,7 +2224,6 @@ export function AuthFilesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import type { Notification, NotificationType } from '@/types';
|
import type { Notification, NotificationType } from '@/types';
|
||||||
import { generateId } from '@/utils/helpers';
|
import { generateId } from '@/utils/helpers';
|
||||||
import { NOTIFICATION_DURATION_MS } from '@/utils/constants';
|
import { NOTIFICATION_DURATION_MS } from '@/utils/constants';
|
||||||
|
|
||||||
interface ConfirmationOptions {
|
interface ConfirmationOptions {
|
||||||
title?: string;
|
title?: string;
|
||||||
message: string;
|
message: ReactNode;
|
||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
variant?: 'danger' | 'primary' | 'secondary';
|
variant?: 'danger' | 'primary' | 'secondary';
|
||||||
|
|||||||
Reference in New Issue
Block a user