mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
35 Commits
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path d="M19.503 0H4.496A4.496 4.496 0 000 4.496v15.007A4.496 4.496 0 004.496 24h15.007A4.496 4.496 0 0024 19.503V4.496A4.496 4.496 0 0019.503 0z" fill="#fff"></path><path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388zm3.482 10.565a.637.637 0 000 1.272h3.636a.637.637 0 100-1.272h-3.636zM8.462 9.23a.637.637 0 00-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 101.095.649l1.454-2.455a.636.636 0 00.005-.64L8.462 9.23z" fill="url(#lobe-icons-codex-fill)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-codex-fill" x1="12" x2="12" y1="3" y2="21"><stop stop-color="#B1A7FF"></stop><stop offset=".5" stop-color="#7A9DFF"></stop><stop offset="1" stop-color="#3941FF"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="256.000000pt" height="256.000000pt" viewBox="0 0 256.000000 256.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<g transform="translate(0.000000,256.000000) scale(0.100000,-0.100000)"
|
||||
fill="#FFFFFF" stroke="none">
|
||||
<path d="M1107 2290 c-316 -57 -615 -283 -748 -565 -68 -144 -91 -241 -96
|
||||
-406 -6 -156 7 -249 49 -374 87 -254 291 -478 542 -596 146 -68 226 -84 426
|
||||
-84 152 0 186 3 260 23 182 50 327 136 465 277 147 150 245 334 282 529 23
|
||||
123 14 344 -20 456 -35 116 -69 190 -134 290 -131 200 -340 354 -578 426 -78
|
||||
23 -111 27 -245 30 -85 1 -177 -1 -203 -6z m362 -216 c91 -21 224 -86 310
|
||||
-152 133 -101 249 -275 293 -439 16 -60 21 -108 21 -203 0 -152 -21 -240 -88
|
||||
-368 -130 -253 -350 -407 -634 -443 -393 -50 -777 214 -882 607 -30 110 -30
|
||||
296 0 408 72 270 282 489 552 576 130 41 287 47 428 14z"/>
|
||||
<path d="M849 1637 c-31 -24 -52 -67 -46 -95 3 -15 35 -78 71 -139 36 -61 66
|
||||
-115 66 -119 0 -5 -30 -58 -66 -119 -36 -60 -68 -123 -70 -140 -7 -42 26 -90
|
||||
70 -105 31 -10 42 -9 72 7 31 15 51 43 125 173 93 162 101 188 73 243 -50 97
|
||||
-169 289 -185 297 -25 14 -91 12 -110 -3z"/>
|
||||
<path d="M1353 1139 c-42 -12 -73 -53 -73 -96 0 -27 8 -43 35 -70 l34 -34 216
|
||||
3 217 3 30 34 c26 29 29 40 25 73 -7 49 -29 75 -76 88 -45 12 -364 12 -408 -1z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="256.000000pt" height="256.000000pt" viewBox="0 0 256.000000 256.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<g transform="translate(0.000000,256.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M1107 2290 c-316 -57 -615 -283 -748 -565 -68 -144 -91 -241 -96
|
||||
-406 -6 -156 7 -249 49 -374 87 -254 291 -478 542 -596 146 -68 226 -84 426
|
||||
-84 152 0 186 3 260 23 182 50 327 136 465 277 147 150 245 334 282 529 23
|
||||
123 14 344 -20 456 -35 116 -69 190 -134 290 -131 200 -340 354 -578 426 -78
|
||||
23 -111 27 -245 30 -85 1 -177 -1 -203 -6z m362 -216 c91 -21 224 -86 310
|
||||
-152 133 -101 249 -275 293 -439 16 -60 21 -108 21 -203 0 -152 -21 -240 -88
|
||||
-368 -130 -253 -350 -407 -634 -443 -393 -50 -777 214 -882 607 -30 110 -30
|
||||
296 0 408 72 270 282 489 552 576 130 41 287 47 428 14z"/>
|
||||
<path d="M849 1637 c-31 -24 -52 -67 -46 -95 3 -15 35 -78 71 -139 36 -61 66
|
||||
-115 66 -119 0 -5 -30 -58 -66 -119 -36 -60 -68 -123 -70 -140 -7 -42 26 -90
|
||||
70 -105 31 -10 42 -9 72 7 31 15 51 43 125 173 93 162 101 188 73 243 -50 97
|
||||
-169 289 -185 297 -25 14 -91 12 -110 -3z"/>
|
||||
<path d="M1353 1139 c-42 -12 -73 -53 -73 -96 0 -27 8 -43 35 -70 l34 -34 216
|
||||
3 217 3 30 34 c26 29 29 40 25 73 -7 49 -29 75 -76 88 -45 12 -364 12 -408 -1z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,108 @@
|
||||
@use '../../styles/mixins' as *;
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.section {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 220px) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
padding: 26px 0;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border-color) 76%, transparent);
|
||||
scroll-margin-top: 112px;
|
||||
|
||||
&:first-child {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
padding: 22px 0;
|
||||
scroll-margin-top: 88px;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 104px;
|
||||
align-self: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
|
||||
@include mobile {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.indexBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 42px;
|
||||
height: 42px;
|
||||
padding: 0 12px;
|
||||
border-radius: 14px;
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--bg-primary) 92%, transparent), transparent),
|
||||
color-mix(in srgb, var(--primary-color) 11%, var(--bg-secondary));
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color) 18%, var(--border-color));
|
||||
color: var(--primary-hover);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.iconBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 88%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||
color: var(--text-secondary);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.headingGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: clamp(20px, 2vw, 25px);
|
||||
line-height: 1.08;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
max-width: 34ch;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
|
||||
@include mobile {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -1,22 +1,34 @@
|
||||
import type { PropsWithChildren, ReactNode } from 'react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { forwardRef, type HTMLAttributes, type PropsWithChildren, type ReactNode } from 'react';
|
||||
import styles from './ConfigSection.module.scss';
|
||||
|
||||
interface ConfigSectionProps {
|
||||
interface ConfigSectionProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
className?: string;
|
||||
indexLabel?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export function ConfigSection({ title, description, className, children }: PropsWithChildren<ConfigSectionProps>) {
|
||||
return (
|
||||
<Card title={title} className={className}>
|
||||
{description && (
|
||||
<p style={{ margin: '-4px 0 16px 0', color: 'var(--text-secondary)', fontSize: 13 }}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
export const ConfigSection = forwardRef<HTMLElement, PropsWithChildren<ConfigSectionProps>>(
|
||||
function ConfigSection(
|
||||
{ title, description, indexLabel, icon, className, children, ...rest },
|
||||
ref
|
||||
) {
|
||||
const sectionClassName = [styles.section, className].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<section ref={ref} className={sectionClassName} {...rest}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.titleRow}>
|
||||
{indexLabel ? <span className={styles.indexBadge}>{indexLabel}</span> : null}
|
||||
{icon ? <span className={styles.iconBadge}>{icon}</span> : null}
|
||||
</div>
|
||||
<div className={styles.headingGroup}>
|
||||
<h2 className={styles.title}>{title}</h2>
|
||||
{description ? <p className={styles.description}>{description}</p> : null}
|
||||
</div>
|
||||
</header>
|
||||
<div className={styles.content}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,3 +1,662 @@
|
||||
@use '../../styles/mixins' as *;
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.visualEditor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xl;
|
||||
|
||||
:global(.form-group) {
|
||||
gap: 8px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:global(.form-group > label) {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
:global(.input) {
|
||||
min-height: 48px;
|
||||
border-radius: 16px;
|
||||
background: color-mix(in srgb, var(--bg-primary) 92%, transparent);
|
||||
border-color: color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
:global(.input:focus) {
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
:global(textarea.input) {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
:global(.hint) {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
:global(.error-box) {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
:global(.item-list) {
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
:global(.item-row) {
|
||||
border-radius: 18px;
|
||||
padding: 14px 16px;
|
||||
background: color-mix(in srgb, var(--bg-primary) 78%, transparent);
|
||||
border-color: color-mix(in srgb, var(--border-color) 86%, transparent);
|
||||
}
|
||||
|
||||
:global(.item-row .item-meta) {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
:global(.item-row .item-actions) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
:global(.pill) {
|
||||
background: color-mix(in srgb, var(--bg-secondary) 84%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.overview {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
border-radius: 30px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at top right,
|
||||
color-mix(in srgb, var(--primary-color) 12%, transparent),
|
||||
transparent 36%
|
||||
),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--bg-primary) 94%, transparent),
|
||||
color-mix(in srgb, var(--bg-secondary) 96%, transparent)
|
||||
);
|
||||
padding: clamp(22px, 3vw, 30px);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
width: min(220px, 44vw);
|
||||
height: min(220px, 44vw);
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--primary-color) 16%, transparent),
|
||||
transparent 72%
|
||||
);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.overviewHeader {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.overviewMeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.overviewPill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.overviewPillWarning {
|
||||
color: var(--warning-text);
|
||||
border-color: var(--warning-border);
|
||||
background: var(--warning-bg);
|
||||
}
|
||||
|
||||
.overviewFocusList {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.overviewFocusLink {
|
||||
@include button-reset;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 74%, transparent);
|
||||
text-align: left;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
background-color 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in srgb, var(--primary-color) 26%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--bg-primary) 92%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.overviewFocusLinkActive {
|
||||
border-color: color-mix(in srgb, var(--primary-color) 28%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--bg-primary) 92%, transparent);
|
||||
}
|
||||
|
||||
.focusIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, var(--bg-secondary));
|
||||
color: var(--primary-hover);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.focusCopy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.focusTitle {
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.focusDescription {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 280px) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: relative;
|
||||
align-self: start;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
position: static;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebarPlaceholder {
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.sidebarRail {
|
||||
max-height: calc(100vh - var(--header-height, 64px) - 36px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px;
|
||||
border-radius: 26px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 76%, transparent);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
box-shadow: 0 24px 56px -34px rgba(0, 0, 0, 0.42);
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.floatingSidebarContainer {
|
||||
position: fixed;
|
||||
left: var(--visual-config-floating-left, 16px);
|
||||
top: var(--visual-config-floating-top, 120px);
|
||||
width: var(--visual-config-floating-width, 280px);
|
||||
max-height: var(--visual-config-floating-max-height, calc(100vh - 136px));
|
||||
z-index: 45;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s ease;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.floatingSidebarRail {
|
||||
max-height: inherit;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px;
|
||||
border-radius: 26px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 76%, transparent);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
box-shadow: 0 24px 56px -34px rgba(0, 0, 0, 0.42);
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.navList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navButton {
|
||||
@include button-reset;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
background-color 0.18s ease,
|
||||
border-color 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateX(2px);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 86%, transparent);
|
||||
border-color: color-mix(in srgb, var(--border-color) 88%, transparent);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
min-width: 232px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navButtonActive {
|
||||
background: color-mix(in srgb, var(--bg-primary) 92%, transparent);
|
||||
border-color: color-mix(in srgb, var(--primary-color) 24%, var(--border-color));
|
||||
box-shadow: 0 18px 36px -30px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
.navIndex {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 88%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.navMain {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.navHeadingRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.navLabelWrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.navIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--primary-hover);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.navLabel {
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.navDescription {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.navBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
color: var(--warning-text);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: clamp(20px, 3vw, 30px);
|
||||
border-radius: 32px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 76%, transparent);
|
||||
}
|
||||
|
||||
.sectionGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sectionStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
}
|
||||
|
||||
.toggleRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 16px 18px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 84%, transparent);
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.toggleCopy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toggleTitle {
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.toggleDescription {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.fieldShell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.fieldControl {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fieldHint {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.inlinePill {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 92%, transparent);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.subsection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 84%, transparent);
|
||||
}
|
||||
|
||||
.subsectionHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.subsectionTitle {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.subsectionDescription {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.sectionIssueBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
color: var(--warning-text);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.blockHeaderRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.blockStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ruleCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 88%, transparent);
|
||||
}
|
||||
|
||||
.ruleCardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ruleCardTitle {
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.blockLabel {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.actionRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
border: 1px dashed color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
border-radius: 18px;
|
||||
padding: 18px 16px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 88%, transparent);
|
||||
}
|
||||
|
||||
.stringList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stringListRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.payloadRuleModelRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 160px auto;
|
||||
@@ -23,9 +682,11 @@
|
||||
}
|
||||
|
||||
.payloadJsonInput {
|
||||
min-height: 96px;
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
font-family: 'Consolas', 'Monaco', 'Menlo', monospace;
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
.payloadParamError {
|
||||
@@ -66,3 +727,19 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.sections {
|
||||
border-radius: 26px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.subsection {
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.stringListRow {
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,30 @@ function getValidationMessage(
|
||||
return t(`config_management.visual.validation.${errorCode}`);
|
||||
}
|
||||
|
||||
function buildProtocolOptions(
|
||||
t: ReturnType<typeof useTranslation>['t'],
|
||||
rules: Array<{ models: PayloadModelEntry[] }>
|
||||
) {
|
||||
const options: Array<{ value: string; label: string }> = VISUAL_CONFIG_PROTOCOL_OPTIONS.map(
|
||||
(option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
|
||||
})
|
||||
);
|
||||
const seen = new Set<string>(options.map((option) => option.value));
|
||||
|
||||
for (const rule of rules) {
|
||||
for (const model of rule.models) {
|
||||
const protocol = model.protocol;
|
||||
if (!protocol || !protocol.trim() || seen.has(protocol)) continue;
|
||||
seen.add(protocol);
|
||||
options.push({ value: protocol, label: protocol });
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({
|
||||
value,
|
||||
disabled,
|
||||
@@ -54,7 +78,10 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({
|
||||
const renderApiKeyIds = useMemo(() => {
|
||||
if (apiKeyIds.length === apiKeys.length) return apiKeyIds;
|
||||
if (apiKeyIds.length > apiKeys.length) return apiKeyIds.slice(0, apiKeys.length);
|
||||
return [...apiKeyIds, ...Array.from({ length: apiKeys.length - apiKeyIds.length }, () => makeClientId())];
|
||||
return [
|
||||
...apiKeyIds,
|
||||
...Array.from({ length: apiKeys.length - apiKeyIds.length }, () => makeClientId()),
|
||||
];
|
||||
}, [apiKeyIds, apiKeys.length]);
|
||||
|
||||
const apiKeyInputId = useId();
|
||||
@@ -116,7 +143,9 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({
|
||||
return;
|
||||
}
|
||||
|
||||
const editingIndex = editingApiKeyId ? renderApiKeyIds.findIndex((id) => id === editingApiKeyId) : -1;
|
||||
const editingIndex = editingApiKeyId
|
||||
? renderApiKeyIds.findIndex((id) => id === editingApiKeyId)
|
||||
: -1;
|
||||
const nextKeys =
|
||||
editingApiKeyId === null
|
||||
? [...apiKeys, trimmed]
|
||||
@@ -143,7 +172,7 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({
|
||||
|
||||
return (
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div className={styles.blockHeaderRow}>
|
||||
<label style={{ margin: 0 }}>{t('config_management.visual.api_keys.label')}</label>
|
||||
<Button size="sm" onClick={openAddModal} disabled={disabled}>
|
||||
{t('config_management.visual.api_keys.add')}
|
||||
@@ -151,28 +180,25 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({
|
||||
</div>
|
||||
|
||||
{apiKeys.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
border: '1px dashed var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
color: 'var(--text-secondary)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{t('config_management.visual.api_keys.empty')}
|
||||
</div>
|
||||
<div className={styles.emptyState}>{t('config_management.visual.api_keys.empty')}</div>
|
||||
) : (
|
||||
<div className="item-list" style={{ marginTop: 4 }}>
|
||||
{apiKeys.map((key, index) => (
|
||||
<div key={renderApiKeyIds[index] ?? `${key}-${index}`} className="item-row">
|
||||
<div className="item-meta">
|
||||
<div className="pill">#{index + 1}</div>
|
||||
<div className="item-title">{t('config_management.visual.api_keys.input_label')}</div>
|
||||
<div className="item-title">
|
||||
{t('config_management.visual.api_keys.input_label')}
|
||||
</div>
|
||||
<div className="item-subtitle">{maskApiKey(String(key || ''))}</div>
|
||||
</div>
|
||||
<div className="item-actions">
|
||||
<Button variant="secondary" size="sm" onClick={() => handleCopy(key)} disabled={disabled}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleCopy(key)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('common.copy')}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -202,20 +228,28 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
onClose={closeModal}
|
||||
title={editingApiKeyId !== null ? t('config_management.visual.api_keys.edit_title') : t('config_management.visual.api_keys.add_title')}
|
||||
title={
|
||||
editingApiKeyId !== null
|
||||
? t('config_management.visual.api_keys.edit_title')
|
||||
: t('config_management.visual.api_keys.add_title')
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={closeModal} disabled={disabled}>
|
||||
{t('config_management.visual.common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={disabled}>
|
||||
{editingApiKeyId !== null ? t('config_management.visual.common.update') : t('config_management.visual.common.add')}
|
||||
{editingApiKeyId !== null
|
||||
? t('config_management.visual.common.update')
|
||||
: t('config_management.visual.common.add')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="form-group">
|
||||
<label htmlFor={apiKeyInputId}>{t('config_management.visual.api_keys.input_label')}</label>
|
||||
<label htmlFor={apiKeyInputId}>
|
||||
{t('config_management.visual.api_keys.input_label')}
|
||||
</label>
|
||||
<div className={styles.apiKeyModalInputRow}>
|
||||
<input
|
||||
id={apiKeyInputId}
|
||||
@@ -237,8 +271,14 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({
|
||||
{t('config_management.visual.api_keys.generate')}
|
||||
</Button>
|
||||
</div>
|
||||
<div id={apiKeyHintId} className="hint">{t('config_management.visual.api_keys.input_hint')}</div>
|
||||
{formError && <div id={apiKeyErrorId} className="error-box">{formError}</div>}
|
||||
<div id={apiKeyHintId} className="hint">
|
||||
{t('config_management.visual.api_keys.input_hint')}
|
||||
</div>
|
||||
{formError && (
|
||||
<div id={apiKeyErrorId} className="error-box">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
@@ -264,7 +304,10 @@ const StringListEditor = memo(function StringListEditor({
|
||||
const renderItemIds = useMemo(() => {
|
||||
if (itemIds.length === items.length) return itemIds;
|
||||
if (itemIds.length > items.length) return itemIds.slice(0, items.length);
|
||||
return [...itemIds, ...Array.from({ length: items.length - itemIds.length }, () => makeClientId())];
|
||||
return [
|
||||
...itemIds,
|
||||
...Array.from({ length: items.length - itemIds.length }, () => makeClientId()),
|
||||
];
|
||||
}, [itemIds, items.length]);
|
||||
|
||||
const updateItem = (index: number, nextValue: string) =>
|
||||
@@ -279,9 +322,9 @@ const StringListEditor = memo(function StringListEditor({
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div className={styles.stringList}>
|
||||
{items.map((item, index) => (
|
||||
<div key={renderItemIds[index] ?? `item-${index}`} style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div key={renderItemIds[index] ?? `item-${index}`} className={styles.stringListRow}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={placeholder}
|
||||
@@ -296,7 +339,7 @@ const StringListEditor = memo(function StringListEditor({
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<div className={styles.actionRow}>
|
||||
<Button variant="secondary" size="sm" onClick={addItem} disabled={disabled}>
|
||||
{t('config_management.visual.common.add')}
|
||||
</Button>
|
||||
@@ -309,23 +352,18 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
|
||||
value,
|
||||
disabled,
|
||||
protocolFirst = false,
|
||||
rawJsonValues = false,
|
||||
onChange,
|
||||
}: {
|
||||
value: PayloadRule[];
|
||||
disabled?: boolean;
|
||||
protocolFirst?: boolean;
|
||||
rawJsonValues?: boolean;
|
||||
onChange: (next: PayloadRule[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rules = value.length ? value : [];
|
||||
const protocolOptions = useMemo(
|
||||
() =>
|
||||
VISUAL_CONFIG_PROTOCOL_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
const rules = value;
|
||||
const protocolOptions = useMemo(() => buildProtocolOptions(t, rules), [rules, t]);
|
||||
const payloadValueTypeOptions = useMemo(
|
||||
() =>
|
||||
VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS.map((option) => ({
|
||||
@@ -359,7 +397,11 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
|
||||
updateRule(ruleIndex, { models: rule.models.filter((_, i) => i !== modelIndex) });
|
||||
};
|
||||
|
||||
const updateModel = (ruleIndex: number, modelIndex: number, patch: Partial<PayloadModelEntry>) => {
|
||||
const updateModel = (
|
||||
ruleIndex: number,
|
||||
modelIndex: number,
|
||||
patch: Partial<PayloadModelEntry>
|
||||
) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, {
|
||||
models: rule.models.map((m, i) => (i === modelIndex ? { ...m, ...patch } : m)),
|
||||
@@ -371,7 +413,7 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
|
||||
const nextParam: PayloadParamEntry = {
|
||||
id: makeClientId(),
|
||||
path: '',
|
||||
valueType: 'string',
|
||||
valueType: rawJsonValues ? 'json' : 'string',
|
||||
value: '',
|
||||
};
|
||||
updateRule(ruleIndex, { params: [...rule.params, nextParam] });
|
||||
@@ -382,7 +424,11 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
|
||||
updateRule(ruleIndex, { params: rule.params.filter((_, i) => i !== paramIndex) });
|
||||
};
|
||||
|
||||
const updateParam = (ruleIndex: number, paramIndex: number, patch: Partial<PayloadParamEntry>) => {
|
||||
const updateParam = (
|
||||
ruleIndex: number,
|
||||
paramIndex: number,
|
||||
patch: Partial<PayloadParamEntry>
|
||||
) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, {
|
||||
params: rule.params.map((p, i) => (i === paramIndex ? { ...p, ...patch } : p)),
|
||||
@@ -405,7 +451,9 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
|
||||
};
|
||||
|
||||
const getParamErrorMessage = (param: PayloadParamEntry) => {
|
||||
const errorCode = getPayloadParamValidationError(param);
|
||||
const errorCode = getPayloadParamValidationError(
|
||||
rawJsonValues ? { ...param, valueType: 'json' } : param
|
||||
);
|
||||
return getValidationMessage(t, errorCode);
|
||||
};
|
||||
|
||||
@@ -414,10 +462,29 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
|
||||
paramIndex: number,
|
||||
param: PayloadParamEntry
|
||||
) => {
|
||||
if (rawJsonValues) {
|
||||
return (
|
||||
<textarea
|
||||
className={`input ${styles.payloadJsonInput}`}
|
||||
placeholder={t('config_management.visual.payload_rules.value_raw_json')}
|
||||
aria-label={t('config_management.visual.payload_rules.param_value')}
|
||||
value={param.value}
|
||||
onChange={(e) =>
|
||||
updateParam(ruleIndex, paramIndex, { value: e.target.value, valueType: 'json' })
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (param.valueType === 'boolean') {
|
||||
return (
|
||||
<Select
|
||||
value={param.value.toLowerCase() === 'true' || param.value.toLowerCase() === 'false' ? param.value.toLowerCase() : ''}
|
||||
value={
|
||||
param.value.toLowerCase() === 'true' || param.value.toLowerCase() === 'false'
|
||||
? param.value.toLowerCase()
|
||||
: ''
|
||||
}
|
||||
options={booleanValueOptions}
|
||||
placeholder={t('config_management.visual.payload_rules.value_boolean')}
|
||||
disabled={disabled}
|
||||
@@ -453,40 +520,34 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div className={styles.blockStack}>
|
||||
{rules.map((rule, ruleIndex) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
style={{
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}>
|
||||
<div key={rule.id} className={styles.ruleCard}>
|
||||
<div className={styles.ruleCardHeader}>
|
||||
<div className={styles.ruleCardTitle}>
|
||||
{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeRule(ruleIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.models')}</div>
|
||||
<div className={styles.blockStack}>
|
||||
<div className={styles.blockLabel}>
|
||||
{t('config_management.visual.payload_rules.models')}
|
||||
</div>
|
||||
{(rule.models.length ? rule.models : []).map((model, modelIndex) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className={[styles.payloadRuleModelRow, protocolFirst ? styles.payloadRuleModelRowProtocolFirst : '']
|
||||
className={[
|
||||
styles.payloadRuleModelRow,
|
||||
protocolFirst ? styles.payloadRuleModelRowProtocolFirst : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
@@ -546,15 +607,22 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={() => addModel(ruleIndex)} disabled={disabled}>
|
||||
<div className={styles.actionRow}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => addModel(ruleIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.payload_rules.add_model')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.params')}</div>
|
||||
<div className={styles.blockStack}>
|
||||
<div className={styles.blockLabel}>
|
||||
{t('config_management.visual.payload_rules.params')}
|
||||
</div>
|
||||
{(rule.params.length ? rule.params : []).map((param, paramIndex) => {
|
||||
const paramError = getParamErrorMessage(param);
|
||||
|
||||
@@ -569,23 +637,25 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
|
||||
onChange={(e) => updateParam(ruleIndex, paramIndex, { path: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
value={param.valueType}
|
||||
options={payloadValueTypeOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.param_type')}
|
||||
onChange={(nextValue) =>
|
||||
updateParam(ruleIndex, paramIndex, {
|
||||
valueType: nextValue as PayloadParamValueType,
|
||||
value:
|
||||
nextValue === 'boolean'
|
||||
? 'true'
|
||||
: nextValue === 'json' && param.value.trim() === ''
|
||||
? '{}'
|
||||
: param.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{rawJsonValues ? null : (
|
||||
<Select
|
||||
value={param.valueType}
|
||||
options={payloadValueTypeOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.param_type')}
|
||||
onChange={(nextValue) =>
|
||||
updateParam(ruleIndex, paramIndex, {
|
||||
valueType: nextValue as PayloadParamValueType,
|
||||
value:
|
||||
nextValue === 'boolean'
|
||||
? 'true'
|
||||
: nextValue === 'json' && param.value.trim() === ''
|
||||
? '{}'
|
||||
: param.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{renderParamValueEditor(ruleIndex, paramIndex, param)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -597,12 +667,19 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
{paramError && <div className={`error-box ${styles.payloadParamError}`}>{paramError}</div>}
|
||||
{paramError && (
|
||||
<div className={`error-box ${styles.payloadParamError}`}>{paramError}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={() => addParam(ruleIndex)} disabled={disabled}>
|
||||
<div className={styles.actionRow}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => addParam(ruleIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.payload_rules.add_param')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -611,20 +688,12 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
|
||||
))}
|
||||
|
||||
{rules.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
border: '1px dashed var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
color: 'var(--text-secondary)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div className={styles.emptyState}>
|
||||
{t('config_management.visual.payload_rules.no_rules')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<div className={styles.actionRow}>
|
||||
<Button variant="secondary" size="sm" onClick={addRule} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_rule')}
|
||||
</Button>
|
||||
@@ -643,15 +712,8 @@ export const PayloadFilterRulesEditor = memo(function PayloadFilterRulesEditor({
|
||||
onChange: (next: PayloadFilterRule[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rules = value.length ? value : [];
|
||||
const protocolOptions = useMemo(
|
||||
() =>
|
||||
VISUAL_CONFIG_PROTOCOL_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
const rules = value;
|
||||
const protocolOptions = useMemo(() => buildProtocolOptions(t, rules), [rules, t]);
|
||||
|
||||
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
|
||||
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
|
||||
@@ -670,7 +732,11 @@ export const PayloadFilterRulesEditor = memo(function PayloadFilterRulesEditor({
|
||||
updateRule(ruleIndex, { models: rule.models.filter((_, i) => i !== modelIndex) });
|
||||
};
|
||||
|
||||
const updateModel = (ruleIndex: number, modelIndex: number, patch: Partial<PayloadModelEntry>) => {
|
||||
const updateModel = (
|
||||
ruleIndex: number,
|
||||
modelIndex: number,
|
||||
patch: Partial<PayloadModelEntry>
|
||||
) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, {
|
||||
models: rule.models.map((m, i) => (i === modelIndex ? { ...m, ...patch } : m)),
|
||||
@@ -678,36 +744,27 @@ export const PayloadFilterRulesEditor = memo(function PayloadFilterRulesEditor({
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div className={styles.blockStack}>
|
||||
{rules.map((rule, ruleIndex) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
style={{
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}>
|
||||
<div key={rule.id} className={styles.ruleCard}>
|
||||
<div className={styles.ruleCardHeader}>
|
||||
<div className={styles.ruleCardTitle}>
|
||||
{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeRule(ruleIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.models')}</div>
|
||||
<div className={styles.blockStack}>
|
||||
<div className={styles.blockLabel}>
|
||||
{t('config_management.visual.payload_rules.models')}
|
||||
</div>
|
||||
{rule.models.map((model, modelIndex) => (
|
||||
<div key={model.id} className={styles.payloadFilterModelRow}>
|
||||
<input
|
||||
@@ -740,15 +797,22 @@ export const PayloadFilterRulesEditor = memo(function PayloadFilterRulesEditor({
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={() => addModel(ruleIndex)} disabled={disabled}>
|
||||
<div className={styles.actionRow}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => addModel(ruleIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.payload_rules.add_model')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.remove_params')}</div>
|
||||
<div className={styles.blockStack}>
|
||||
<div className={styles.blockLabel}>
|
||||
{t('config_management.visual.payload_rules.remove_params')}
|
||||
</div>
|
||||
<StringListEditor
|
||||
value={rule.params}
|
||||
disabled={disabled}
|
||||
@@ -761,20 +825,12 @@ export const PayloadFilterRulesEditor = memo(function PayloadFilterRulesEditor({
|
||||
))}
|
||||
|
||||
{rules.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
border: '1px dashed var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
color: 'var(--text-secondary)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div className={styles.emptyState}>
|
||||
{t('config_management.visual.payload_rules.no_rules')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<div className={styles.actionRow}>
|
||||
<Button variant="secondary" size="sm" onClick={addRule} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_rule')}
|
||||
</Button>
|
||||
|
||||
@@ -13,15 +13,15 @@ import { Button } from '@/components/ui/Button';
|
||||
import { PageTransition } from '@/components/common/PageTransition';
|
||||
import { MainRoutes } from '@/router/MainRoutes';
|
||||
import {
|
||||
IconBot,
|
||||
IconChartLine,
|
||||
IconFileText,
|
||||
IconInfo,
|
||||
IconLayoutDashboard,
|
||||
IconScrollText,
|
||||
IconSettings,
|
||||
IconShield,
|
||||
IconTimer,
|
||||
IconSidebarAuthFiles,
|
||||
IconSidebarConfig,
|
||||
IconSidebarDashboard,
|
||||
IconSidebarLogs,
|
||||
IconSidebarOauth,
|
||||
IconSidebarProviders,
|
||||
IconSidebarQuota,
|
||||
IconSidebarSystem,
|
||||
IconSidebarUsage,
|
||||
} from '@/components/ui/icons';
|
||||
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||
import {
|
||||
@@ -31,22 +31,21 @@ import {
|
||||
useNotificationStore,
|
||||
useThemeStore,
|
||||
} from '@/stores';
|
||||
import { versionApi } from '@/services/api';
|
||||
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
|
||||
import { isSupportedLanguage } from '@/utils/language';
|
||||
import type { Theme } from '@/types';
|
||||
|
||||
const sidebarIcons: Record<string, ReactNode> = {
|
||||
dashboard: <IconLayoutDashboard size={18} />,
|
||||
aiProviders: <IconBot size={18} />,
|
||||
authFiles: <IconFileText size={18} />,
|
||||
oauth: <IconShield size={18} />,
|
||||
quota: <IconTimer size={18} />,
|
||||
usage: <IconChartLine size={18} />,
|
||||
config: <IconSettings size={18} />,
|
||||
logs: <IconScrollText size={18} />,
|
||||
system: <IconInfo size={18} />,
|
||||
dashboard: <IconSidebarDashboard size={18} />,
|
||||
aiProviders: <IconSidebarProviders size={18} />,
|
||||
authFiles: <IconSidebarAuthFiles size={18} />,
|
||||
oauth: <IconSidebarOauth size={18} />,
|
||||
quota: <IconSidebarQuota size={18} />,
|
||||
usage: <IconSidebarUsage size={18} />,
|
||||
config: <IconSidebarConfig size={18} />,
|
||||
logs: <IconSidebarLogs size={18} />,
|
||||
system: <IconSidebarSystem size={18} />,
|
||||
};
|
||||
|
||||
// Header action icons - smaller size for header buttons
|
||||
@@ -70,12 +69,6 @@ const headerIcons = {
|
||||
<path d="M21 3v5h-5" />
|
||||
</svg>
|
||||
),
|
||||
update: (
|
||||
<svg {...headerIconProps}>
|
||||
<path d="M12 19V5" />
|
||||
<path d="m5 12 7-7 7 7" />
|
||||
</svg>
|
||||
),
|
||||
menu: (
|
||||
<svg {...headerIconProps}>
|
||||
<path d="M4 7h16" />
|
||||
@@ -132,7 +125,13 @@ const headerIcons = {
|
||||
</clipPath>
|
||||
</defs>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<circle cx="12" cy="12" r="4" clipPath="url(#mainLayoutAutoThemeSunLeftHalf)" fill="currentColor" />
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="4"
|
||||
clipPath="url(#mainLayoutAutoThemeSunLeftHalf)"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="M4.93 4.93l1.41 1.41" />
|
||||
@@ -161,63 +160,54 @@ const THEME_CARDS: Array<{
|
||||
key: 'auto',
|
||||
labelKey: 'theme.auto',
|
||||
colors: {
|
||||
bg: 'linear-gradient(135deg, #faf9f5 0 50%, #151412 50% 100%)',
|
||||
card: 'linear-gradient(135deg, #f0eee8 0 50%, #1d1b18 50% 100%)',
|
||||
border: '#bdb6ae',
|
||||
bg: 'linear-gradient(135deg, #ffffff 0 50%, #111111 50% 100%)',
|
||||
card: 'linear-gradient(135deg, #ffffff 0 50%, #1a1a1a 50% 100%)',
|
||||
border: '#bdbdbd',
|
||||
text: '#2d2a26',
|
||||
textMuted: 'linear-gradient(135deg, #a29c95 0 50%, #9c958d 50% 100%)',
|
||||
textMuted: 'linear-gradient(135deg, #c9c9c9 0 50%, #5a5a5a 50% 100%)',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'white',
|
||||
labelKey: 'theme.white',
|
||||
colors: { bg: '#ffffff', card: '#ffffff', border: '#e5e5e5', text: '#2d2a26', textMuted: '#a29c95' },
|
||||
colors: {
|
||||
bg: '#ffffff',
|
||||
card: '#ffffff',
|
||||
border: '#e5e5e5',
|
||||
text: '#2d2a26',
|
||||
textMuted: '#a29c95',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'light',
|
||||
labelKey: 'theme.light',
|
||||
colors: { bg: '#faf9f5', card: '#f0eee8', border: '#e3e1db', text: '#2d2a26', textMuted: '#a29c95' },
|
||||
colors: {
|
||||
bg: '#faf9f5',
|
||||
card: '#f0eee8',
|
||||
border: '#e3e1db',
|
||||
text: '#2d2a26',
|
||||
textMuted: '#a29c95',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'dark',
|
||||
labelKey: 'theme.dark',
|
||||
colors: { bg: '#151412', card: '#1d1b18', border: '#3a3530', text: '#f6f4f1', textMuted: '#9c958d' },
|
||||
colors: {
|
||||
bg: '#151412',
|
||||
card: '#1d1b18',
|
||||
border: '#3a3530',
|
||||
text: '#f6f4f1',
|
||||
textMuted: '#9c958d',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const parseVersionSegments = (version?: string | null) => {
|
||||
if (!version) return null;
|
||||
const cleaned = version.trim().replace(/^v/i, '');
|
||||
if (!cleaned) return null;
|
||||
const parts = cleaned
|
||||
.split(/[^0-9]+/)
|
||||
.filter(Boolean)
|
||||
.map((segment) => Number.parseInt(segment, 10))
|
||||
.filter(Number.isFinite);
|
||||
return parts.length ? parts : null;
|
||||
};
|
||||
|
||||
const compareVersions = (latest?: string | null, current?: string | null) => {
|
||||
const latestParts = parseVersionSegments(latest);
|
||||
const currentParts = parseVersionSegments(current);
|
||||
if (!latestParts || !currentParts) return null;
|
||||
const length = Math.max(latestParts.length, currentParts.length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
const l = latestParts[i] || 0;
|
||||
const c = currentParts[i] || 0;
|
||||
if (l > c) return 1;
|
||||
if (l < c) return -1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export function MainLayout() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const location = useLocation();
|
||||
|
||||
const apiBase = useAuthStore((state) => state.apiBase);
|
||||
const serverVersion = useAuthStore((state) => state.serverVersion);
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
|
||||
@@ -232,7 +222,6 @@ export function MainLayout() {
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [checkingVersion, setCheckingVersion] = useState(false);
|
||||
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
|
||||
const [themeMenuOpen, setThemeMenuOpen] = useState(false);
|
||||
const [brandExpanded, setBrandExpanded] = useState(true);
|
||||
@@ -420,7 +409,6 @@ export function MainLayout() {
|
||||
});
|
||||
}, [fetchConfig]);
|
||||
|
||||
|
||||
const statusClass =
|
||||
connectionStatus === 'connected'
|
||||
? 'success'
|
||||
@@ -503,7 +491,7 @@ export function MainLayout() {
|
||||
clearCache();
|
||||
const results = await Promise.allSettled([
|
||||
fetchConfig(undefined, true),
|
||||
triggerHeaderRefresh()
|
||||
triggerHeaderRefresh(),
|
||||
]);
|
||||
const rejected = results.find((result) => result.status === 'rejected');
|
||||
if (rejected && rejected.status === 'rejected') {
|
||||
@@ -519,39 +507,6 @@ export function MainLayout() {
|
||||
showNotification(t('notification.data_refreshed'), 'success');
|
||||
};
|
||||
|
||||
const handleVersionCheck = async () => {
|
||||
setCheckingVersion(true);
|
||||
try {
|
||||
const data = await versionApi.checkLatest();
|
||||
const latestRaw = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
|
||||
const latest = typeof latestRaw === 'string' ? latestRaw : String(latestRaw ?? '');
|
||||
const comparison = compareVersions(latest, serverVersion);
|
||||
|
||||
if (!latest) {
|
||||
showNotification(t('system_info.version_check_error'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (comparison === null) {
|
||||
showNotification(t('system_info.version_current_missing'), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (comparison > 0) {
|
||||
showNotification(t('system_info.version_update_available', { version: latest }), 'warning');
|
||||
} else {
|
||||
showNotification(t('system_info.version_is_latest'), 'success');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : typeof error === 'string' ? error : '';
|
||||
const suffix = message ? `: ${message}` : '';
|
||||
showNotification(`${t('system_info.version_check_error')}${suffix}`, 'error');
|
||||
} finally {
|
||||
setCheckingVersion(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="main-header" ref={headerRef}>
|
||||
@@ -609,16 +564,10 @@ export function MainLayout() {
|
||||
>
|
||||
{headerIcons.refresh}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleVersionCheck}
|
||||
loading={checkingVersion}
|
||||
title={t('system_info.version_check_button')}
|
||||
<div
|
||||
className={`language-menu ${languageMenuOpen ? 'open' : ''}`}
|
||||
ref={languageMenuRef}
|
||||
>
|
||||
{headerIcons.update}
|
||||
</Button>
|
||||
<div className={`language-menu ${languageMenuOpen ? 'open' : ''}`} ref={languageMenuRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -631,7 +580,11 @@ export function MainLayout() {
|
||||
{headerIcons.language}
|
||||
</Button>
|
||||
{languageMenuOpen && (
|
||||
<div className="notification entering language-menu-popover" role="menu" aria-label={t('language.switch')}>
|
||||
<div
|
||||
className="notification entering language-menu-popover"
|
||||
role="menu"
|
||||
aria-label={t('language.switch')}
|
||||
>
|
||||
{LANGUAGE_ORDER.map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
@@ -667,7 +620,11 @@ export function MainLayout() {
|
||||
: headerIcons.sun}
|
||||
</Button>
|
||||
{themeMenuOpen && (
|
||||
<div className="notification entering theme-menu-popover" role="menu" aria-label={t('theme.switch')}>
|
||||
<div
|
||||
className="notification entering theme-menu-popover"
|
||||
role="menu"
|
||||
aria-label={t('theme.switch')}
|
||||
>
|
||||
{THEME_CARDS.map((tc) => (
|
||||
<button
|
||||
key={tc.key}
|
||||
@@ -679,7 +636,10 @@ export function MainLayout() {
|
||||
>
|
||||
<div
|
||||
className="theme-card-preview"
|
||||
style={{ background: tc.colors.bg, border: `1px solid ${tc.colors.border}` }}
|
||||
style={{
|
||||
background: tc.colors.bg,
|
||||
border: `1px solid ${tc.colors.border}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="theme-card-header"
|
||||
@@ -722,6 +682,15 @@ export function MainLayout() {
|
||||
</header>
|
||||
|
||||
<div className="main-body">
|
||||
<button
|
||||
type="button"
|
||||
className={`sidebar-backdrop ${sidebarOpen ? 'visible' : ''}`}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-label={t('common.close')}
|
||||
aria-hidden={!sidebarOpen}
|
||||
tabIndex={sidebarOpen ? 0 : -1}
|
||||
/>
|
||||
|
||||
<aside
|
||||
className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}
|
||||
>
|
||||
|
||||
@@ -3,8 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconCodexLight from '@/assets/icons/codex_light.svg';
|
||||
import iconCodexDark from '@/assets/icons/codex_drak.svg';
|
||||
import iconCodex from '@/assets/icons/codex.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import {
|
||||
@@ -25,7 +24,6 @@ interface CodexSectionProps {
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
resolvedTheme: string;
|
||||
onAdd: () => void;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
@@ -39,7 +37,6 @@ export function CodexSection({
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
resolvedTheme,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -72,11 +69,7 @@ export function CodexSection({
|
||||
<Card
|
||||
title={
|
||||
<span className={styles.cardTitle}>
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? iconCodexDark : iconCodexLight}
|
||||
alt=""
|
||||
className={styles.cardTitleIcon}
|
||||
/>
|
||||
<img src={iconCodex} alt="" className={styles.cardTitleIcon} />
|
||||
{t('ai_providers.codex_title')}
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@ import { useThemeStore } from '@/stores';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||
import iconCodexLight from '@/assets/icons/codex_light.svg';
|
||||
import iconCodexDark from '@/assets/icons/codex_drak.svg';
|
||||
import iconCodex from '@/assets/icons/codex.svg';
|
||||
import iconClaude from '@/assets/icons/claude.svg';
|
||||
import iconVertex from '@/assets/icons/vertex.svg';
|
||||
import iconAmp from '@/assets/icons/amp.svg';
|
||||
@@ -23,7 +22,7 @@ interface ProviderNavItem {
|
||||
|
||||
const PROVIDERS: ProviderNavItem[] = [
|
||||
{ id: 'gemini', label: 'Gemini', getIcon: () => iconGemini },
|
||||
{ id: 'codex', label: 'Codex', getIcon: (theme) => (theme === 'dark' ? iconCodexDark : iconCodexLight) },
|
||||
{ id: 'codex', label: 'Codex', getIcon: () => iconCodex },
|
||||
{ id: 'claude', label: 'Claude', getIcon: () => iconClaude },
|
||||
{ id: 'vertex', label: 'Vertex', getIcon: () => iconVertex },
|
||||
{ id: 'ampcode', label: 'Ampcode', getIcon: () => iconAmp },
|
||||
|
||||
@@ -32,6 +32,11 @@ function formatTime(timestamp: number): string {
|
||||
return `${h}:${m}`;
|
||||
}
|
||||
|
||||
function formatSuccessRate(rate: number): string {
|
||||
const rounded = rate.toFixed(1);
|
||||
return `${rounded.endsWith('.0') ? rounded.slice(0, -2) : rounded}%`;
|
||||
}
|
||||
|
||||
type StylesModule = Record<string, string>;
|
||||
|
||||
interface ProviderStatusBarProps {
|
||||
@@ -138,7 +143,7 @@ export function ProviderStatusBar({ statusData, styles: stylesProp }: ProviderSt
|
||||
})}
|
||||
</div>
|
||||
<span className={`${s.statusRate} ${rateClass}`}>
|
||||
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
|
||||
{hasData ? formatSuccessRate(statusData.successRate) : '--'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,13 +20,17 @@ import type {
|
||||
CodexUsageWindow,
|
||||
CodexQuotaWindow,
|
||||
CodexUsagePayload,
|
||||
GeminiCliCodeAssistPayload,
|
||||
GeminiCliCredits,
|
||||
GeminiCliParsedBucket,
|
||||
GeminiCliQuotaBucketState,
|
||||
GeminiCliQuotaState,
|
||||
GeminiCliUserTier,
|
||||
KimiQuotaRow,
|
||||
KimiQuotaState,
|
||||
} from '@/types';
|
||||
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
|
||||
import { useQuotaStore } from '@/stores';
|
||||
import {
|
||||
ANTIGRAVITY_QUOTA_URLS,
|
||||
ANTIGRAVITY_REQUEST_HEADERS,
|
||||
@@ -37,6 +41,7 @@ import {
|
||||
CODEX_USAGE_URL,
|
||||
CODEX_REQUEST_HEADERS,
|
||||
GEMINI_CLI_QUOTA_URL,
|
||||
GEMINI_CLI_CODE_ASSIST_URL,
|
||||
GEMINI_CLI_REQUEST_HEADERS,
|
||||
KIMI_USAGE_URL,
|
||||
KIMI_REQUEST_HEADERS,
|
||||
@@ -49,6 +54,7 @@ import {
|
||||
parseClaudeUsagePayload,
|
||||
parseCodexUsagePayload,
|
||||
parseGeminiCliQuotaPayload,
|
||||
parseGeminiCliCodeAssistPayload,
|
||||
parseKimiUsagePayload,
|
||||
resolveCodexChatgptAccountId,
|
||||
resolveCodexPlanType,
|
||||
@@ -78,6 +84,11 @@ type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||
type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli' | 'kimi';
|
||||
|
||||
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
|
||||
const geminiCliSupplementaryRequestIds = new Map<string, number>();
|
||||
const geminiCliSupplementaryCache = new Map<
|
||||
string,
|
||||
{ requestId: number; tierLabel: string | null; tierId: string | null; creditBalance: number | null }
|
||||
>();
|
||||
|
||||
export interface QuotaStore {
|
||||
antigravityQuota: Record<string, AntigravityQuotaState>;
|
||||
@@ -427,10 +438,181 @@ const fetchCodexQuota = async (
|
||||
return { planType: planTypeFromUsage ?? planTypeFromFile, windows };
|
||||
};
|
||||
|
||||
const GEMINI_CLI_G1_CREDIT_TYPE = 'GOOGLE_ONE_AI';
|
||||
|
||||
const GEMINI_CLI_TIER_LABELS: Record<string, string> = {
|
||||
'free-tier': 'tier_free',
|
||||
'legacy-tier': 'tier_legacy',
|
||||
'standard-tier': 'tier_standard',
|
||||
'g1-pro-tier': 'tier_pro',
|
||||
'g1-ultra-tier': 'tier_ultra',
|
||||
};
|
||||
|
||||
const resolveGeminiCliTierLabel = (
|
||||
payload: GeminiCliCodeAssistPayload | null,
|
||||
t: TFunction
|
||||
): string | null => {
|
||||
if (!payload) return null;
|
||||
const currentTier: GeminiCliUserTier | null | undefined =
|
||||
payload.currentTier ?? payload.current_tier;
|
||||
const paidTier: GeminiCliUserTier | null | undefined =
|
||||
payload.paidTier ?? payload.paid_tier;
|
||||
const rawId = normalizeStringValue(paidTier?.id) ?? normalizeStringValue(currentTier?.id);
|
||||
if (!rawId) return null;
|
||||
const tierId = rawId.toLowerCase();
|
||||
const labelKey = GEMINI_CLI_TIER_LABELS[tierId];
|
||||
return labelKey ? t(`gemini_cli_quota.${labelKey}`) : rawId;
|
||||
};
|
||||
|
||||
const resolveGeminiCliTierId = (
|
||||
payload: GeminiCliCodeAssistPayload | null
|
||||
): string | null => {
|
||||
if (!payload) return null;
|
||||
const currentTier: GeminiCliUserTier | null | undefined =
|
||||
payload.currentTier ?? payload.current_tier;
|
||||
const paidTier: GeminiCliUserTier | null | undefined =
|
||||
payload.paidTier ?? payload.paid_tier;
|
||||
const rawId = normalizeStringValue(paidTier?.id) ?? normalizeStringValue(currentTier?.id);
|
||||
return rawId ? rawId.toLowerCase() : null;
|
||||
};
|
||||
|
||||
const resolveGeminiCliCreditBalance = (
|
||||
payload: GeminiCliCodeAssistPayload | null
|
||||
): number | null => {
|
||||
if (!payload) return null;
|
||||
const paidTier: GeminiCliUserTier | null | undefined =
|
||||
payload.paidTier ?? payload.paid_tier;
|
||||
const currentTier: GeminiCliUserTier | null | undefined =
|
||||
payload.currentTier ?? payload.current_tier;
|
||||
const tier = paidTier ?? currentTier;
|
||||
if (!tier) return null;
|
||||
const credits: GeminiCliCredits[] =
|
||||
tier.availableCredits ?? tier.available_credits ?? [];
|
||||
let total = 0;
|
||||
let found = false;
|
||||
for (const credit of credits) {
|
||||
const creditType = normalizeStringValue(credit.creditType ?? credit.credit_type);
|
||||
if (creditType !== GEMINI_CLI_G1_CREDIT_TYPE) continue;
|
||||
const amount = normalizeNumberValue(credit.creditAmount ?? credit.credit_amount);
|
||||
if (amount !== null) {
|
||||
total += amount;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
return found ? total : null;
|
||||
};
|
||||
|
||||
const fetchGeminiCliCodeAssist = async (
|
||||
authIndex: string,
|
||||
projectId: string,
|
||||
t: TFunction
|
||||
): Promise<{ tierLabel: string | null; tierId: string | null; creditBalance: number | null }> => {
|
||||
try {
|
||||
const result = await apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'POST',
|
||||
url: GEMINI_CLI_CODE_ASSIST_URL,
|
||||
header: { ...GEMINI_CLI_REQUEST_HEADERS },
|
||||
data: JSON.stringify({
|
||||
cloudaicompanionProject: projectId,
|
||||
metadata: {
|
||||
ideType: 'IDE_UNSPECIFIED',
|
||||
platform: 'PLATFORM_UNSPECIFIED',
|
||||
pluginType: 'GEMINI',
|
||||
duetProject: projectId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
return { tierLabel: null, tierId: null, creditBalance: null };
|
||||
}
|
||||
|
||||
const payload = parseGeminiCliCodeAssistPayload(result.body ?? result.bodyText);
|
||||
return {
|
||||
tierLabel: resolveGeminiCliTierLabel(payload, t),
|
||||
tierId: resolveGeminiCliTierId(payload),
|
||||
creditBalance: resolveGeminiCliCreditBalance(payload),
|
||||
};
|
||||
} catch {
|
||||
return { tierLabel: null, tierId: null, creditBalance: null };
|
||||
}
|
||||
};
|
||||
|
||||
const readGeminiCliSupplementarySnapshot = (
|
||||
fileName: string,
|
||||
requestId: number
|
||||
): { tierLabel: string | null; tierId: string | null; creditBalance: number | null } => {
|
||||
const cached = geminiCliSupplementaryCache.get(fileName);
|
||||
if (!cached || cached.requestId !== requestId) {
|
||||
return { tierLabel: null, tierId: null, creditBalance: null };
|
||||
}
|
||||
|
||||
return {
|
||||
tierLabel: cached.tierLabel,
|
||||
tierId: cached.tierId,
|
||||
creditBalance: cached.creditBalance,
|
||||
};
|
||||
};
|
||||
|
||||
const scheduleGeminiCliSupplementaryRefresh = (
|
||||
fileName: string,
|
||||
authIndex: string,
|
||||
projectId: string,
|
||||
t: TFunction
|
||||
): number => {
|
||||
const requestId = (geminiCliSupplementaryRequestIds.get(fileName) ?? 0) + 1;
|
||||
geminiCliSupplementaryRequestIds.set(fileName, requestId);
|
||||
geminiCliSupplementaryCache.delete(fileName);
|
||||
|
||||
void (async () => {
|
||||
const supplementary = await fetchGeminiCliCodeAssist(authIndex, projectId, t);
|
||||
if (geminiCliSupplementaryRequestIds.get(fileName) !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
geminiCliSupplementaryCache.set(fileName, { requestId, ...supplementary });
|
||||
|
||||
useQuotaStore.getState().setGeminiCliQuota((prev) => {
|
||||
const current = prev[fileName];
|
||||
if (!current || current.status !== 'success') {
|
||||
return prev;
|
||||
}
|
||||
|
||||
if (
|
||||
current.tierLabel === supplementary.tierLabel &&
|
||||
current.tierId === supplementary.tierId &&
|
||||
current.creditBalance === supplementary.creditBalance
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[fileName]: {
|
||||
...current,
|
||||
tierLabel: supplementary.tierLabel,
|
||||
tierId: supplementary.tierId,
|
||||
creditBalance: supplementary.creditBalance,
|
||||
},
|
||||
};
|
||||
});
|
||||
})();
|
||||
|
||||
return requestId;
|
||||
};
|
||||
|
||||
const fetchGeminiCliQuota = async (
|
||||
file: AuthFileItem,
|
||||
t: TFunction
|
||||
): Promise<GeminiCliQuotaBucketState[]> => {
|
||||
): Promise<{
|
||||
fileName: string;
|
||||
supplementaryRequestId: number;
|
||||
buckets: GeminiCliQuotaBucketState[];
|
||||
tierLabel: string | null;
|
||||
tierId: string | null;
|
||||
creditBalance: number | null;
|
||||
}> => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndex = normalizeAuthIndex(rawAuthIndex);
|
||||
if (!authIndex) {
|
||||
@@ -442,21 +624,19 @@ const fetchGeminiCliQuota = async (
|
||||
throw new Error(t('gemini_cli_quota.missing_project_id'));
|
||||
}
|
||||
|
||||
const result = await apiCallApi.request({
|
||||
const quotaResponse = await apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'POST',
|
||||
url: GEMINI_CLI_QUOTA_URL,
|
||||
header: { ...GEMINI_CLI_REQUEST_HEADERS },
|
||||
data: JSON.stringify({ project: projectId }),
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
||||
if (quotaResponse.statusCode < 200 || quotaResponse.statusCode >= 300) {
|
||||
throw createStatusError(getApiCallErrorMessage(quotaResponse), quotaResponse.statusCode);
|
||||
}
|
||||
|
||||
const payload = parseGeminiCliQuotaPayload(result.body ?? result.bodyText);
|
||||
const payload = parseGeminiCliQuotaPayload(quotaResponse.body ?? quotaResponse.bodyText);
|
||||
const buckets = Array.isArray(payload?.buckets) ? payload?.buckets : [];
|
||||
if (buckets.length === 0) return [];
|
||||
|
||||
const parsedBuckets = buckets
|
||||
.map((bucket) => {
|
||||
@@ -487,7 +667,26 @@ const fetchGeminiCliQuota = async (
|
||||
})
|
||||
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
|
||||
|
||||
return buildGeminiCliQuotaBuckets(parsedBuckets);
|
||||
const builtBuckets = buildGeminiCliQuotaBuckets(parsedBuckets);
|
||||
const supplementaryRequestId = scheduleGeminiCliSupplementaryRefresh(
|
||||
file.name,
|
||||
authIndex,
|
||||
projectId,
|
||||
t
|
||||
);
|
||||
const supplementarySnapshot = readGeminiCliSupplementarySnapshot(
|
||||
file.name,
|
||||
supplementaryRequestId
|
||||
);
|
||||
|
||||
return {
|
||||
fileName: file.name,
|
||||
supplementaryRequestId,
|
||||
buckets: builtBuckets,
|
||||
tierLabel: supplementarySnapshot.tierLabel,
|
||||
tierId: supplementarySnapshot.tierId,
|
||||
creditBalance: supplementarySnapshot.creditBalance,
|
||||
};
|
||||
};
|
||||
|
||||
const renderAntigravityItems = (
|
||||
@@ -527,6 +726,8 @@ const renderAntigravityItems = (
|
||||
});
|
||||
};
|
||||
|
||||
const PREMIUM_GEMINI_CLI_TIER_IDS = new Set(['g1-ultra-tier']);
|
||||
|
||||
const renderCodexItems = (
|
||||
quota: CodexQuotaState,
|
||||
t: TFunction,
|
||||
@@ -540,6 +741,7 @@ const renderCodexItems = (
|
||||
const getPlanLabel = (pt?: string | null): string | null => {
|
||||
const normalized = normalizePlanType(pt);
|
||||
if (!normalized) return null;
|
||||
if (normalized === 'pro') return t('codex_quota.plan_pro');
|
||||
if (normalized === 'plus') return t('codex_quota.plan_plus');
|
||||
if (normalized === 'team') return t('codex_quota.plan_team');
|
||||
if (normalized === 'free') return t('codex_quota.plan_free');
|
||||
@@ -547,15 +749,17 @@ const renderCodexItems = (
|
||||
};
|
||||
|
||||
const planLabel = getPlanLabel(planType);
|
||||
const isPremiumPlan = normalizePlanType(planType) === 'pro';
|
||||
const nodes: ReactNode[] = [];
|
||||
|
||||
if (planLabel) {
|
||||
const valueClass = isPremiumPlan ? styleMap.premiumPlanValue : styleMap.codexPlanValue;
|
||||
nodes.push(
|
||||
h(
|
||||
'div',
|
||||
{ key: 'plan', className: styleMap.codexPlan },
|
||||
h('span', { className: styleMap.codexPlanLabel }, t('codex_quota.plan_label')),
|
||||
h('span', { className: styleMap.codexPlanValue }, planLabel)
|
||||
h('span', { className: valueClass }, planLabel)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -605,50 +809,89 @@ const renderGeminiCliItems = (
|
||||
helpers: QuotaRenderHelpers
|
||||
): ReactNode => {
|
||||
const { styles: styleMap, QuotaProgressBar } = helpers;
|
||||
const { createElement: h } = React;
|
||||
const { createElement: h, Fragment } = React;
|
||||
const buckets = quota.buckets ?? [];
|
||||
const tierLabel = quota.tierLabel ?? null;
|
||||
const tierId = quota.tierId ?? null;
|
||||
const creditBalance = quota.creditBalance ?? null;
|
||||
const isPremiumTier = tierId !== null && PREMIUM_GEMINI_CLI_TIER_IDS.has(tierId);
|
||||
const nodes: ReactNode[] = [];
|
||||
|
||||
if (buckets.length === 0) {
|
||||
return h('div', { className: styleMap.quotaMessage }, t('gemini_cli_quota.empty_buckets'));
|
||||
}
|
||||
|
||||
return buckets.map((bucket) => {
|
||||
const fraction = bucket.remainingFraction;
|
||||
const clamped = fraction === null ? null : Math.max(0, Math.min(1, fraction));
|
||||
const percent = clamped === null ? null : Math.round(clamped * 100);
|
||||
const percentLabel = percent === null ? '--' : `${percent}%`;
|
||||
const remainingAmountLabel =
|
||||
bucket.remainingAmount === null || bucket.remainingAmount === undefined
|
||||
? null
|
||||
: t('gemini_cli_quota.remaining_amount', {
|
||||
count: bucket.remainingAmount,
|
||||
});
|
||||
const titleBase =
|
||||
bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label;
|
||||
const title = bucket.tokenType ? `${titleBase} (${bucket.tokenType})` : titleBase;
|
||||
|
||||
const resetLabel = formatQuotaResetTime(bucket.resetTime);
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ key: bucket.id, className: styleMap.quotaRow },
|
||||
if (tierLabel) {
|
||||
const valueClass = isPremiumTier ? styleMap.premiumPlanValue : styleMap.codexPlanValue;
|
||||
nodes.push(
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaRowHeader },
|
||||
h('span', { className: styleMap.quotaModel, title }, bucket.label),
|
||||
{ key: 'tier', className: styleMap.codexPlan },
|
||||
h('span', { className: styleMap.codexPlanLabel }, t('gemini_cli_quota.tier_label')),
|
||||
h('span', { className: valueClass }, tierLabel)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (creditBalance !== null) {
|
||||
nodes.push(
|
||||
h(
|
||||
'div',
|
||||
{ key: 'credits', className: styleMap.codexPlan },
|
||||
h('span', { className: styleMap.codexPlanLabel }, t('gemini_cli_quota.credit_label')),
|
||||
h(
|
||||
'span',
|
||||
{ className: styleMap.codexPlanValue },
|
||||
t('gemini_cli_quota.credit_amount', { count: creditBalance })
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (buckets.length === 0) {
|
||||
nodes.push(
|
||||
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('gemini_cli_quota.empty_buckets'))
|
||||
);
|
||||
return h(Fragment, null, ...nodes);
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
...buckets.map((bucket) => {
|
||||
const fraction = bucket.remainingFraction;
|
||||
const clamped = fraction === null ? null : Math.max(0, Math.min(1, fraction));
|
||||
const percent = clamped === null ? null : Math.round(clamped * 100);
|
||||
const percentLabel = percent === null ? '--' : `${percent}%`;
|
||||
const remainingAmountLabel =
|
||||
bucket.remainingAmount === null || bucket.remainingAmount === undefined
|
||||
? null
|
||||
: t('gemini_cli_quota.remaining_amount', {
|
||||
count: bucket.remainingAmount,
|
||||
});
|
||||
const titleBase =
|
||||
bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label;
|
||||
const title = bucket.tokenType ? `${titleBase} (${bucket.tokenType})` : titleBase;
|
||||
|
||||
const resetLabel = formatQuotaResetTime(bucket.resetTime);
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ key: bucket.id, className: styleMap.quotaRow },
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaMeta },
|
||||
h('span', { className: styleMap.quotaPercent }, percentLabel),
|
||||
remainingAmountLabel
|
||||
? h('span', { className: styleMap.quotaAmount }, remainingAmountLabel)
|
||||
: null,
|
||||
h('span', { className: styleMap.quotaReset }, resetLabel)
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
|
||||
);
|
||||
});
|
||||
{ className: styleMap.quotaRowHeader },
|
||||
h('span', { className: styleMap.quotaModel, title }, bucket.label),
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaMeta },
|
||||
h('span', { className: styleMap.quotaPercent }, percentLabel),
|
||||
remainingAmountLabel
|
||||
? h('span', { className: styleMap.quotaAmount }, remainingAmountLabel)
|
||||
: null,
|
||||
h('span', { className: styleMap.quotaReset }, resetLabel)
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
return h(Fragment, null, ...nodes);
|
||||
};
|
||||
|
||||
const buildClaudeQuotaWindows = (
|
||||
@@ -927,7 +1170,17 @@ export const CODEX_CONFIG: QuotaConfig<
|
||||
renderQuotaItems: renderCodexItems,
|
||||
};
|
||||
|
||||
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
|
||||
export const GEMINI_CLI_CONFIG: QuotaConfig<
|
||||
GeminiCliQuotaState,
|
||||
{
|
||||
fileName: string;
|
||||
supplementaryRequestId: number;
|
||||
buckets: GeminiCliQuotaBucketState[];
|
||||
tierLabel: string | null;
|
||||
tierId: string | null;
|
||||
creditBalance: number | null;
|
||||
}
|
||||
> = {
|
||||
type: 'gemini-cli',
|
||||
i18nPrefix: 'gemini_cli_quota',
|
||||
cardIdleMessageKey: 'quota_management.card_idle_hint',
|
||||
@@ -936,8 +1189,21 @@ export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaB
|
||||
fetchQuota: fetchGeminiCliQuota,
|
||||
storeSelector: (state) => state.geminiCliQuota,
|
||||
storeSetter: 'setGeminiCliQuota',
|
||||
buildLoadingState: () => ({ status: 'loading', buckets: [] }),
|
||||
buildSuccessState: (buckets) => ({ status: 'success', buckets }),
|
||||
buildLoadingState: () => ({ status: 'loading', buckets: [], tierLabel: null, tierId: null, creditBalance: null }),
|
||||
buildSuccessState: (data) => {
|
||||
const supplementarySnapshot = readGeminiCliSupplementarySnapshot(
|
||||
data.fileName,
|
||||
data.supplementaryRequestId
|
||||
);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
buckets: data.buckets,
|
||||
tierLabel: supplementarySnapshot.tierLabel ?? data.tierLabel,
|
||||
tierId: supplementarySnapshot.tierId ?? data.tierId,
|
||||
creditBalance: supplementarySnapshot.creditBalance ?? data.creditBalance,
|
||||
};
|
||||
},
|
||||
buildErrorState: (message, status) => ({
|
||||
status: 'error',
|
||||
buckets: [],
|
||||
|
||||
+156
-1
@@ -16,7 +16,15 @@ const baseSvgProps: SVGProps<SVGSVGElement> = {
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
'aria-hidden': 'true',
|
||||
focusable: 'false'
|
||||
focusable: 'false',
|
||||
};
|
||||
|
||||
const sidebarSvgProps: SVGProps<SVGSVGElement> = {
|
||||
...baseSvgProps,
|
||||
strokeWidth: 1.72,
|
||||
strokeLinecap: 'square',
|
||||
strokeLinejoin: 'miter',
|
||||
strokeMiterlimit: 10,
|
||||
};
|
||||
|
||||
export function IconSlidersHorizontal({ size = 20, ...props }: IconProps) {
|
||||
@@ -58,6 +66,36 @@ export function IconBot({ size = 20, ...props }: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function IconModelCluster({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<rect x="3" y="5" width="6" height="6" rx="1.5" />
|
||||
<rect x="15" y="5" width="6" height="6" rx="1.5" />
|
||||
<rect x="9" y="13" width="6" height="6" rx="1.5" />
|
||||
<path d="M9 8h6" />
|
||||
<path d="M12 11v2" />
|
||||
<path d="M7.5 11v2" />
|
||||
<path d="M16.5 11v2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconFilterAll({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<rect x="3.5" y="3.5" width="5" height="5" rx="1.4" />
|
||||
<rect x="15.5" y="3.5" width="5" height="5" rx="1.4" />
|
||||
<rect x="3.5" y="15.5" width="5" height="5" rx="1.4" />
|
||||
<rect x="15.5" y="15.5" width="5" height="5" rx="1.4" />
|
||||
<path d="M8.5 8.5 10.75 10.75" />
|
||||
<path d="M15.5 8.5 13.25 10.75" />
|
||||
<path d="M8.5 15.5 10.75 13.25" />
|
||||
<path d="M15.5 15.5 13.25 13.25" />
|
||||
<circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconFileText({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
@@ -322,3 +360,120 @@ export function IconLayoutDashboard({ size = 20, ...props }: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSidebarDashboard({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
|
||||
<rect x="3" y="3" width="7.5" height="8" rx="1.5" />
|
||||
<rect x="13.5" y="3" width="7.5" height="5" rx="1.5" fill="currentColor" fillOpacity="0.12" />
|
||||
<rect x="3" y="14" width="7.5" height="7" rx="1.5" fill="currentColor" fillOpacity="0.12" />
|
||||
<rect x="13.5" y="11" width="7.5" height="10" rx="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSidebarConfig({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M4 8h16" />
|
||||
<path d="M4 16h16" />
|
||||
<circle cx="9.5" cy="8" r="2.8" fill="currentColor" fillOpacity="0.12" />
|
||||
<circle cx="15" cy="16" r="2.8" fill="currentColor" fillOpacity="0.12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSidebarProviders({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
|
||||
<circle cx="12" cy="5.5" r="2.8" fill="currentColor" fillOpacity="0.12" />
|
||||
<circle cx="5.5" cy="18.5" r="2.8" />
|
||||
<circle cx="18.5" cy="18.5" r="2.8" />
|
||||
<path d="M10.2 7.8 7 16.2" />
|
||||
<path d="M13.8 7.8 17 16.2" />
|
||||
<path d="M8.3 18.5h7.4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSidebarAuthFiles({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M7 3h7l4 4v12a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z" />
|
||||
<path d="M14 3v4h4" fill="currentColor" fillOpacity="0.12" />
|
||||
<path d="M9 13l2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSidebarOauth({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
|
||||
<path
|
||||
d="M12 3l8 4v5c0 5.25-3.4 8.25-8 10-4.6-1.75-8-4.75-8-10V7Z"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.08"
|
||||
/>
|
||||
<circle cx="12" cy="11" r="1.5" fill="currentColor" stroke="none" />
|
||||
<path d="M12 12.5v2.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSidebarQuota({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
<path d="M12 12V4a8 8 0 0 1 8 8Z" fill="currentColor" fillOpacity="0.12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSidebarUsage({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M3.5 20h17" />
|
||||
<rect x="5" y="13" width="3.5" height="7" rx="0.5" />
|
||||
<rect
|
||||
x="10.25"
|
||||
y="7"
|
||||
width="3.5"
|
||||
height="13"
|
||||
rx="0.5"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.12"
|
||||
/>
|
||||
<rect x="15.5" y="10" width="3.5" height="10" rx="0.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSidebarLogs({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<path d="M3 8.5h18" />
|
||||
<circle cx="5.5" cy="6.2" r="0.8" fill="currentColor" stroke="none" />
|
||||
<circle cx="7.8" cy="6.2" r="0.8" fill="currentColor" fillOpacity="0.4" stroke="none" />
|
||||
<path d="M7 12l3 2.5-3 2.5" />
|
||||
<path d="M13 17h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSidebarSystem({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||
<rect x="9" y="9" width="6" height="6" rx="1" fill="currentColor" fillOpacity="0.12" />
|
||||
<path d="M6 10H3" />
|
||||
<path d="M6 14H3" />
|
||||
<path d="M21 10h-3" />
|
||||
<path d="M21 14h-3" />
|
||||
<path d="M10 6V3" />
|
||||
<path d="M14 6V3" />
|
||||
<path d="M10 21v-3" />
|
||||
<path d="M14 21v-3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import {
|
||||
IconBot,
|
||||
IconCode,
|
||||
IconDownload,
|
||||
IconInfo,
|
||||
IconModelCluster,
|
||||
IconSettings,
|
||||
IconTrash2,
|
||||
} from '@/components/ui/icons';
|
||||
import { ProviderStatusBar } from '@/components/providers/ProviderStatusBar';
|
||||
@@ -18,10 +18,12 @@ import { formatFileSize } from '@/utils/format';
|
||||
import {
|
||||
QUOTA_PROVIDER_TYPES,
|
||||
formatModified,
|
||||
getAuthFileIcon,
|
||||
getAuthFileStatusMessage,
|
||||
getTypeColor,
|
||||
getTypeLabel,
|
||||
isRuntimeOnlyAuthFile,
|
||||
parsePriorityValue,
|
||||
resolveAuthFileStats,
|
||||
type QuotaProviderType,
|
||||
type ResolvedTheme,
|
||||
@@ -34,6 +36,7 @@ const HEALTHY_STATUS_MESSAGES = new Set(['ok', 'healthy', 'ready', 'success', 'a
|
||||
|
||||
export type AuthFileCardProps = {
|
||||
file: AuthFileItem;
|
||||
compact: boolean;
|
||||
selected: boolean;
|
||||
resolvedTheme: ResolvedTheme;
|
||||
disableControls: boolean;
|
||||
@@ -43,7 +46,6 @@ export type AuthFileCardProps = {
|
||||
keyStats: KeyStats;
|
||||
statusBarCache: Map<string, AuthFileStatusBarData>;
|
||||
onShowModels: (file: AuthFileItem) => void;
|
||||
onShowDetails: (file: AuthFileItem) => void;
|
||||
onDownload: (name: string) => void;
|
||||
onOpenPrefixProxyEditor: (file: AuthFileItem) => void;
|
||||
onDelete: (name: string) => void;
|
||||
@@ -61,6 +63,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
file,
|
||||
compact,
|
||||
selected,
|
||||
resolvedTheme,
|
||||
disableControls,
|
||||
@@ -70,7 +73,6 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
keyStats,
|
||||
statusBarCache,
|
||||
onShowModels,
|
||||
onShowDetails,
|
||||
onDownload,
|
||||
onOpenPrefixProxyEditor,
|
||||
onDelete,
|
||||
@@ -83,24 +85,26 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
const isAistudio = (file.type || '').toLowerCase() === 'aistudio';
|
||||
const showModelsButton = !isRuntimeOnly || isAistudio;
|
||||
const typeColor = getTypeColor(file.type || 'unknown', resolvedTheme);
|
||||
const typeLabel = getTypeLabel(t, file.type || 'unknown');
|
||||
const providerIcon = getAuthFileIcon(file.type || 'unknown', resolvedTheme);
|
||||
|
||||
const quotaType =
|
||||
quotaFilterType && resolveQuotaType(file) === quotaFilterType ? quotaFilterType : null;
|
||||
|
||||
const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly;
|
||||
const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly && !compact;
|
||||
|
||||
const providerCardClass =
|
||||
quotaType === 'antigravity'
|
||||
? styles.antigravityCard
|
||||
: quotaType === 'claude'
|
||||
? styles.claudeCard
|
||||
: quotaType === 'codex'
|
||||
? styles.codexCard
|
||||
: quotaType === 'gemini-cli'
|
||||
? styles.geminiCliCard
|
||||
: quotaType === 'kimi'
|
||||
? styles.kimiCard
|
||||
: '';
|
||||
: quotaType === 'codex'
|
||||
? styles.codexCard
|
||||
: quotaType === 'gemini-cli'
|
||||
? styles.geminiCliCard
|
||||
: quotaType === 'kimi'
|
||||
? styles.kimiCard
|
||||
: '';
|
||||
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndexKey = normalizeAuthIndex(rawAuthIndex);
|
||||
@@ -110,9 +114,28 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
const hasStatusWarning =
|
||||
Boolean(rawStatusMessage) && !HEALTHY_STATUS_MESSAGES.has(rawStatusMessage.toLowerCase());
|
||||
|
||||
const priorityValue = parsePriorityValue(file.priority ?? file['priority']);
|
||||
const noteValue = typeof file.note === 'string' ? file.note.trim() : '';
|
||||
const stateLabel = isRuntimeOnly
|
||||
? t('auth_files.type_virtual') || '虚拟认证文件'
|
||||
: file.disabled
|
||||
? t('auth_files.health_status_disabled')
|
||||
: hasStatusWarning
|
||||
? t('auth_files.health_status_warning')
|
||||
: rawStatusMessage
|
||||
? t('auth_files.health_status_healthy')
|
||||
: t('auth_files.status_toggle_label');
|
||||
const stateBadgeClass = isRuntimeOnly
|
||||
? styles.stateBadgeVirtual
|
||||
: file.disabled
|
||||
? styles.stateBadgeDisabled
|
||||
: hasStatusWarning
|
||||
? styles.stateBadgeWarning
|
||||
: styles.stateBadgeActive;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.fileCard} ${providerCardClass} ${selected ? styles.fileCardSelected : ''} ${file.disabled ? styles.fileCardDisabled : ''}`}
|
||||
className={`${styles.fileCard} ${compact ? styles.fileCardCompact : ''} ${providerCardClass} ${selected ? styles.fileCardSelected : ''} ${file.disabled ? styles.fileCardDisabled : ''}`}
|
||||
>
|
||||
<div className={styles.fileCardLayout}>
|
||||
<div className={styles.fileCardMain}>
|
||||
@@ -121,122 +144,176 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
<SelectionCheckbox
|
||||
checked={selected}
|
||||
onChange={() => onToggleSelect(file.name)}
|
||||
className={styles.cardSelection}
|
||||
aria-label={
|
||||
selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')
|
||||
}
|
||||
title={selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={styles.typeBadge}
|
||||
<div
|
||||
className={styles.providerAvatar}
|
||||
style={{
|
||||
backgroundColor: typeColor.bg,
|
||||
color: typeColor.text,
|
||||
...(typeColor.border ? { border: typeColor.border } : {}),
|
||||
}}
|
||||
>
|
||||
{getTypeLabel(t, file.type || 'unknown')}
|
||||
</span>
|
||||
<span className={styles.fileName}>{file.name}</span>
|
||||
{providerIcon ? (
|
||||
<img src={providerIcon} alt="" className={styles.providerAvatarImage} />
|
||||
) : (
|
||||
<span className={styles.providerAvatarFallback}>
|
||||
{typeLabel.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.cardHeaderContent}>
|
||||
<div className={styles.cardBadgeRow}>
|
||||
<span
|
||||
className={styles.typeBadge}
|
||||
style={{
|
||||
backgroundColor: typeColor.bg,
|
||||
color: typeColor.text,
|
||||
...(typeColor.border ? { border: typeColor.border } : {}),
|
||||
}}
|
||||
>
|
||||
{typeLabel}
|
||||
</span>
|
||||
<span className={`${styles.stateBadge} ${stateBadgeClass}`}>{stateLabel}</span>
|
||||
</div>
|
||||
<span className={styles.fileName} title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
{!compact && noteValue && (
|
||||
<div className={styles.noteText} title={noteValue}>
|
||||
<span className={styles.noteLabel}>{t('auth_files.note_display')}</span>
|
||||
<span className={styles.noteValue}>{noteValue}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.cardMeta}>
|
||||
<span>
|
||||
{t('auth_files.file_size')}: {file.size ? formatFileSize(file.size) : '-'}
|
||||
</span>
|
||||
<span>
|
||||
{t('auth_files.file_modified')}: {formatModified(file)}
|
||||
</span>
|
||||
<div className={`${styles.cardMeta} ${compact ? styles.cardMetaCompact : ''}`}>
|
||||
<div className={styles.metaItem}>
|
||||
<span className={styles.metaLabel}>{t('auth_files.file_size')}</span>
|
||||
<span className={styles.metaValue}>
|
||||
{file.size ? formatFileSize(file.size) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.metaItem}>
|
||||
<span className={styles.metaLabel}>{t('auth_files.file_modified')}</span>
|
||||
<span className={styles.metaValue}>{formatModified(file)}</span>
|
||||
</div>
|
||||
{priorityValue !== undefined && (
|
||||
<div className={`${styles.metaItem} ${styles.priorityBadge}`}>
|
||||
<span className={styles.metaLabel}>{t('auth_files.priority_display')}</span>
|
||||
<span className={`${styles.metaValue} ${styles.priorityValue}`}>
|
||||
{priorityValue}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rawStatusMessage && hasStatusWarning && (
|
||||
<div className={styles.healthStatusMessage} title={rawStatusMessage}>
|
||||
{rawStatusMessage}
|
||||
<IconInfo className={styles.messageIcon} size={14} />
|
||||
<span>{rawStatusMessage}</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 className={`${styles.cardInsights} ${compact ? styles.cardInsightsCompact : ''}`}>
|
||||
<div className={`${styles.cardStats} ${compact ? styles.cardStatsCompact : ''}`}>
|
||||
<div className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||
<span className={styles.statLabel}>{t('stats.success')}</span>
|
||||
<span className={styles.statValue}>{fileStats.success}</span>
|
||||
</div>
|
||||
<div className={`${styles.statPill} ${styles.statFailure}`}>
|
||||
<span className={styles.statLabel}>{t('stats.failure')}</span>
|
||||
<span className={styles.statValue}>{fileStats.failure}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.statusPanel} ${compact ? styles.statusPanelCompact : ''}`}>
|
||||
<div className={styles.statusPanelLabel}>
|
||||
<span>{t('auth_files.health_status_label')}</span>
|
||||
</div>
|
||||
<ProviderStatusBar statusData={statusData} styles={styles} />
|
||||
</div>
|
||||
|
||||
{showQuotaLayout && quotaType && (
|
||||
<AuthFileQuotaSection
|
||||
file={file}
|
||||
quotaType={quotaType}
|
||||
disableControls={disableControls}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ProviderStatusBar statusData={statusData} styles={styles} />
|
||||
|
||||
{showQuotaLayout && quotaType && (
|
||||
<AuthFileQuotaSection
|
||||
file={file}
|
||||
quotaType={quotaType}
|
||||
disableControls={disableControls}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.cardActions}>
|
||||
{showModelsButton && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onShowModels(file)}
|
||||
className={styles.iconButton}
|
||||
title={t('auth_files.models_button', { defaultValue: '模型' })}
|
||||
disabled={disableControls}
|
||||
>
|
||||
<IconBot className={styles.actionIcon} size={16} />
|
||||
</Button>
|
||||
)}
|
||||
{!isRuntimeOnly && (
|
||||
<>
|
||||
<div className={styles.cardActionsMain}>
|
||||
{showModelsButton && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onShowDetails(file)}
|
||||
className={styles.iconButton}
|
||||
title={t('common.info', { defaultValue: '关于' })}
|
||||
onClick={() => onShowModels(file)}
|
||||
className={`${styles.primaryActionButton} ${styles.modelsActionButton}`}
|
||||
title={t('auth_files.models_button', { defaultValue: '模型' })}
|
||||
disabled={disableControls}
|
||||
>
|
||||
<IconInfo className={styles.actionIcon} size={16} />
|
||||
<>
|
||||
<span className={styles.modelsActionIconWrap}>
|
||||
<IconModelCluster className={styles.actionIcon} size={16} />
|
||||
</span>
|
||||
<span className={styles.actionButtonLabel}>
|
||||
{t('auth_files.models_button', { defaultValue: '模型' })}
|
||||
</span>
|
||||
</>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onDownload(file.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={() => onOpenPrefixProxyEditor(file)}
|
||||
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={() => onDelete(file.name)}
|
||||
className={styles.iconButton}
|
||||
title={t('auth_files.delete_button')}
|
||||
disabled={disableControls || deleting === file.name}
|
||||
>
|
||||
{deleting === file.name ? (
|
||||
<LoadingSpinner size={14} />
|
||||
) : (
|
||||
<IconTrash2 className={styles.actionIcon} size={16} />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
{!isRuntimeOnly && (
|
||||
<div className={styles.cardUtilityActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onDownload(file.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={() => onOpenPrefixProxyEditor(file)}
|
||||
className={styles.iconButton}
|
||||
title={t('auth_files.prefix_proxy_button')}
|
||||
disabled={disableControls}
|
||||
>
|
||||
<IconSettings className={styles.actionIcon} size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => onDelete(file.name)}
|
||||
className={styles.iconButton}
|
||||
title={t('auth_files.delete_button')}
|
||||
disabled={disableControls || deleting === file.name}
|
||||
>
|
||||
{deleting === file.name ? (
|
||||
<LoadingSpinner size={14} />
|
||||
) : (
|
||||
<IconTrash2 className={styles.actionIcon} size={16} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isRuntimeOnly && (
|
||||
<div className={styles.statusToggle}>
|
||||
<span className={styles.statusToggleLabel}>
|
||||
{t('auth_files.status_toggle_label')}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
ariaLabel={t('auth_files.status_toggle_label')}
|
||||
checked={!file.disabled}
|
||||
@@ -245,11 +322,6 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isRuntimeOnly && (
|
||||
<div className={styles.virtualBadge}>
|
||||
{t('auth_files.type_virtual') || '虚拟认证文件'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||
|
||||
export type AuthFileDetailModalProps = {
|
||||
open: boolean;
|
||||
file: AuthFileItem | null;
|
||||
onClose: () => void;
|
||||
onCopyText: (text: string) => void;
|
||||
};
|
||||
|
||||
export function AuthFileDetailModal({ open, file, onClose, onCopyText }: AuthFileDetailModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={file?.name || t('auth_files.title_section')}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!file) return;
|
||||
const text = JSON.stringify(file, null, 2);
|
||||
onCopyText(text);
|
||||
}}
|
||||
>
|
||||
{t('common.copy')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{file && (
|
||||
<div className={styles.detailContent}>
|
||||
<pre className={styles.jsonContent}>{JSON.stringify(file, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,13 +17,24 @@ export type AuthFilesPrefixProxyEditorModalProps = {
|
||||
updatedText: string;
|
||||
dirty: boolean;
|
||||
onClose: () => void;
|
||||
onCopyText: (text: string) => void | Promise<void>;
|
||||
onSave: () => void;
|
||||
onChange: (field: PrefixProxyEditorField, value: PrefixProxyEditorFieldValue) => void;
|
||||
};
|
||||
|
||||
export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEditorModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { disableControls, editor, updatedText, dirty, onClose, onSave, onChange } = props;
|
||||
const { disableControls, editor, updatedText, dirty, onClose, onCopyText, onSave, onChange } =
|
||||
props;
|
||||
const formatJsonText = (text: string) => {
|
||||
if (!text) return '';
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
const previewText = formatJsonText(updatedText);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -39,7 +50,17 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={editor?.saving === true}>
|
||||
{t('common.cancel')}
|
||||
{dirty ? t('common.cancel') : t('common.close')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (!updatedText) return;
|
||||
void onCopyText(updatedText);
|
||||
}}
|
||||
disabled={editor?.saving === true || !updatedText}
|
||||
>
|
||||
{t('common.copy')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSave}
|
||||
@@ -61,6 +82,17 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
|
||||
) : (
|
||||
<>
|
||||
{editor.error && <div className={styles.prefixProxyError}>{editor.error}</div>}
|
||||
<div className={styles.prefixProxyJsonWrapper}>
|
||||
<label className={styles.prefixProxyLabel}>
|
||||
{t('auth_files.prefix_proxy_info_label')}
|
||||
</label>
|
||||
<textarea
|
||||
className={styles.prefixProxyTextarea}
|
||||
rows={8}
|
||||
readOnly
|
||||
value={editor.fileInfoText}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.prefixProxyJsonWrapper}>
|
||||
<label className={styles.prefixProxyLabel}>
|
||||
{t('auth_files.prefix_proxy_source_label')}
|
||||
@@ -69,7 +101,7 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
|
||||
className={styles.prefixProxyTextarea}
|
||||
rows={10}
|
||||
readOnly
|
||||
value={updatedText}
|
||||
value={previewText}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.prefixProxyFields}>
|
||||
@@ -114,14 +146,22 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
|
||||
disabled={disableControls || editor.saving || !editor.json}
|
||||
onChange={(e) => onChange('disableCooling', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t('auth_files.note_label')}
|
||||
value={editor.note}
|
||||
placeholder={t('auth_files.note_placeholder')}
|
||||
hint={t('auth_files.note_hint')}
|
||||
disabled={disableControls || editor.saving || !editor.json}
|
||||
onChange={(e) => onChange('note', e.target.value)}
|
||||
/>
|
||||
{editor.isCodexFile && (
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.codex_websockets_label')}</label>
|
||||
<ToggleSwitch
|
||||
checked={Boolean(editor.websocket)}
|
||||
checked={Boolean(editor.websockets)}
|
||||
disabled={disableControls || editor.saving || !editor.json}
|
||||
ariaLabel={t('ai_providers.codex_websockets_label')}
|
||||
onChange={(value) => onChange('websocket', value)}
|
||||
onChange={(value) => onChange('websockets', value)}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.codex_websockets_hint')}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import iconAntigravity from '@/assets/icons/antigravity.svg';
|
||||
import iconClaude from '@/assets/icons/claude.svg';
|
||||
import iconCodex from '@/assets/icons/codex.svg';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import iconIflow from '@/assets/icons/iflow.svg';
|
||||
import iconKimiDark from '@/assets/icons/kimi-dark.svg';
|
||||
import iconKimiLight from '@/assets/icons/kimi-light.svg';
|
||||
import iconQwen from '@/assets/icons/qwen.svg';
|
||||
import iconVertex from '@/assets/icons/vertex.svg';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import {
|
||||
normalizeAuthIndex,
|
||||
normalizeUsageSourceId,
|
||||
type KeyStatBucket,
|
||||
type KeyStats
|
||||
type KeyStats,
|
||||
} from '@/utils/usage';
|
||||
|
||||
export type ThemeColors = { bg: string; text: string; border?: string };
|
||||
export type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
|
||||
export type ResolvedTheme = 'light' | 'dark';
|
||||
export type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string };
|
||||
export type AuthFileModelItem = {
|
||||
id: string;
|
||||
display_name?: string;
|
||||
type?: string;
|
||||
owned_by?: string;
|
||||
};
|
||||
export type AuthFileIconAsset = string | { light: string; dark: string };
|
||||
|
||||
export type QuotaProviderType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli' | 'kimi';
|
||||
|
||||
@@ -19,7 +34,7 @@ export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>([
|
||||
'claude',
|
||||
'codex',
|
||||
'gemini-cli',
|
||||
'kimi'
|
||||
'kimi',
|
||||
]);
|
||||
|
||||
export const MIN_CARD_PAGE_SIZE = 3;
|
||||
@@ -30,52 +45,79 @@ export const INTEGER_STRING_PATTERN = /^[+-]?\d+$/;
|
||||
export const TRUTHY_TEXT_VALUES = new Set(['true', '1', 'yes', 'y', 'on']);
|
||||
export const FALSY_TEXT_VALUES = new Set(['false', '0', 'no', 'n', 'off']);
|
||||
|
||||
// 标签类型颜色配置(对齐重构前 styles.css 的 file-type-badge 颜色)
|
||||
// 标签类型颜色配置 — 基于各提供商 Logo 品牌色调配,确保彼此不重复
|
||||
export const TYPE_COLORS: Record<string, TypeColorSet> = {
|
||||
// Qwen logo: 紫罗兰渐变 #6336E7 → #6F69F7
|
||||
qwen: {
|
||||
light: { bg: '#e8f5e9', text: '#2e7d32' },
|
||||
dark: { bg: '#1b5e20', text: '#81c784' }
|
||||
light: { bg: '#ede5fd', text: '#5530c7' },
|
||||
dark: { bg: '#36208a', text: '#b5a3f0' },
|
||||
},
|
||||
// Kimi logo: 亮蓝 #027AFF(K字 + 蓝色圆点)
|
||||
kimi: {
|
||||
light: { bg: '#fff4e5', text: '#ad6800' },
|
||||
dark: { bg: '#7c4a03', text: '#ffd591' }
|
||||
light: { bg: '#dce8ff', text: '#0560cf' },
|
||||
dark: { bg: '#003880', text: '#70b5ff' },
|
||||
},
|
||||
// Gemini logo: 多色蓝 #3186FF(偏柔和的蓝)
|
||||
gemini: {
|
||||
light: { bg: '#e3f2fd', text: '#1565c0' },
|
||||
dark: { bg: '#0d47a1', text: '#64b5f6' }
|
||||
dark: { bg: '#0d47a1', text: '#64b5f6' },
|
||||
},
|
||||
// Gemini-CLI: 同 Gemini 图标,用更深的海军蓝区分
|
||||
'gemini-cli': {
|
||||
light: { bg: '#e7efff', text: '#1e4fa3' },
|
||||
dark: { bg: '#1c3f73', text: '#a8c7ff' }
|
||||
light: { bg: '#e0e8ff', text: '#1e4fa3' },
|
||||
dark: { bg: '#1c3f73', text: '#a8c7ff' },
|
||||
},
|
||||
// AI Studio: 使用 Gemini 图标,中性灰标签
|
||||
aistudio: {
|
||||
light: { bg: '#f0f2f5', text: '#2f343c' },
|
||||
dark: { bg: '#373c42', text: '#cfd3db' }
|
||||
dark: { bg: '#373c42', text: '#cfd3db' },
|
||||
},
|
||||
// Claude logo: 陶土橙 #D97757
|
||||
claude: {
|
||||
light: { bg: '#fce4ec', text: '#c2185b' },
|
||||
dark: { bg: '#880e4f', text: '#f48fb1' }
|
||||
light: { bg: '#fbece4', text: '#c05621' },
|
||||
dark: { bg: '#5e2c14', text: '#e8a882' },
|
||||
},
|
||||
// Codex logo: 靛蓝渐变 #B1A7FF → #3941FF
|
||||
codex: {
|
||||
light: { bg: '#fff3e0', text: '#ef6c00' },
|
||||
dark: { bg: '#e65100', text: '#ffb74d' }
|
||||
light: { bg: '#eae7ff', text: '#3538d4' },
|
||||
dark: { bg: '#262395', text: '#b5b0ff' },
|
||||
},
|
||||
// Antigravity logo: 多色(主色 #3789F9 蓝 + #53A89A 青绿),用青色区分
|
||||
antigravity: {
|
||||
light: { bg: '#e0f7fa', text: '#006064' },
|
||||
dark: { bg: '#004d40', text: '#80deea' }
|
||||
dark: { bg: '#004d40', text: '#80deea' },
|
||||
},
|
||||
// iFlow logo: 品红紫渐变 #5C5CFF → #AE5CFF,偏品红以区别于 Qwen 的紫罗兰
|
||||
iflow: {
|
||||
light: { bg: '#f3e5f5', text: '#7b1fa2' },
|
||||
dark: { bg: '#4a148c', text: '#ce93d8' }
|
||||
light: { bg: '#f5e3fc', text: '#9025c8' },
|
||||
dark: { bg: '#521490', text: '#d49cf5' },
|
||||
},
|
||||
// Vertex logo: Google 蓝 #4285F4
|
||||
vertex: {
|
||||
light: { bg: '#e4edfd', text: '#2b5fbc' },
|
||||
dark: { bg: '#1a3d80', text: '#89b3f7' },
|
||||
},
|
||||
empty: {
|
||||
light: { bg: '#f5f5f5', text: '#616161' },
|
||||
dark: { bg: '#424242', text: '#bdbdbd' }
|
||||
dark: { bg: '#424242', text: '#bdbdbd' },
|
||||
},
|
||||
unknown: {
|
||||
light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' },
|
||||
dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' }
|
||||
}
|
||||
dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' },
|
||||
},
|
||||
};
|
||||
|
||||
export const AUTH_FILE_ICONS: Record<string, AuthFileIconAsset> = {
|
||||
antigravity: iconAntigravity,
|
||||
aistudio: iconGemini,
|
||||
claude: iconClaude,
|
||||
codex: iconCodex,
|
||||
gemini: iconGemini,
|
||||
'gemini-cli': iconGemini,
|
||||
iflow: iconIflow,
|
||||
kimi: { light: iconKimiLight, dark: iconKimiDark },
|
||||
qwen: iconQwen,
|
||||
vertex: iconVertex,
|
||||
};
|
||||
|
||||
export const clampCardPageSize = (value: number) =>
|
||||
@@ -116,6 +158,16 @@ export const getTypeColor = (type: string, resolvedTheme: ResolvedTheme): ThemeC
|
||||
return resolvedTheme === 'dark' && set.dark ? set.dark : set.light;
|
||||
};
|
||||
|
||||
export const getAuthFileIcon = (type: string, resolvedTheme: ResolvedTheme): string | null => {
|
||||
const iconEntry = AUTH_FILE_ICONS[normalizeProviderKey(type)];
|
||||
if (!iconEntry) return null;
|
||||
return typeof iconEntry === 'string'
|
||||
? iconEntry
|
||||
: resolvedTheme === 'dark'
|
||||
? iconEntry.dark
|
||||
: iconEntry.light;
|
||||
};
|
||||
|
||||
export const parsePriorityValue = (value: unknown): number | undefined => {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isInteger(value) ? value : undefined;
|
||||
@@ -160,6 +212,19 @@ export const parseDisableCoolingValue = (value: unknown): boolean | undefined =>
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const readCodexAuthFileWebsockets = (value: Record<string, unknown>): boolean =>
|
||||
parseDisableCoolingValue(value.websockets) ?? false;
|
||||
|
||||
export const applyCodexAuthFileWebsockets = (
|
||||
value: Record<string, unknown>,
|
||||
websockets: boolean
|
||||
): Record<string, unknown> => {
|
||||
const next = { ...value };
|
||||
delete next.websocket;
|
||||
next.websockets = websockets;
|
||||
return next;
|
||||
};
|
||||
|
||||
export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
|
||||
const raw = file['runtime_only'] ?? file.runtimeOnly;
|
||||
if (typeof raw === 'boolean') return raw;
|
||||
|
||||
@@ -30,6 +30,7 @@ export type UseAuthFilesDataResult = {
|
||||
deleting: string | null;
|
||||
deletingAll: boolean;
|
||||
statusUpdating: Record<string, boolean>;
|
||||
batchStatusUpdating: boolean;
|
||||
fileInputRef: RefObject<HTMLInputElement | null>;
|
||||
loadFiles: () => Promise<void>;
|
||||
handleUploadClick: () => void;
|
||||
@@ -40,7 +41,9 @@ export type UseAuthFilesDataResult = {
|
||||
handleStatusToggle: (item: AuthFileItem, enabled: boolean) => Promise<void>;
|
||||
toggleSelect: (name: string) => void;
|
||||
selectAllVisible: (visibleFiles: AuthFileItem[]) => void;
|
||||
invertVisibleSelection: (visibleFiles: AuthFileItem[]) => void;
|
||||
deselectAll: () => void;
|
||||
batchDownload: (names: string[]) => Promise<void>;
|
||||
batchSetStatus: (names: string[], enabled: boolean) => Promise<void>;
|
||||
batchDelete: (names: string[]) => void;
|
||||
};
|
||||
@@ -61,9 +64,11 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [deletingAll, setDeletingAll] = useState(false);
|
||||
const [statusUpdating, setStatusUpdating] = useState<Record<string, boolean>>({});
|
||||
const [batchStatusUpdating, setBatchStatusUpdating] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const batchStatusPendingRef = useRef(false);
|
||||
const selectionCount = selectedFiles.size;
|
||||
const toggleSelect = useCallback((name: string) => {
|
||||
setSelectedFiles((prev) => {
|
||||
@@ -81,7 +86,31 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
const nextSelected = visibleFiles
|
||||
.filter((file) => !isRuntimeOnlyAuthFile(file))
|
||||
.map((file) => file.name);
|
||||
setSelectedFiles(new Set(nextSelected));
|
||||
if (nextSelected.length === 0) return;
|
||||
setSelectedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
nextSelected.forEach((name) => next.add(name));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const invertVisibleSelection = useCallback((visibleFiles: AuthFileItem[]) => {
|
||||
const visibleNames = visibleFiles
|
||||
.filter((file) => !isRuntimeOnlyAuthFile(file))
|
||||
.map((file) => file.name);
|
||||
if (visibleNames.length === 0) return;
|
||||
|
||||
setSelectedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
visibleNames.forEach((name) => {
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const deselectAll = useCallback(() => {
|
||||
@@ -220,7 +249,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[showConfirmation, showNotification, t]
|
||||
@@ -348,7 +377,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
} finally {
|
||||
setDeletingAll(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[deselectAll, files, showConfirmation, showNotification, t]
|
||||
@@ -412,62 +441,131 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
|
||||
const batchSetStatus = useCallback(
|
||||
async (names: string[], enabled: boolean) => {
|
||||
if (batchStatusPendingRef.current) return;
|
||||
|
||||
const uniqueNames = Array.from(new Set(names));
|
||||
if (uniqueNames.length === 0) return;
|
||||
if (uniqueNames.some((name) => statusUpdating[name] === true)) return;
|
||||
|
||||
const originalDisabled = new Map(
|
||||
files
|
||||
.filter((file) => uniqueNames.includes(file.name))
|
||||
.map((file) => [file.name, file.disabled === true])
|
||||
);
|
||||
const targetNames = new Set(originalDisabled.keys());
|
||||
const targetNameList = Array.from(targetNames);
|
||||
if (targetNameList.length === 0) return;
|
||||
|
||||
const targetNames = new Set(uniqueNames);
|
||||
const nextDisabled = !enabled;
|
||||
|
||||
batchStatusPendingRef.current = true;
|
||||
setBatchStatusUpdating(true);
|
||||
setStatusUpdating((prev) => {
|
||||
const next = { ...prev };
|
||||
targetNameList.forEach((name) => {
|
||||
next[name] = true;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setFiles((prev) =>
|
||||
prev.map((file) =>
|
||||
targetNames.has(file.name) ? { ...file, disabled: nextDisabled } : file
|
||||
)
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
uniqueNames.map((name) => authFilesApi.setStatus(name, nextDisabled))
|
||||
);
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
targetNameList.map((name) => authFilesApi.setStatus(name, nextDisabled))
|
||||
);
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
const failedNames = new Set<string>();
|
||||
const confirmedDisabled = new Map<string, boolean>();
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const name = targetNameList[index];
|
||||
if (result.status === 'fulfilled') {
|
||||
successCount++;
|
||||
confirmedDisabled.set(name, result.value.disabled);
|
||||
} else {
|
||||
failCount++;
|
||||
failedNames.add(name);
|
||||
}
|
||||
});
|
||||
|
||||
setFiles((prev) =>
|
||||
prev.map((file) => {
|
||||
if (failedNames.has(file.name)) {
|
||||
return { ...file, disabled: originalDisabled.get(file.name) === true };
|
||||
}
|
||||
if (confirmedDisabled.has(file.name)) {
|
||||
return { ...file, disabled: confirmedDisabled.get(file.name) };
|
||||
}
|
||||
return file;
|
||||
})
|
||||
);
|
||||
|
||||
if (failCount === 0) {
|
||||
showNotification(t('auth_files.batch_status_success', { count: successCount }), 'success');
|
||||
} else {
|
||||
showNotification(
|
||||
t('auth_files.batch_status_partial', { success: successCount, failed: failCount }),
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
|
||||
deselectAll();
|
||||
} finally {
|
||||
batchStatusPendingRef.current = false;
|
||||
setBatchStatusUpdating(false);
|
||||
setStatusUpdating((prev) => {
|
||||
const next = { ...prev };
|
||||
targetNameList.forEach((name) => {
|
||||
delete next[name];
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[deselectAll, files, showNotification, statusUpdating, t]
|
||||
);
|
||||
|
||||
const batchDownload = useCallback(
|
||||
async (names: string[]) => {
|
||||
const uniqueNames = Array.from(new Set(names));
|
||||
if (uniqueNames.length === 0) return;
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
const failedNames = new Set<string>();
|
||||
const confirmedDisabled = new Map<string, boolean>();
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const name = uniqueNames[index];
|
||||
if (result.status === 'fulfilled') {
|
||||
for (const name of uniqueNames) {
|
||||
try {
|
||||
const response = await apiClient.getRaw(
|
||||
`/auth-files/download?name=${encodeURIComponent(name)}`,
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
const blob = new Blob([response.data]);
|
||||
downloadBlob({ filename: name, blob });
|
||||
successCount++;
|
||||
confirmedDisabled.set(name, result.value.disabled);
|
||||
} else {
|
||||
} catch {
|
||||
failCount++;
|
||||
failedNames.add(name);
|
||||
}
|
||||
});
|
||||
|
||||
setFiles((prev) =>
|
||||
prev.map((file) => {
|
||||
if (failedNames.has(file.name)) {
|
||||
return { ...file, disabled: !nextDisabled };
|
||||
}
|
||||
if (confirmedDisabled.has(file.name)) {
|
||||
return { ...file, disabled: confirmedDisabled.get(file.name) };
|
||||
}
|
||||
return file;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (failCount === 0) {
|
||||
showNotification(t('auth_files.batch_status_success', { count: successCount }), 'success');
|
||||
showNotification(
|
||||
t('auth_files.batch_download_success', { count: successCount }),
|
||||
'success'
|
||||
);
|
||||
} else {
|
||||
showNotification(
|
||||
t('auth_files.batch_status_partial', { success: successCount, failed: failCount }),
|
||||
t('auth_files.batch_download_partial', { success: successCount, failed: failCount }),
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
|
||||
deselectAll();
|
||||
},
|
||||
[deselectAll, showNotification, t]
|
||||
[showNotification, t]
|
||||
);
|
||||
|
||||
const batchDelete = useCallback(
|
||||
@@ -516,18 +614,21 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
});
|
||||
|
||||
if (failCount === 0) {
|
||||
showNotification(`${t('auth_files.delete_all_success')} (${deleted.length})`, 'success');
|
||||
showNotification(
|
||||
`${t('auth_files.delete_all_success')} (${deleted.length})`,
|
||||
'success'
|
||||
);
|
||||
} else {
|
||||
showNotification(
|
||||
t('auth_files.delete_filtered_partial', {
|
||||
success: deleted.length,
|
||||
failed: failCount,
|
||||
type: t('auth_files.filter_all')
|
||||
type: t('auth_files.filter_all'),
|
||||
}),
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[showConfirmation, showNotification, t]
|
||||
@@ -543,6 +644,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
deleting,
|
||||
deletingAll,
|
||||
statusUpdating,
|
||||
batchStatusUpdating,
|
||||
fileInputRef,
|
||||
loadFiles,
|
||||
handleUploadClick,
|
||||
@@ -553,8 +655,10 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
handleStatusToggle,
|
||||
toggleSelect,
|
||||
selectAllVisible,
|
||||
invertVisibleSelection,
|
||||
deselectAll,
|
||||
batchDownload,
|
||||
batchSetStatus,
|
||||
batchDelete
|
||||
batchDelete,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ import { useNotificationStore } from '@/stores';
|
||||
import { formatFileSize } from '@/utils/format';
|
||||
import { MAX_AUTH_FILE_SIZE } from '@/utils/constants';
|
||||
import {
|
||||
applyCodexAuthFileWebsockets,
|
||||
normalizeExcludedModels,
|
||||
parseDisableCoolingValue,
|
||||
parseExcludedModelsText,
|
||||
parsePriorityValue,
|
||||
readCodexAuthFileWebsockets,
|
||||
} from '@/features/authFiles/constants';
|
||||
|
||||
export type PrefixProxyEditorField =
|
||||
@@ -18,12 +20,14 @@ export type PrefixProxyEditorField =
|
||||
| 'priority'
|
||||
| 'excludedModelsText'
|
||||
| 'disableCooling'
|
||||
| 'websocket';
|
||||
| 'websockets'
|
||||
| 'note';
|
||||
|
||||
export type PrefixProxyEditorFieldValue = string | boolean;
|
||||
|
||||
export type PrefixProxyEditorState = {
|
||||
fileName: string;
|
||||
fileInfoText: string;
|
||||
isCodexFile: boolean;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
@@ -36,7 +40,9 @@ export type PrefixProxyEditorState = {
|
||||
priority: string;
|
||||
excludedModelsText: string;
|
||||
disableCooling: string;
|
||||
websocket: boolean;
|
||||
websockets: boolean;
|
||||
note: string;
|
||||
noteTouched: boolean;
|
||||
};
|
||||
|
||||
export type UseAuthFilesPrefixProxyEditorOptions = {
|
||||
@@ -49,7 +55,7 @@ export type UseAuthFilesPrefixProxyEditorResult = {
|
||||
prefixProxyEditor: PrefixProxyEditorState | null;
|
||||
prefixProxyUpdatedText: string;
|
||||
prefixProxyDirty: boolean;
|
||||
openPrefixProxyEditor: (file: Pick<AuthFileItem, 'name' | 'type' | 'provider'>) => Promise<void>;
|
||||
openPrefixProxyEditor: (file: AuthFileItem) => Promise<void>;
|
||||
closePrefixProxyEditor: () => void;
|
||||
handlePrefixProxyChange: (
|
||||
field: PrefixProxyEditorField,
|
||||
@@ -89,11 +95,18 @@ const buildPrefixProxyUpdatedText = (editor: PrefixProxyEditorState | null): str
|
||||
delete next.disable_cooling;
|
||||
}
|
||||
|
||||
if (editor.isCodexFile) {
|
||||
next.websocket = editor.websocket;
|
||||
if (editor.noteTouched) {
|
||||
const noteValue = editor.note.trim();
|
||||
if (noteValue) {
|
||||
next.note = editor.note;
|
||||
} else if ('note' in next) {
|
||||
delete next.note;
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(next);
|
||||
return JSON.stringify(
|
||||
editor.isCodexFile ? applyCodexAuthFileWebsockets(next, editor.websockets) : next
|
||||
);
|
||||
};
|
||||
|
||||
export function useAuthFilesPrefixProxyEditor(
|
||||
@@ -115,7 +128,7 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
setPrefixProxyEditor(null);
|
||||
};
|
||||
|
||||
const openPrefixProxyEditor = async (file: Pick<AuthFileItem, 'name' | 'type' | 'provider'>) => {
|
||||
const openPrefixProxyEditor = async (file: AuthFileItem) => {
|
||||
const name = file.name;
|
||||
const normalizedType = String(file.type ?? '')
|
||||
.trim()
|
||||
@@ -133,6 +146,7 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
|
||||
setPrefixProxyEditor({
|
||||
fileName: name,
|
||||
fileInfoText: JSON.stringify(file, null, 2),
|
||||
isCodexFile,
|
||||
loading: true,
|
||||
saving: false,
|
||||
@@ -145,7 +159,9 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
priority: '',
|
||||
excludedModelsText: '',
|
||||
disableCooling: '',
|
||||
websocket: false,
|
||||
websockets: false,
|
||||
note: '',
|
||||
noteTouched: false,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -185,8 +201,9 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
|
||||
const json = { ...(parsed as Record<string, unknown>) };
|
||||
if (isCodexFile) {
|
||||
const websocketValue = parseDisableCoolingValue(json.websocket);
|
||||
json.websocket = websocketValue ?? false;
|
||||
const normalizedWebsockets = readCodexAuthFileWebsockets(json);
|
||||
delete json.websocket;
|
||||
json.websockets = normalizedWebsockets;
|
||||
}
|
||||
const originalText = JSON.stringify(json);
|
||||
const prefix = typeof json.prefix === 'string' ? json.prefix : '';
|
||||
@@ -194,7 +211,8 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
const priority = parsePriorityValue(json.priority);
|
||||
const excludedModels = normalizeExcludedModels(json.excluded_models);
|
||||
const disableCoolingValue = parseDisableCoolingValue(json.disable_cooling);
|
||||
const websocketValue = parseDisableCoolingValue(json.websocket);
|
||||
const websocketsValue = readCodexAuthFileWebsockets(json);
|
||||
const note = typeof json.note === 'string' ? json.note : '';
|
||||
|
||||
setPrefixProxyEditor((prev) => {
|
||||
if (!prev || prev.fileName !== name) return prev;
|
||||
@@ -210,7 +228,9 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
excludedModelsText: excludedModels.join('\n'),
|
||||
disableCooling:
|
||||
disableCoolingValue === undefined ? '' : disableCoolingValue ? 'true' : 'false',
|
||||
websocket: websocketValue ?? false,
|
||||
websockets: websocketsValue,
|
||||
note,
|
||||
noteTouched: false,
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
@@ -235,7 +255,8 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
if (field === 'priority') return { ...prev, priority: String(value) };
|
||||
if (field === 'excludedModelsText') return { ...prev, excludedModelsText: String(value) };
|
||||
if (field === 'disableCooling') return { ...prev, disableCooling: String(value) };
|
||||
return { ...prev, websocket: Boolean(value) };
|
||||
if (field === 'note') return { ...prev, note: String(value), noteTouched: true };
|
||||
return { ...prev, websockets: Boolean(value) };
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
export const AUTH_FILES_SORT_MODES = ['default', 'az', 'priority'] as const;
|
||||
|
||||
export type AuthFilesSortMode = (typeof AUTH_FILES_SORT_MODES)[number];
|
||||
|
||||
export type AuthFilesUiState = {
|
||||
filter?: string;
|
||||
problemOnly?: boolean;
|
||||
compactMode?: boolean;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
regularPageSize?: number;
|
||||
compactPageSize?: number;
|
||||
sortMode?: AuthFilesSortMode;
|
||||
};
|
||||
|
||||
const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState';
|
||||
const AUTH_FILES_SORT_MODE_SET = new Set<AuthFilesSortMode>(AUTH_FILES_SORT_MODES);
|
||||
|
||||
export const isAuthFilesSortMode = (value: unknown): value is AuthFilesSortMode =>
|
||||
typeof value === 'string' && AUTH_FILES_SORT_MODE_SET.has(value as AuthFilesSortMode);
|
||||
|
||||
export const readAuthFilesUiState = (): AuthFilesUiState | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
+135
-142
@@ -47,100 +47,26 @@ function parseApiKeysText(raw: unknown): string {
|
||||
return keys.join('\n');
|
||||
}
|
||||
|
||||
function replaceApiKeyValue(entry: unknown, apiKey: string): unknown {
|
||||
const record = asRecord(entry);
|
||||
if (!record) return apiKey;
|
||||
function resolveApiKeysText(parsed: Record<string, unknown>): string {
|
||||
if (Object.prototype.hasOwnProperty.call(parsed, 'api-keys')) {
|
||||
return parseApiKeysText(parsed['api-keys']);
|
||||
}
|
||||
|
||||
if ('api-key' in record) return { ...record, 'api-key': apiKey };
|
||||
if ('apiKey' in record) return { ...record, apiKey };
|
||||
if ('key' in record) return { ...record, key: apiKey };
|
||||
if ('Key' in record) return { ...record, Key: apiKey };
|
||||
|
||||
return { ...record, 'api-key': apiKey };
|
||||
}
|
||||
|
||||
function buildApiKeyEntries(
|
||||
apiKeys: string[],
|
||||
metadata: ApiKeysStorageMetadata
|
||||
): Array<string | Record<string, unknown>> {
|
||||
return apiKeys.map((apiKey, index) => {
|
||||
const originalEntry = metadata.originalEntries[index];
|
||||
if (metadata.entryMode === 'object') {
|
||||
const replaced = replaceApiKeyValue(originalEntry, apiKey);
|
||||
return asRecord(replaced) ?? { 'api-key': apiKey };
|
||||
}
|
||||
|
||||
const record = asRecord(originalEntry);
|
||||
return record ? ({ ...record, ...(replaceApiKeyValue(record, apiKey) as Record<string, unknown>) }) : apiKey;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveApiKeysStorage(parsed: Record<string, unknown>): {
|
||||
text: string;
|
||||
metadata: ApiKeysStorageMetadata;
|
||||
} {
|
||||
const legacyEntries = Array.isArray(parsed['api-keys']) ? parsed['api-keys'] : [];
|
||||
const auth = asRecord(parsed.auth);
|
||||
const providers = asRecord(auth?.providers);
|
||||
const configApiKeyProvider = asRecord(providers?.['config-api-key']);
|
||||
if (!configApiKeyProvider) return '';
|
||||
|
||||
if (configApiKeyProvider) {
|
||||
const providerEntries = Array.isArray(configApiKeyProvider['api-key-entries'])
|
||||
? configApiKeyProvider['api-key-entries']
|
||||
: Array.isArray(configApiKeyProvider['api-keys'])
|
||||
? configApiKeyProvider['api-keys']
|
||||
: [];
|
||||
const providerListKey = Array.isArray(configApiKeyProvider['api-key-entries'])
|
||||
? 'api-key-entries'
|
||||
: 'api-keys';
|
||||
|
||||
return {
|
||||
text: parseApiKeysText(providerEntries),
|
||||
metadata: {
|
||||
source: 'auth-provider',
|
||||
providerListKey,
|
||||
entryMode:
|
||||
providerListKey === 'api-key-entries' || providerEntries.some((entry) => Boolean(asRecord(entry)))
|
||||
? 'object'
|
||||
: 'string',
|
||||
originalEntries: providerEntries,
|
||||
syncLegacy: legacyEntries.length > 0,
|
||||
},
|
||||
};
|
||||
if (Object.prototype.hasOwnProperty.call(configApiKeyProvider, 'api-key-entries')) {
|
||||
return parseApiKeysText(configApiKeyProvider['api-key-entries']);
|
||||
}
|
||||
|
||||
return {
|
||||
text: parseApiKeysText(legacyEntries),
|
||||
metadata: {
|
||||
source: 'legacy',
|
||||
entryMode: legacyEntries.some((entry) => Boolean(asRecord(entry))) ? 'object' : 'string',
|
||||
originalEntries: legacyEntries,
|
||||
syncLegacy: false,
|
||||
},
|
||||
};
|
||||
return parseApiKeysText(configApiKeyProvider['api-keys']);
|
||||
}
|
||||
|
||||
type YamlDocument = ReturnType<typeof parseDocument>;
|
||||
type YamlPath = string[];
|
||||
|
||||
type ApiKeysStorageMode = 'legacy' | 'auth-provider';
|
||||
type ApiKeysEntryMode = 'string' | 'object';
|
||||
|
||||
type ApiKeysStorageMetadata = {
|
||||
source: ApiKeysStorageMode;
|
||||
providerListKey?: 'api-keys' | 'api-key-entries';
|
||||
entryMode: ApiKeysEntryMode;
|
||||
originalEntries: unknown[];
|
||||
syncLegacy: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_API_KEYS_STORAGE_METADATA: ApiKeysStorageMetadata = {
|
||||
source: 'legacy',
|
||||
entryMode: 'string',
|
||||
originalEntries: [],
|
||||
syncLegacy: false,
|
||||
};
|
||||
|
||||
function docHas(doc: YamlDocument, path: YamlPath): boolean {
|
||||
return doc.hasIn(path);
|
||||
}
|
||||
@@ -148,7 +74,8 @@ function docHas(doc: YamlDocument, path: YamlPath): boolean {
|
||||
function ensureMapInDoc(doc: YamlDocument, path: YamlPath): void {
|
||||
const existing = doc.getIn(path, true);
|
||||
if (isMap(existing)) return;
|
||||
doc.setIn(path, {});
|
||||
// Use a YAML node here; plain objects are not treated as collections by subsequent `setIn`.
|
||||
doc.setIn(path, doc.createNode({}));
|
||||
}
|
||||
|
||||
function deleteIfMapEmpty(doc: YamlDocument, path: YamlPath): void {
|
||||
@@ -220,6 +147,7 @@ export function getVisualConfigValidationErrors(
|
||||
port: getPortError(values.port),
|
||||
logsMaxTotalSizeMb: getNonNegativeIntegerError(values.logsMaxTotalSizeMb),
|
||||
requestRetry: getNonNegativeIntegerError(values.requestRetry),
|
||||
maxRetryCredentials: getNonNegativeIntegerError(values.maxRetryCredentials),
|
||||
maxRetryInterval: getNonNegativeIntegerError(values.maxRetryInterval),
|
||||
'streaming.keepaliveSeconds': getNonNegativeIntegerError(values.streaming.keepaliveSeconds),
|
||||
'streaming.bootstrapRetries': getNonNegativeIntegerError(values.streaming.bootstrapRetries),
|
||||
@@ -290,21 +218,32 @@ function parsePayloadParamValue(raw: unknown): { valueType: PayloadParamValueTyp
|
||||
return { valueType: 'string', value: String(raw ?? '') };
|
||||
}
|
||||
|
||||
const PAYLOAD_PROTOCOL_VALUES = [
|
||||
'openai',
|
||||
'openai-response',
|
||||
'gemini',
|
||||
'claude',
|
||||
'codex',
|
||||
'antigravity',
|
||||
] as const;
|
||||
type PayloadProtocol = (typeof PAYLOAD_PROTOCOL_VALUES)[number];
|
||||
function parseRawPayloadParamValue(raw: unknown): string {
|
||||
if (typeof raw === 'string') return raw;
|
||||
|
||||
function parsePayloadProtocol(raw: unknown): PayloadProtocol | undefined {
|
||||
try {
|
||||
const json = JSON.stringify(raw, null, 2);
|
||||
return json ?? '';
|
||||
} catch {
|
||||
return String(raw ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
function parsePayloadProtocol(raw: unknown): string | undefined {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
return PAYLOAD_PROTOCOL_VALUES.includes(raw as PayloadProtocol)
|
||||
? (raw as PayloadProtocol)
|
||||
: undefined;
|
||||
return raw.trim() ? raw : undefined;
|
||||
}
|
||||
|
||||
function deleteLegacyApiKeysProvider(doc: YamlDocument): void {
|
||||
if (docHas(doc, ['auth', 'providers', 'config-api-key', 'api-key-entries'])) {
|
||||
doc.deleteIn(['auth', 'providers', 'config-api-key', 'api-key-entries']);
|
||||
}
|
||||
if (docHas(doc, ['auth', 'providers', 'config-api-key', 'api-keys'])) {
|
||||
doc.deleteIn(['auth', 'providers', 'config-api-key', 'api-keys']);
|
||||
}
|
||||
deleteIfMapEmpty(doc, ['auth', 'providers', 'config-api-key']);
|
||||
deleteIfMapEmpty(doc, ['auth', 'providers']);
|
||||
deleteIfMapEmpty(doc, ['auth']);
|
||||
}
|
||||
|
||||
function parsePayloadRules(rules: unknown): PayloadRule[] {
|
||||
@@ -373,6 +312,41 @@ function parsePayloadFilterRules(rules: unknown): PayloadFilterRule[] {
|
||||
});
|
||||
}
|
||||
|
||||
function parseRawPayloadRules(rules: unknown): PayloadRule[] {
|
||||
if (!Array.isArray(rules)) return [];
|
||||
|
||||
return rules.map((rule, index) => {
|
||||
const record = asRecord(rule) ?? {};
|
||||
|
||||
const modelsRaw = record.models;
|
||||
const models = Array.isArray(modelsRaw)
|
||||
? modelsRaw.map((model, modelIndex) => {
|
||||
const modelRecord = asRecord(model);
|
||||
const nameRaw =
|
||||
typeof model === 'string' ? model : (modelRecord?.name ?? modelRecord?.id ?? '');
|
||||
const name = typeof nameRaw === 'string' ? nameRaw : String(nameRaw ?? '');
|
||||
return {
|
||||
id: `raw-model-${index}-${modelIndex}`,
|
||||
name,
|
||||
protocol: parsePayloadProtocol(modelRecord?.protocol),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const paramsRecord = asRecord(record.params);
|
||||
const params = paramsRecord
|
||||
? Object.entries(paramsRecord).map(([path, value], pIndex) => ({
|
||||
id: `raw-param-${index}-${pIndex}`,
|
||||
path,
|
||||
valueType: 'json' as const,
|
||||
value: parseRawPayloadParamValue(value),
|
||||
}))
|
||||
: [];
|
||||
|
||||
return { id: `payload-raw-rule-${index}`, models, params };
|
||||
});
|
||||
}
|
||||
|
||||
function serializePayloadRulesForYaml(rules: PayloadRule[]): Array<Record<string, unknown>> {
|
||||
return rules
|
||||
.map((rule) => {
|
||||
@@ -430,6 +404,28 @@ function serializePayloadFilterRulesForYaml(
|
||||
.filter((rule) => rule.models.length > 0);
|
||||
}
|
||||
|
||||
function serializeRawPayloadRulesForYaml(rules: PayloadRule[]): Array<Record<string, unknown>> {
|
||||
return rules
|
||||
.map((rule) => {
|
||||
const models = (rule.models || [])
|
||||
.filter((m) => m.name?.trim())
|
||||
.map((m) => {
|
||||
const obj: Record<string, unknown> = { name: m.name.trim() };
|
||||
if (m.protocol) obj.protocol = m.protocol;
|
||||
return obj;
|
||||
});
|
||||
|
||||
const params: Record<string, unknown> = {};
|
||||
for (const param of rule.params || []) {
|
||||
if (!param.path?.trim()) continue;
|
||||
params[param.path.trim()] = param.value;
|
||||
}
|
||||
|
||||
return { models, params };
|
||||
})
|
||||
.filter((rule) => rule.models.length > 0);
|
||||
}
|
||||
|
||||
export function useVisualConfig() {
|
||||
const [visualValues, setVisualValuesState] = useState<VisualConfigValues>({
|
||||
...DEFAULT_VISUAL_VALUES,
|
||||
@@ -439,8 +435,6 @@ export function useVisualConfig() {
|
||||
...DEFAULT_VISUAL_VALUES,
|
||||
});
|
||||
const [visualParseError, setVisualParseError] = useState<string | null>(null);
|
||||
const [apiKeysStorageMetadata, setApiKeysStorageMetadata] =
|
||||
useState<ApiKeysStorageMetadata>(DEFAULT_API_KEYS_STORAGE_METADATA);
|
||||
const visualValidationErrors = useMemo(
|
||||
() => getVisualConfigValidationErrors(visualValues),
|
||||
[visualValues]
|
||||
@@ -448,8 +442,15 @@ export function useVisualConfig() {
|
||||
const visualHasPayloadValidationErrors = useMemo(
|
||||
() =>
|
||||
hasPayloadParamValidationErrors(visualValues.payloadDefaultRules) ||
|
||||
hasPayloadParamValidationErrors(visualValues.payloadOverrideRules),
|
||||
[visualValues.payloadDefaultRules, visualValues.payloadOverrideRules]
|
||||
hasPayloadParamValidationErrors(visualValues.payloadDefaultRawRules) ||
|
||||
hasPayloadParamValidationErrors(visualValues.payloadOverrideRules) ||
|
||||
hasPayloadParamValidationErrors(visualValues.payloadOverrideRawRules),
|
||||
[
|
||||
visualValues.payloadDefaultRules,
|
||||
visualValues.payloadDefaultRawRules,
|
||||
visualValues.payloadOverrideRules,
|
||||
visualValues.payloadOverrideRawRules,
|
||||
]
|
||||
);
|
||||
|
||||
const visualDirty = useMemo(() => {
|
||||
@@ -471,7 +472,6 @@ export function useVisualConfig() {
|
||||
const routing = asRecord(parsed.routing);
|
||||
const payload = asRecord(parsed.payload);
|
||||
const streaming = asRecord(parsed.streaming);
|
||||
const apiKeysStorage = resolveApiKeysStorage(parsed);
|
||||
|
||||
const newValues: VisualConfigValues = {
|
||||
host: typeof parsed.host === 'string' ? parsed.host : '',
|
||||
@@ -490,10 +490,10 @@ export function useVisualConfig() {
|
||||
? remoteManagement['panel-github-repository']
|
||||
: typeof remoteManagement?.['panel-repo'] === 'string'
|
||||
? remoteManagement['panel-repo']
|
||||
: '',
|
||||
: '',
|
||||
|
||||
authDir: typeof parsed['auth-dir'] === 'string' ? parsed['auth-dir'] : '',
|
||||
apiKeysText: apiKeysStorage.text,
|
||||
apiKeysText: resolveApiKeysText(parsed),
|
||||
|
||||
debug: Boolean(parsed.debug),
|
||||
commercialMode: Boolean(parsed['commercial-mode']),
|
||||
@@ -504,6 +504,7 @@ export function useVisualConfig() {
|
||||
proxyUrl: typeof parsed['proxy-url'] === 'string' ? parsed['proxy-url'] : '',
|
||||
forceModelPrefix: Boolean(parsed['force-model-prefix']),
|
||||
requestRetry: String(parsed['request-retry'] ?? ''),
|
||||
maxRetryCredentials: String(parsed['max-retry-credentials'] ?? ''),
|
||||
maxRetryInterval: String(parsed['max-retry-interval'] ?? ''),
|
||||
wsAuth: Boolean(parsed['ws-auth']),
|
||||
|
||||
@@ -516,7 +517,9 @@ export function useVisualConfig() {
|
||||
routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin',
|
||||
|
||||
payloadDefaultRules: parsePayloadRules(payload?.default),
|
||||
payloadDefaultRawRules: parseRawPayloadRules(payload?.['default-raw']),
|
||||
payloadOverrideRules: parsePayloadRules(payload?.override),
|
||||
payloadOverrideRawRules: parseRawPayloadRules(payload?.['override-raw']),
|
||||
payloadFilterRules: parsePayloadFilterRules(payload?.filter),
|
||||
|
||||
streaming: {
|
||||
@@ -528,7 +531,6 @@ export function useVisualConfig() {
|
||||
|
||||
setVisualValuesState(newValues);
|
||||
setBaselineValues(deepClone(newValues));
|
||||
setApiKeysStorageMetadata(apiKeysStorage.metadata);
|
||||
setVisualParseError(null);
|
||||
return { ok: true as const };
|
||||
} catch (error: unknown) {
|
||||
@@ -587,44 +589,16 @@ export function useVisualConfig() {
|
||||
}
|
||||
|
||||
setStringInDoc(doc, ['auth-dir'], values.authDir);
|
||||
if (values.apiKeysText !== baselineValues.apiKeysText) {
|
||||
const apiKeys = values.apiKeysText
|
||||
.split('\n')
|
||||
.map((key) => key.trim())
|
||||
.filter(Boolean);
|
||||
const apiKeyEntries = buildApiKeyEntries(apiKeys, apiKeysStorageMetadata);
|
||||
|
||||
if (apiKeysStorageMetadata.source === 'auth-provider') {
|
||||
ensureMapInDoc(doc, ['auth']);
|
||||
ensureMapInDoc(doc, ['auth', 'providers']);
|
||||
ensureMapInDoc(doc, ['auth', 'providers', 'config-api-key']);
|
||||
|
||||
const providerListKey = apiKeysStorageMetadata.providerListKey ?? 'api-key-entries';
|
||||
const providerPath = ['auth', 'providers', 'config-api-key', providerListKey];
|
||||
|
||||
if (apiKeys.length > 0) {
|
||||
doc.setIn(providerPath, apiKeyEntries);
|
||||
} else if (docHas(doc, providerPath)) {
|
||||
doc.deleteIn(providerPath);
|
||||
}
|
||||
|
||||
deleteIfMapEmpty(doc, ['auth', 'providers', 'config-api-key']);
|
||||
deleteIfMapEmpty(doc, ['auth', 'providers']);
|
||||
deleteIfMapEmpty(doc, ['auth']);
|
||||
|
||||
if (apiKeysStorageMetadata.syncLegacy) {
|
||||
if (apiKeys.length > 0) {
|
||||
doc.setIn(['api-keys'], apiKeys);
|
||||
} else if (docHas(doc, ['api-keys'])) {
|
||||
doc.deleteIn(['api-keys']);
|
||||
}
|
||||
}
|
||||
} else if (apiKeys.length > 0) {
|
||||
doc.setIn(['api-keys'], apiKeyEntries);
|
||||
} else if (docHas(doc, ['api-keys'])) {
|
||||
doc.deleteIn(['api-keys']);
|
||||
}
|
||||
const apiKeys = values.apiKeysText
|
||||
.split('\n')
|
||||
.map((key) => key.trim())
|
||||
.filter(Boolean);
|
||||
if (apiKeys.length > 0) {
|
||||
doc.setIn(['api-keys'], apiKeys);
|
||||
} else if (docHas(doc, ['api-keys'])) {
|
||||
doc.deleteIn(['api-keys']);
|
||||
}
|
||||
deleteLegacyApiKeysProvider(doc);
|
||||
|
||||
setBooleanInDoc(doc, ['debug'], values.debug);
|
||||
|
||||
@@ -636,6 +610,7 @@ export function useVisualConfig() {
|
||||
setStringInDoc(doc, ['proxy-url'], values.proxyUrl);
|
||||
setBooleanInDoc(doc, ['force-model-prefix'], values.forceModelPrefix);
|
||||
setIntFromStringInDoc(doc, ['request-retry'], values.requestRetry);
|
||||
setIntFromStringInDoc(doc, ['max-retry-credentials'], values.maxRetryCredentials);
|
||||
setIntFromStringInDoc(doc, ['max-retry-interval'], values.maxRetryInterval);
|
||||
setBooleanInDoc(doc, ['ws-auth'], values.wsAuth);
|
||||
|
||||
@@ -686,7 +661,9 @@ export function useVisualConfig() {
|
||||
if (
|
||||
docHas(doc, ['payload']) ||
|
||||
values.payloadDefaultRules.length > 0 ||
|
||||
values.payloadDefaultRawRules.length > 0 ||
|
||||
values.payloadOverrideRules.length > 0 ||
|
||||
values.payloadOverrideRawRules.length > 0 ||
|
||||
values.payloadFilterRules.length > 0
|
||||
) {
|
||||
ensureMapInDoc(doc, ['payload']);
|
||||
@@ -698,6 +675,14 @@ export function useVisualConfig() {
|
||||
} else if (docHas(doc, ['payload', 'default'])) {
|
||||
doc.deleteIn(['payload', 'default']);
|
||||
}
|
||||
if (values.payloadDefaultRawRules.length > 0) {
|
||||
doc.setIn(
|
||||
['payload', 'default-raw'],
|
||||
serializeRawPayloadRulesForYaml(values.payloadDefaultRawRules)
|
||||
);
|
||||
} else if (docHas(doc, ['payload', 'default-raw'])) {
|
||||
doc.deleteIn(['payload', 'default-raw']);
|
||||
}
|
||||
if (values.payloadOverrideRules.length > 0) {
|
||||
doc.setIn(
|
||||
['payload', 'override'],
|
||||
@@ -706,6 +691,14 @@ export function useVisualConfig() {
|
||||
} else if (docHas(doc, ['payload', 'override'])) {
|
||||
doc.deleteIn(['payload', 'override']);
|
||||
}
|
||||
if (values.payloadOverrideRawRules.length > 0) {
|
||||
doc.setIn(
|
||||
['payload', 'override-raw'],
|
||||
serializeRawPayloadRulesForYaml(values.payloadOverrideRawRules)
|
||||
);
|
||||
} else if (docHas(doc, ['payload', 'override-raw'])) {
|
||||
doc.deleteIn(['payload', 'override-raw']);
|
||||
}
|
||||
if (values.payloadFilterRules.length > 0) {
|
||||
doc.setIn(
|
||||
['payload', 'filter'],
|
||||
@@ -722,7 +715,7 @@ export function useVisualConfig() {
|
||||
return currentYaml;
|
||||
}
|
||||
},
|
||||
[apiKeysStorageMetadata, baselineValues, visualValues]
|
||||
[visualValues]
|
||||
);
|
||||
|
||||
const setVisualValues = useCallback((newValues: Partial<VisualConfigValues>) => {
|
||||
|
||||
@@ -495,6 +495,13 @@
|
||||
"search_placeholder": "Filter by name, type, or provider",
|
||||
"problem_filter_label": "Problem Filter",
|
||||
"problem_filter_only": "Only show problematic credentials",
|
||||
"display_options_label": "Display options",
|
||||
"compact_mode_label": "Compact mode",
|
||||
"sort_label": "Sort",
|
||||
"sort_default": "Default",
|
||||
"sort_az": "A-Z Name",
|
||||
"sort_priority": "Priority",
|
||||
"priority_display": "Priority",
|
||||
"page_size_label": "Per page",
|
||||
"page_size_unit": "items",
|
||||
"view_mode_paged": "Paged",
|
||||
@@ -544,12 +551,19 @@
|
||||
"batch_delete_confirm": "Are you sure you want to delete {{count}} files?",
|
||||
"batch_selected": "{{count}} selected",
|
||||
"batch_select_all": "Select All",
|
||||
"batch_select_page": "Select page",
|
||||
"batch_select_filtered": "Select filtered",
|
||||
"batch_invert_page": "Invert page",
|
||||
"batch_deselect": "Deselect",
|
||||
"batch_download": "Download selected",
|
||||
"batch_download_success": "Started downloading {{count}} files",
|
||||
"batch_download_partial": "Download finished: {{success}} succeeded, {{failed}} failed",
|
||||
"batch_enable": "Enable",
|
||||
"batch_disable": "Disable",
|
||||
"prefix_proxy_button": "Edit Auth Fields",
|
||||
"auth_field_editor_title": "Edit Auth Fields - {{name}}",
|
||||
"prefix_proxy_button": "Auth File Details / Edit",
|
||||
"auth_field_editor_title": "Auth File Details / Edit - {{name}}",
|
||||
"prefix_proxy_loading": "Loading auth file...",
|
||||
"prefix_proxy_info_label": "Auth file info (info)",
|
||||
"prefix_proxy_source_label": "Auth file JSON (preview)",
|
||||
"prefix_label": "Prefix (prefix)",
|
||||
"proxy_url_label": "Proxy URL (proxy_url)",
|
||||
@@ -564,6 +578,10 @@
|
||||
"disable_cooling_label": "Disable cooling (disable_cooling)",
|
||||
"disable_cooling_placeholder": "e.g. true / false / 1 / 0",
|
||||
"disable_cooling_hint": "Supports booleans, numeric 0/non-0, and strings like true/false/1/0; unparseable values are ignored.",
|
||||
"note_label": "Note",
|
||||
"note_placeholder": "Enter a note, e.g.: John's account",
|
||||
"note_hint": "Optional. Used to describe the purpose or owner of this credential; leave empty to omit.",
|
||||
"note_display": "Note",
|
||||
"prefix_proxy_invalid_json": "This auth file is not a JSON object, so fields cannot be edited.",
|
||||
"prefix_proxy_saved_success": "Updated auth file \"{{name}}\" successfully",
|
||||
"quota_refresh_success": "Quota refreshed for \"{{name}}\"",
|
||||
@@ -630,7 +648,8 @@
|
||||
"plan_label": "Plan",
|
||||
"plan_plus": "Plus",
|
||||
"plan_team": "Team",
|
||||
"plan_free": "Free"
|
||||
"plan_free": "Free",
|
||||
"plan_pro": "Pro"
|
||||
},
|
||||
"gemini_cli_quota": {
|
||||
"title": "Gemini CLI Quota",
|
||||
@@ -644,7 +663,15 @@
|
||||
"empty_buckets": "No quota data available",
|
||||
"refresh_button": "Refresh Quota",
|
||||
"fetch_all": "Fetch All",
|
||||
"remaining_amount": "Remaining {{count}}"
|
||||
"remaining_amount": "Remaining {{count}}",
|
||||
"tier_label": "Tier",
|
||||
"tier_free": "Free",
|
||||
"tier_legacy": "Legacy",
|
||||
"tier_standard": "Standard",
|
||||
"tier_pro": "Pro",
|
||||
"tier_ultra": "Ultra",
|
||||
"credit_label": "Google One AI Credits",
|
||||
"credit_amount": "{{count}} credits"
|
||||
},
|
||||
"kimi_quota": {
|
||||
"title": "Kimi Quota",
|
||||
@@ -1134,6 +1161,8 @@
|
||||
"source": "Source File Editor"
|
||||
},
|
||||
"visual": {
|
||||
"notice": "Visual mode covers common fields. Review or edit unsupported config.yaml entries in source mode.",
|
||||
"quick_jump": "Quick Jump",
|
||||
"sections": {
|
||||
"server": {
|
||||
"title": "Server Configuration",
|
||||
@@ -1184,6 +1213,8 @@
|
||||
"description": "Proxy, retry, and routing settings",
|
||||
"proxy_url": "Proxy URL",
|
||||
"request_retry": "Request Retry Count",
|
||||
"max_retry_credentials": "Max Retry Credentials",
|
||||
"max_retry_credentials_hint": "Leave empty to keep it unset. Set to 0 to preserve legacy behavior and try all available credentials.",
|
||||
"max_retry_interval": "Max Retry Interval (seconds)",
|
||||
"routing_strategy": "Routing Strategy",
|
||||
"routing_strategy_hint": "Select credential selection strategy",
|
||||
@@ -1215,11 +1246,15 @@
|
||||
},
|
||||
"payload": {
|
||||
"title": "Payload Configuration",
|
||||
"description": "Default values, override rules, and filter rules",
|
||||
"description": "Default values, raw JSON rules, override rules, and filter rules",
|
||||
"default_rules": "Default Rules",
|
||||
"default_rules_desc": "Use these default values when parameters are not specified in the request",
|
||||
"default_raw_rules": "Default Raw Rules",
|
||||
"default_raw_rules_desc": "When parameters are missing, write these values as raw JSON fragments such as true, 123, \"high\", or {\"type\":\"object\"}",
|
||||
"override_rules": "Override Rules",
|
||||
"override_rules_desc": "Force override parameter values in the request",
|
||||
"override_raw_rules": "Override Raw Rules",
|
||||
"override_raw_rules_desc": "Always overwrite parameter values as raw JSON fragments, useful for response_format, schemas, and other complex fields",
|
||||
"filter_rules": "Filter Rules",
|
||||
"filter_rules_desc": "Pre-filter upstream request body via JSON Path, automatically remove non-compliant/redundant parameters (Request Sanitization)"
|
||||
}
|
||||
@@ -1268,6 +1303,7 @@
|
||||
"value_number": "Number value (e.g., 0.7)",
|
||||
"value_boolean": "true or false",
|
||||
"value_json": "JSON value",
|
||||
"value_raw_json": "Raw JSON fragment, e.g. true, 123, \"high\", or {\"type\":\"object\"}",
|
||||
"value_default": "Value",
|
||||
"boolean_true": "true",
|
||||
"boolean_false": "false"
|
||||
@@ -1414,10 +1450,10 @@
|
||||
},
|
||||
"theme": {
|
||||
"switch": "Theme",
|
||||
"light": "Light",
|
||||
"light": "Wool Paper",
|
||||
"white": "Pure White",
|
||||
"dark": "Dark",
|
||||
"switch_to_light": "Switch to light mode",
|
||||
"switch_to_light": "Switch to wool paper mode",
|
||||
"switch_to_dark": "Switch to dark mode",
|
||||
"auto": "Follow system"
|
||||
},
|
||||
|
||||
@@ -495,6 +495,13 @@
|
||||
"search_placeholder": "Фильтр по имени, типу или провайдеру",
|
||||
"problem_filter_label": "Фильтр проблем",
|
||||
"problem_filter_only": "Показывать только проблемные учётные данные",
|
||||
"display_options_label": "Параметры отображения",
|
||||
"compact_mode_label": "Компактный режим",
|
||||
"sort_label": "Сортировка",
|
||||
"sort_default": "По умолчанию",
|
||||
"sort_az": "A-Z Имя",
|
||||
"sort_priority": "Приоритет",
|
||||
"priority_display": "Приоритет",
|
||||
"page_size_label": "На странице",
|
||||
"page_size_unit": "элементов",
|
||||
"view_mode_paged": "Постранично",
|
||||
@@ -544,12 +551,19 @@
|
||||
"batch_delete_confirm": "Удалить {{count}} файлов?",
|
||||
"batch_selected": "{{count}} выбрано",
|
||||
"batch_select_all": "Выбрать все",
|
||||
"batch_select_page": "Выбрать страницу",
|
||||
"batch_select_filtered": "Выбрать по фильтру",
|
||||
"batch_invert_page": "Инвертировать страницу",
|
||||
"batch_deselect": "Отменить",
|
||||
"batch_download": "Скачать выбранные",
|
||||
"batch_download_success": "Запущена загрузка {{count}} файлов",
|
||||
"batch_download_partial": "Загрузка завершена: успешно {{success}}, ошибок {{failed}}",
|
||||
"batch_enable": "Включить",
|
||||
"batch_disable": "Отключить",
|
||||
"prefix_proxy_button": "Редактировать поля файла авторизации",
|
||||
"auth_field_editor_title": "Редактировать поля файла авторизации - {{name}}",
|
||||
"prefix_proxy_button": "Просмотр / редактирование файла авторизации",
|
||||
"auth_field_editor_title": "Просмотр / редактирование файла авторизации - {{name}}",
|
||||
"prefix_proxy_loading": "Загрузка файла авторизации...",
|
||||
"prefix_proxy_info_label": "Информация о файле авторизации (info)",
|
||||
"prefix_proxy_source_label": "JSON файла авторизации (предпросмотр)",
|
||||
"prefix_label": "Префикс (prefix)",
|
||||
"proxy_url_label": "URL прокси (proxy_url)",
|
||||
@@ -564,6 +578,10 @@
|
||||
"disable_cooling_label": "Отключение охлаждения (disable_cooling)",
|
||||
"disable_cooling_placeholder": "например: true / false / 1 / 0",
|
||||
"disable_cooling_hint": "Поддерживает boolean, числа 0/не 0 и строки true/false/1/0; непарсируемые значения игнорируются.",
|
||||
"note_label": "Заметка (note)",
|
||||
"note_placeholder": "Введите заметку, например: аккаунт Ивана",
|
||||
"note_hint": "Необязательно. Используется для описания назначения или владельца учётных данных; оставьте пустым, чтобы не записывать.",
|
||||
"note_display": "Заметка",
|
||||
"prefix_proxy_invalid_json": "Этот файл авторизации не является JSON-объектом, поэтому поля нельзя редактировать.",
|
||||
"prefix_proxy_saved_success": "Файл авторизации \"{{name}}\" успешно обновлён",
|
||||
"card_tools_title": "Инструменты",
|
||||
@@ -633,7 +651,8 @@
|
||||
"plan_label": "Тариф",
|
||||
"plan_plus": "Plus",
|
||||
"plan_team": "Team",
|
||||
"plan_free": "Free"
|
||||
"plan_free": "Free",
|
||||
"plan_pro": "Pro"
|
||||
},
|
||||
"gemini_cli_quota": {
|
||||
"title": "Квота Gemini CLI",
|
||||
@@ -647,7 +666,15 @@
|
||||
"empty_buckets": "Данные по квоте отсутствуют",
|
||||
"refresh_button": "Обновить квоту",
|
||||
"fetch_all": "Получить все",
|
||||
"remaining_amount": "Осталось {{count}}"
|
||||
"remaining_amount": "Осталось {{count}}",
|
||||
"tier_label": "Уровень",
|
||||
"tier_free": "Бесплатный",
|
||||
"tier_legacy": "Устаревший",
|
||||
"tier_standard": "Стандартный",
|
||||
"tier_pro": "Pro",
|
||||
"tier_ultra": "Ultra",
|
||||
"credit_label": "Google One AI кредиты",
|
||||
"credit_amount": "{{count}} кредитов"
|
||||
},
|
||||
"kimi_quota": {
|
||||
"title": "Квота Kimi",
|
||||
@@ -1137,6 +1164,8 @@
|
||||
"source": "Редактор файла"
|
||||
},
|
||||
"visual": {
|
||||
"notice": "Визуальный режим охватывает основные поля. Остальные параметры config.yaml по-прежнему нужно проверять или редактировать в режиме исходника.",
|
||||
"quick_jump": "Быстрый переход",
|
||||
"sections": {
|
||||
"server": {
|
||||
"title": "Настройки сервера",
|
||||
@@ -1189,6 +1218,8 @@
|
||||
"description": "Параметры прокси, повторов и маршрутизации",
|
||||
"proxy_url": "URL прокси",
|
||||
"request_retry": "Количество повторов запросов",
|
||||
"max_retry_credentials": "Максимум учётных данных для повторов",
|
||||
"max_retry_credentials_hint": "Оставьте пустым, чтобы не задавать поле. Значение 0 сохраняет legacy-поведение и позволяет перебрать все доступные учётные данные.",
|
||||
"max_retry_interval": "Максимальный интервал повтора (сек)",
|
||||
"routing_strategy": "Стратегия маршрутизации",
|
||||
"routing_strategy_hint": "Выберите стратегию подбора учётных данных",
|
||||
@@ -1220,11 +1251,15 @@
|
||||
},
|
||||
"payload": {
|
||||
"title": "Настройки полезной нагрузки",
|
||||
"description": "Значения по умолчанию, правила переопределения и фильтрации",
|
||||
"description": "Значения по умолчанию, raw JSON-правила, правила переопределения и фильтрации",
|
||||
"default_rules": "Правила по умолчанию",
|
||||
"default_rules_desc": "Использовать эти значения, если параметр не указан в запросе",
|
||||
"default_raw_rules": "Raw-правила по умолчанию",
|
||||
"default_raw_rules_desc": "Если параметр отсутствует, записывать эти значения как raw JSON-фрагменты, например true, 123, \"high\" или {\"type\":\"object\"}",
|
||||
"override_rules": "Правила переопределения",
|
||||
"override_rules_desc": "Принудительно задавать значения параметров в запросе",
|
||||
"override_raw_rules": "Raw-правила переопределения",
|
||||
"override_raw_rules_desc": "Всегда перезаписывать значения как raw JSON-фрагменты; полезно для response_format, схем и других сложных полей",
|
||||
"filter_rules": "Правила фильтрации",
|
||||
"filter_rules_desc": "Предварительно фильтровать тело исходящего запроса через JSON Path, автоматически удалять несоответствующие или лишние параметры (санитизация запроса)"
|
||||
}
|
||||
@@ -1273,6 +1308,7 @@
|
||||
"value_number": "Числовое значение (например, 0.7)",
|
||||
"value_boolean": "true или false",
|
||||
"value_json": "Значение JSON",
|
||||
"value_raw_json": "Raw JSON-фрагмент, например true, 123, \"high\" или {\"type\":\"object\"}",
|
||||
"value_default": "Значение",
|
||||
"boolean_true": "true",
|
||||
"boolean_false": "false"
|
||||
@@ -1419,10 +1455,10 @@
|
||||
},
|
||||
"theme": {
|
||||
"switch": "Тема",
|
||||
"light": "Светлая",
|
||||
"light": "Шерстяная бумага",
|
||||
"white": "Чисто-белая",
|
||||
"dark": "Тёмная",
|
||||
"switch_to_light": "Переключиться на светлую тему",
|
||||
"switch_to_light": "Переключиться на тему «Шерстяная бумага»",
|
||||
"switch_to_dark": "Переключиться на тёмную тему",
|
||||
"auto": "Следовать системе"
|
||||
},
|
||||
|
||||
@@ -495,6 +495,13 @@
|
||||
"search_placeholder": "输入名称、类型或提供方关键字",
|
||||
"problem_filter_label": "问题筛选",
|
||||
"problem_filter_only": "仅显示有问题凭证",
|
||||
"display_options_label": "显示选项",
|
||||
"compact_mode_label": "简略模式",
|
||||
"sort_label": "排序",
|
||||
"sort_default": "默认",
|
||||
"sort_az": "A-Z 名称",
|
||||
"sort_priority": "优先级",
|
||||
"priority_display": "优先级",
|
||||
"page_size_label": "单页数量",
|
||||
"page_size_unit": "个/页",
|
||||
"view_mode_paged": "按页显示",
|
||||
@@ -544,12 +551,19 @@
|
||||
"batch_delete_confirm": "确定要删除 {{count}} 个文件吗?",
|
||||
"batch_selected": "已选 {{count}} 项",
|
||||
"batch_select_all": "全选",
|
||||
"batch_select_page": "全选当前页",
|
||||
"batch_select_filtered": "全选筛选结果",
|
||||
"batch_invert_page": "反选当前页",
|
||||
"batch_deselect": "取消选择",
|
||||
"batch_download": "下载选中",
|
||||
"batch_download_success": "已开始下载 {{count}} 个文件",
|
||||
"batch_download_partial": "下载完成:成功 {{success}} 个,失败 {{failed}} 个",
|
||||
"batch_enable": "启用",
|
||||
"batch_disable": "禁用",
|
||||
"prefix_proxy_button": "编辑认证文件字段",
|
||||
"auth_field_editor_title": "编辑认证文件字段 - {{name}}",
|
||||
"prefix_proxy_button": "认证文件详情 / 编辑",
|
||||
"auth_field_editor_title": "认证文件详情 / 编辑 - {{name}}",
|
||||
"prefix_proxy_loading": "正在加载认证文件...",
|
||||
"prefix_proxy_info_label": "认证文件信息(info)",
|
||||
"prefix_proxy_source_label": "认证文件 JSON(预览)",
|
||||
"prefix_label": "前缀(prefix)",
|
||||
"proxy_url_label": "代理 URL(proxy_url)",
|
||||
@@ -564,6 +578,10 @@
|
||||
"disable_cooling_label": "禁用冷却(disable_cooling)",
|
||||
"disable_cooling_placeholder": "例如: true / false / 1 / 0",
|
||||
"disable_cooling_hint": "支持布尔值、0/非0 数字或字符串 true/false/1/0;无法解析时忽略。",
|
||||
"note_label": "备注(note)",
|
||||
"note_placeholder": "输入备注信息,例如:张三的账号",
|
||||
"note_hint": "可选,用于标记凭证用途或归属;留空则不写入。",
|
||||
"note_display": "备注",
|
||||
"prefix_proxy_invalid_json": "该认证文件不是 JSON 对象,无法编辑字段。",
|
||||
"prefix_proxy_saved_success": "已更新认证文件 \"{{name}}\"",
|
||||
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
|
||||
@@ -630,7 +648,8 @@
|
||||
"plan_label": "套餐",
|
||||
"plan_plus": "Plus",
|
||||
"plan_team": "Team",
|
||||
"plan_free": "Free"
|
||||
"plan_free": "Free",
|
||||
"plan_pro": "Pro"
|
||||
},
|
||||
"gemini_cli_quota": {
|
||||
"title": "Gemini CLI 额度",
|
||||
@@ -644,7 +663,15 @@
|
||||
"empty_buckets": "暂无额度数据",
|
||||
"refresh_button": "刷新额度",
|
||||
"fetch_all": "获取全部",
|
||||
"remaining_amount": "剩余 {{count}}"
|
||||
"remaining_amount": "剩余 {{count}}",
|
||||
"tier_label": "层级",
|
||||
"tier_free": "免费版",
|
||||
"tier_legacy": "旧版",
|
||||
"tier_standard": "标准版",
|
||||
"tier_pro": "Pro",
|
||||
"tier_ultra": "Ultra",
|
||||
"credit_label": "Google One AI 积分",
|
||||
"credit_amount": "{{count}} 积分"
|
||||
},
|
||||
"kimi_quota": {
|
||||
"title": "Kimi 额度",
|
||||
@@ -1134,6 +1161,8 @@
|
||||
"source": "源文件编辑"
|
||||
},
|
||||
"visual": {
|
||||
"notice": "可视化模式覆盖常用字段,未覆盖的配置仍需在源文件模式中查看或编辑。",
|
||||
"quick_jump": "快速跳转",
|
||||
"sections": {
|
||||
"server": {
|
||||
"title": "服务器配置",
|
||||
@@ -1184,6 +1213,8 @@
|
||||
"description": "代理、重试和路由设置",
|
||||
"proxy_url": "代理 URL",
|
||||
"request_retry": "请求重试次数",
|
||||
"max_retry_credentials": "最大重试凭据数",
|
||||
"max_retry_credentials_hint": "留空表示不设置;设为 0 表示保留 legacy 行为,并尝试所有可用凭据。",
|
||||
"max_retry_interval": "最大重试间隔 (秒)",
|
||||
"routing_strategy": "路由策略",
|
||||
"routing_strategy_hint": "选择凭据选择策略",
|
||||
@@ -1215,11 +1246,15 @@
|
||||
},
|
||||
"payload": {
|
||||
"title": "Payload 配置",
|
||||
"description": "默认值、覆盖规则与过滤规则",
|
||||
"description": "默认值、原始 JSON 规则、覆盖规则与过滤规则",
|
||||
"default_rules": "默认规则",
|
||||
"default_rules_desc": "当请求中未指定参数时,使用这些默认值",
|
||||
"default_raw_rules": "默认 Raw 规则",
|
||||
"default_raw_rules_desc": "当请求中未指定参数时,按原始 JSON 片段写入这些值,例如 true、123、\"high\" 或 {\"type\":\"object\"}",
|
||||
"override_rules": "覆盖规则",
|
||||
"override_rules_desc": "强制覆盖请求中的参数值",
|
||||
"override_raw_rules": "覆盖 Raw 规则",
|
||||
"override_raw_rules_desc": "始终按原始 JSON 片段覆盖参数值,适合 response_format、schema 等复杂字段",
|
||||
"filter_rules": "过滤规则",
|
||||
"filter_rules_desc": "通过 JSON Path 预过滤上游请求体,自动剔除不合规/冗余参数(Request Sanitization)"
|
||||
}
|
||||
@@ -1268,6 +1303,7 @@
|
||||
"value_number": "数字值 (如 0.7)",
|
||||
"value_boolean": "true 或 false",
|
||||
"value_json": "JSON 值",
|
||||
"value_raw_json": "原始 JSON 片段,如 true、123、\"high\" 或 {\"type\":\"object\"}",
|
||||
"value_default": "值",
|
||||
"boolean_true": "true",
|
||||
"boolean_false": "false"
|
||||
@@ -1414,10 +1450,10 @@
|
||||
},
|
||||
"theme": {
|
||||
"switch": "主题",
|
||||
"light": "亮色",
|
||||
"light": "羊毛纸",
|
||||
"white": "纯白",
|
||||
"dark": "暗色",
|
||||
"switch_to_light": "切换到亮色模式",
|
||||
"switch_to_light": "切换到羊毛纸模式",
|
||||
"switch_to_dark": "切换到暗色模式",
|
||||
"auto": "跟随系统"
|
||||
},
|
||||
|
||||
@@ -381,7 +381,6 @@ export function AiProvidersPage() {
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSwitching={isSwitching}
|
||||
resolvedTheme={resolvedTheme}
|
||||
onAdd={() => openEditor('/ai-providers/codex/new')}
|
||||
onEdit={(index) => openEditor(`/ai-providers/codex/${index}`)}
|
||||
onDelete={(index) => void deleteProviderEntry('codex', index)}
|
||||
|
||||
@@ -170,19 +170,19 @@
|
||||
}
|
||||
|
||||
.modelItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-radius: $radius-sm;
|
||||
transition: background-color $transition-fast;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
&:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,7 @@
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modelId {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
|
||||
import { AutocompleteInput } from '@/components/ui/AutocompleteInput';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { IconInfo } from '@/components/ui/icons';
|
||||
@@ -400,20 +401,22 @@ export function AuthFilesOAuthExcludedEditPage() {
|
||||
{modelsList.map((model) => {
|
||||
const checked = selectedModels.has(model.id);
|
||||
return (
|
||||
<label key={model.id} className={styles.modelItem}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disableControls || saving}
|
||||
onChange={(event) => toggleModel(model.id, event.target.checked)}
|
||||
/>
|
||||
<span className={styles.modelText}>
|
||||
<span className={styles.modelId}>{model.id}</span>
|
||||
{model.display_name && model.display_name !== model.id && (
|
||||
<span className={styles.modelDisplayName}>{model.display_name}</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<SelectionCheckbox
|
||||
key={model.id}
|
||||
checked={checked}
|
||||
disabled={disableControls || saving}
|
||||
onChange={(value) => toggleModel(model.id, value)}
|
||||
className={styles.modelItem}
|
||||
labelClassName={styles.modelText}
|
||||
label={
|
||||
<>
|
||||
<span className={styles.modelId}>{model.id}</span>
|
||||
{model.display_name && model.display_name !== model.id && (
|
||||
<span className={styles.modelDisplayName}>{model.display_name}</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
+975
-129
File diff suppressed because it is too large
Load Diff
+337
-165
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
useCallback,
|
||||
type CSSProperties,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
@@ -18,6 +19,8 @@ import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer'
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { IconFilterAll } from '@/components/ui/icons';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
@@ -26,16 +29,17 @@ import {
|
||||
MIN_CARD_PAGE_SIZE,
|
||||
QUOTA_PROVIDER_TYPES,
|
||||
clampCardPageSize,
|
||||
getAuthFileIcon,
|
||||
getTypeColor,
|
||||
getTypeLabel,
|
||||
hasAuthFileStatusMessage,
|
||||
isRuntimeOnlyAuthFile,
|
||||
normalizeProviderKey,
|
||||
parsePriorityValue,
|
||||
type QuotaProviderType,
|
||||
type ResolvedTheme,
|
||||
} from '@/features/authFiles/constants';
|
||||
import { AuthFileCard } from '@/features/authFiles/components/AuthFileCard';
|
||||
import { AuthFileDetailModal } from '@/features/authFiles/components/AuthFileDetailModal';
|
||||
import { AuthFileModelsModal } from '@/features/authFiles/components/AuthFileModelsModal';
|
||||
import { AuthFilesPrefixProxyEditorModal } from '@/features/authFiles/components/AuthFilesPrefixProxyEditorModal';
|
||||
import { OAuthExcludedCard } from '@/features/authFiles/components/OAuthExcludedCard';
|
||||
@@ -46,15 +50,21 @@ import { useAuthFilesOauth } from '@/features/authFiles/hooks/useAuthFilesOauth'
|
||||
import { useAuthFilesPrefixProxyEditor } from '@/features/authFiles/hooks/useAuthFilesPrefixProxyEditor';
|
||||
import { useAuthFilesStats } from '@/features/authFiles/hooks/useAuthFilesStats';
|
||||
import { useAuthFilesStatusBarCache } from '@/features/authFiles/hooks/useAuthFilesStatusBarCache';
|
||||
import { readAuthFilesUiState, writeAuthFilesUiState } from '@/features/authFiles/uiState';
|
||||
import {
|
||||
isAuthFilesSortMode,
|
||||
readAuthFilesUiState,
|
||||
writeAuthFilesUiState,
|
||||
type AuthFilesSortMode,
|
||||
} from '@/features/authFiles/uiState';
|
||||
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import styles from './AuthFilesPage.module.scss';
|
||||
|
||||
const easePower3Out = (progress: number) => 1 - (1 - progress) ** 4;
|
||||
const easePower2In = (progress: number) => progress ** 3;
|
||||
const BATCH_BAR_BASE_TRANSFORM = 'translateX(-50%)';
|
||||
const BATCH_BAR_HIDDEN_TRANSFORM = 'translateX(-50%) translateY(56px)';
|
||||
const DEFAULT_REGULAR_PAGE_SIZE = 9;
|
||||
const DEFAULT_COMPACT_PAGE_SIZE = 12;
|
||||
|
||||
export function AuthFilesPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -67,13 +77,16 @@ export function AuthFilesPage() {
|
||||
|
||||
const [filter, setFilter] = useState<'all' | string>('all');
|
||||
const [problemOnly, setProblemOnly] = useState(false);
|
||||
const [compactMode, setCompactMode] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(9);
|
||||
const [pageSizeByMode, setPageSizeByMode] = useState({
|
||||
regular: DEFAULT_REGULAR_PAGE_SIZE,
|
||||
compact: DEFAULT_COMPACT_PAGE_SIZE,
|
||||
});
|
||||
const [pageSizeInput, setPageSizeInput] = useState('9');
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<AuthFileItem | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
|
||||
const [sortMode, setSortMode] = useState<AuthFilesSortMode>('default');
|
||||
const [batchActionBarVisible, setBatchActionBarVisible] = useState(false);
|
||||
const floatingBatchActionsRef = useRef<HTMLDivElement>(null);
|
||||
const batchActionAnimationRef = useRef<AnimationPlaybackControlsWithThen | null>(null);
|
||||
@@ -91,6 +104,7 @@ export function AuthFilesPage() {
|
||||
deleting,
|
||||
deletingAll,
|
||||
statusUpdating,
|
||||
batchStatusUpdating,
|
||||
fileInputRef,
|
||||
loadFiles,
|
||||
handleUploadClick,
|
||||
@@ -101,7 +115,9 @@ export function AuthFilesPage() {
|
||||
handleStatusToggle,
|
||||
toggleSelect,
|
||||
selectAllVisible,
|
||||
invertVisibleSelection,
|
||||
deselectAll,
|
||||
batchDownload,
|
||||
batchSetStatus,
|
||||
batchDelete,
|
||||
} = useAuthFilesData({ refreshKeyStats });
|
||||
@@ -157,6 +173,7 @@ export function AuthFilesPage() {
|
||||
)
|
||||
? (normalizedFilter as QuotaProviderType)
|
||||
: null;
|
||||
const pageSize = compactMode ? pageSizeByMode.compact : pageSizeByMode.regular;
|
||||
|
||||
useEffect(() => {
|
||||
const persisted = readAuthFilesUiState();
|
||||
@@ -168,25 +185,63 @@ export function AuthFilesPage() {
|
||||
if (typeof persisted.problemOnly === 'boolean') {
|
||||
setProblemOnly(persisted.problemOnly);
|
||||
}
|
||||
if (typeof persisted.compactMode === 'boolean') {
|
||||
setCompactMode(persisted.compactMode);
|
||||
}
|
||||
if (typeof persisted.search === 'string') {
|
||||
setSearch(persisted.search);
|
||||
}
|
||||
if (typeof persisted.page === 'number' && Number.isFinite(persisted.page)) {
|
||||
setPage(Math.max(1, Math.round(persisted.page)));
|
||||
}
|
||||
if (typeof persisted.pageSize === 'number' && Number.isFinite(persisted.pageSize)) {
|
||||
setPageSize(clampCardPageSize(persisted.pageSize));
|
||||
const legacyPageSize =
|
||||
typeof persisted.pageSize === 'number' && Number.isFinite(persisted.pageSize)
|
||||
? clampCardPageSize(persisted.pageSize)
|
||||
: null;
|
||||
const regularPageSize =
|
||||
typeof persisted.regularPageSize === 'number' && Number.isFinite(persisted.regularPageSize)
|
||||
? clampCardPageSize(persisted.regularPageSize)
|
||||
: legacyPageSize ?? DEFAULT_REGULAR_PAGE_SIZE;
|
||||
const compactPageSize =
|
||||
typeof persisted.compactPageSize === 'number' && Number.isFinite(persisted.compactPageSize)
|
||||
? clampCardPageSize(persisted.compactPageSize)
|
||||
: legacyPageSize ?? DEFAULT_COMPACT_PAGE_SIZE;
|
||||
setPageSizeByMode({
|
||||
regular: regularPageSize,
|
||||
compact: compactPageSize,
|
||||
});
|
||||
if (isAuthFilesSortMode(persisted.sortMode)) {
|
||||
setSortMode(persisted.sortMode);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
writeAuthFilesUiState({ filter, problemOnly, search, page, pageSize });
|
||||
}, [filter, problemOnly, search, page, pageSize]);
|
||||
writeAuthFilesUiState({
|
||||
filter,
|
||||
problemOnly,
|
||||
compactMode,
|
||||
search,
|
||||
page,
|
||||
pageSize,
|
||||
regularPageSize: pageSizeByMode.regular,
|
||||
compactPageSize: pageSizeByMode.compact,
|
||||
sortMode,
|
||||
});
|
||||
}, [filter, problemOnly, compactMode, search, page, pageSize, pageSizeByMode, sortMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageSizeInput(String(pageSize));
|
||||
}, [pageSize]);
|
||||
|
||||
const setCurrentModePageSize = useCallback(
|
||||
(next: number) => {
|
||||
setPageSizeByMode((current) =>
|
||||
compactMode ? { ...current, compact: next } : { ...current, regular: next }
|
||||
);
|
||||
},
|
||||
[compactMode]
|
||||
);
|
||||
|
||||
const commitPageSizeInput = (rawValue: string) => {
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) {
|
||||
@@ -201,7 +256,7 @@ export function AuthFilesPage() {
|
||||
}
|
||||
|
||||
const next = clampCardPageSize(value);
|
||||
setPageSize(next);
|
||||
setCurrentModePageSize(next);
|
||||
setPageSizeInput(String(next));
|
||||
setPage(1);
|
||||
};
|
||||
@@ -219,10 +274,20 @@ export function AuthFilesPage() {
|
||||
const rounded = Math.round(parsed);
|
||||
if (rounded < MIN_CARD_PAGE_SIZE || rounded > MAX_CARD_PAGE_SIZE) return;
|
||||
|
||||
setPageSize(rounded);
|
||||
setCurrentModePageSize(rounded);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSortModeChange = useCallback(
|
||||
(value: string) => {
|
||||
if (!isAuthFilesSortMode(value) || value === sortMode) return;
|
||||
setSortMode(value);
|
||||
setPage(1);
|
||||
void loadFiles().catch(() => {});
|
||||
},
|
||||
[loadFiles, sortMode]
|
||||
);
|
||||
|
||||
const handleHeaderRefresh = useCallback(async () => {
|
||||
await Promise.all([loadFiles(), refreshKeyStats(), loadExcluded(), loadModelAlias()]);
|
||||
}, [loadFiles, refreshKeyStats, loadExcluded, loadModelAlias]);
|
||||
@@ -259,6 +324,15 @@ export function AuthFilesPage() {
|
||||
[files, problemOnly]
|
||||
);
|
||||
|
||||
const sortOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'default', label: t('auth_files.sort_default') },
|
||||
{ value: 'az', label: t('auth_files.sort_az') },
|
||||
{ value: 'priority', label: t('auth_files.sort_priority') },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const typeCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = { all: filesMatchingProblemFilter.length };
|
||||
filesMatchingProblemFilter.forEach((file) => {
|
||||
@@ -281,20 +355,50 @@ export function AuthFilesPage() {
|
||||
});
|
||||
}, [filesMatchingProblemFilter, filter, search]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const sorted = useMemo(() => {
|
||||
const copy = [...filtered];
|
||||
if (sortMode === 'default') {
|
||||
copy.sort((a, b) => {
|
||||
const providerA = normalizeProviderKey(String(a.provider ?? a.type ?? 'unknown'));
|
||||
const providerB = normalizeProviderKey(String(b.provider ?? b.type ?? 'unknown'));
|
||||
const providerCompare = providerA.localeCompare(providerB);
|
||||
if (providerCompare !== 0) return providerCompare;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
} else if (sortMode === 'az') {
|
||||
copy.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} else if (sortMode === 'priority') {
|
||||
copy.sort((a, b) => {
|
||||
const pa = parsePriorityValue(a.priority ?? a['priority']) ?? 0;
|
||||
const pb = parsePriorityValue(b.priority ?? b['priority']) ?? 0;
|
||||
return pb - pa; // 高优先级排前面
|
||||
});
|
||||
}
|
||||
return copy;
|
||||
}, [filtered, sortMode]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
const pageItems = filtered.slice(start, start + pageSize);
|
||||
const pageItems = sorted.slice(start, start + pageSize);
|
||||
const selectablePageItems = useMemo(
|
||||
() => pageItems.filter((file) => !isRuntimeOnlyAuthFile(file)),
|
||||
[pageItems]
|
||||
);
|
||||
const selectableFilteredItems = useMemo(
|
||||
() => sorted.filter((file) => !isRuntimeOnlyAuthFile(file)),
|
||||
[sorted]
|
||||
);
|
||||
const selectedNames = useMemo(() => Array.from(selectedFiles), [selectedFiles]);
|
||||
|
||||
const showDetails = (file: AuthFileItem) => {
|
||||
setSelectedFile(file);
|
||||
setDetailModalOpen(true);
|
||||
};
|
||||
const selectedHasStatusUpdating = useMemo(
|
||||
() => selectedNames.some((name) => statusUpdating[name] === true),
|
||||
[selectedNames, statusUpdating]
|
||||
);
|
||||
const batchStatusButtonsDisabled =
|
||||
disableControls ||
|
||||
selectedNames.length === 0 ||
|
||||
batchStatusUpdating ||
|
||||
selectedHasStatusUpdating;
|
||||
|
||||
const copyTextWithNotification = useCallback(
|
||||
async (text: string) => {
|
||||
@@ -430,33 +534,54 @@ export function AuthFilesPage() {
|
||||
);
|
||||
|
||||
const renderFilterTags = () => (
|
||||
<div className={styles.filterTags}>
|
||||
{existingTypes.map((type) => {
|
||||
const isActive = filter === type;
|
||||
const color =
|
||||
type === 'all'
|
||||
? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' }
|
||||
: getTypeColor(type, resolvedTheme);
|
||||
const activeTextColor = resolvedTheme === 'dark' ? '#111827' : '#fff';
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
className={`${styles.filterTag} ${isActive ? styles.filterTagActive : ''}`}
|
||||
style={{
|
||||
backgroundColor: isActive ? color.text : color.bg,
|
||||
color: isActive ? activeTextColor : color.text,
|
||||
borderColor: color.text,
|
||||
}}
|
||||
onClick={() => {
|
||||
setFilter(type);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<span className={styles.filterTagLabel}>{getTypeLabel(t, type)}</span>
|
||||
<span className={styles.filterTagCount}>{typeCounts[type] ?? 0}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className={styles.filterRail}>
|
||||
<div className={styles.filterTags}>
|
||||
{existingTypes.map((type) => {
|
||||
const isActive = filter === type;
|
||||
const iconSrc = getAuthFileIcon(type, resolvedTheme);
|
||||
const color =
|
||||
type === 'all'
|
||||
? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' }
|
||||
: getTypeColor(type, resolvedTheme);
|
||||
const buttonStyle = {
|
||||
'--filter-color': color.text,
|
||||
'--filter-surface': color.bg,
|
||||
'--filter-active-text': resolvedTheme === 'dark' ? '#111827' : '#ffffff',
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
className={`${styles.filterTag} ${isActive ? styles.filterTagActive : ''}`}
|
||||
style={buttonStyle}
|
||||
onClick={() => {
|
||||
setFilter(type);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<span className={styles.filterTagLabel}>
|
||||
{type === 'all' ? (
|
||||
<span className={`${styles.filterTagIconWrap} ${styles.filterAllIconWrap}`}>
|
||||
<IconFilterAll className={styles.filterAllIcon} size={16} />
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.filterTagIconWrap}>
|
||||
{iconSrc ? (
|
||||
<img src={iconSrc} alt="" className={styles.filterTagIcon} />
|
||||
) : (
|
||||
<span className={styles.filterTagIconFallback}>
|
||||
{getTypeLabel(t, type).slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.filterTagText}>{getTypeLabel(t, type)}</span>
|
||||
</span>
|
||||
<span className={styles.filterTagCount}>{typeCounts[type] ?? 0}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -529,119 +654,148 @@ export function AuthFilesPage() {
|
||||
<div className={styles.filterSection}>
|
||||
{renderFilterTags()}
|
||||
|
||||
<div className={styles.filterControls}>
|
||||
<div className={styles.filterItem}>
|
||||
<label>{t('auth_files.search_label')}</label>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder={t('auth_files.search_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterItem}>
|
||||
<label>{t('auth_files.page_size_label')}</label>
|
||||
<input
|
||||
className={styles.pageSizeSelect}
|
||||
type="number"
|
||||
min={MIN_CARD_PAGE_SIZE}
|
||||
max={MAX_CARD_PAGE_SIZE}
|
||||
step={1}
|
||||
value={pageSizeInput}
|
||||
onChange={handlePageSizeChange}
|
||||
onBlur={(e) => commitPageSizeInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.filterItem} ${styles.filterToggleItem}`}>
|
||||
<label>{t('auth_files.problem_filter_label')}</label>
|
||||
<div className={styles.filterToggle}>
|
||||
<ToggleSwitch
|
||||
checked={problemOnly}
|
||||
onChange={(value) => {
|
||||
setProblemOnly(value);
|
||||
setPage(1);
|
||||
}}
|
||||
ariaLabel={t('auth_files.problem_filter_only')}
|
||||
label={
|
||||
<span className={styles.filterToggleLabel}>
|
||||
{t('auth_files.problem_filter_only')}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div className={styles.filterContent}>
|
||||
<div className={styles.filterControlsPanel}>
|
||||
<div className={styles.filterControls}>
|
||||
<div className={styles.filterItem}>
|
||||
<label>{t('auth_files.search_label')}</label>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder={t('auth_files.search_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterItem}>
|
||||
<label>{t('auth_files.page_size_label')}</label>
|
||||
<input
|
||||
className={styles.pageSizeSelect}
|
||||
type="number"
|
||||
min={MIN_CARD_PAGE_SIZE}
|
||||
max={MAX_CARD_PAGE_SIZE}
|
||||
step={1}
|
||||
value={pageSizeInput}
|
||||
onChange={handlePageSizeChange}
|
||||
onBlur={(e) => commitPageSizeInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterItem}>
|
||||
<label>{t('auth_files.sort_label')}</label>
|
||||
<Select
|
||||
className={styles.sortSelect}
|
||||
value={sortMode}
|
||||
options={sortOptions}
|
||||
onChange={handleSortModeChange}
|
||||
ariaLabel={t('auth_files.sort_label')}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.filterItem} ${styles.filterToggleItem}`}>
|
||||
<label>{t('auth_files.display_options_label')}</label>
|
||||
<div className={styles.filterToggleGroup}>
|
||||
<div className={styles.filterToggleCard}>
|
||||
<ToggleSwitch
|
||||
checked={problemOnly}
|
||||
onChange={(value) => {
|
||||
setProblemOnly(value);
|
||||
setPage(1);
|
||||
}}
|
||||
ariaLabel={t('auth_files.problem_filter_only')}
|
||||
label={
|
||||
<span className={styles.filterToggleLabel}>
|
||||
{t('auth_files.problem_filter_only')}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterToggleCard}>
|
||||
<ToggleSwitch
|
||||
checked={compactMode}
|
||||
onChange={(value) => setCompactMode(value)}
|
||||
ariaLabel={t('auth_files.compact_mode_label')}
|
||||
label={
|
||||
<span className={styles.filterToggleLabel}>
|
||||
{t('auth_files.compact_mode_label')}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : pageItems.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('auth_files.search_empty_title')}
|
||||
description={t('auth_files.search_empty_desc')}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`${styles.fileGrid} ${quotaFilterType ? styles.fileGridQuotaManaged : ''} ${compactMode ? styles.fileGridCompact : ''}`}
|
||||
>
|
||||
{pageItems.map((file) => (
|
||||
<AuthFileCard
|
||||
key={file.name}
|
||||
file={file}
|
||||
compact={compactMode}
|
||||
selected={selectedFiles.has(file.name)}
|
||||
resolvedTheme={resolvedTheme}
|
||||
disableControls={disableControls}
|
||||
deleting={deleting}
|
||||
statusUpdating={statusUpdating}
|
||||
quotaFilterType={quotaFilterType}
|
||||
keyStats={keyStats}
|
||||
statusBarCache={statusBarCache}
|
||||
onShowModels={showModels}
|
||||
onDownload={handleDownload}
|
||||
onOpenPrefixProxyEditor={openPrefixProxyEditor}
|
||||
onDelete={handleDelete}
|
||||
onToggleStatus={handleStatusToggle}
|
||||
onToggleSelect={toggleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && sorted.length > pageSize && (
|
||||
<div className={styles.pagination}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
{t('auth_files.pagination_prev')}
|
||||
</Button>
|
||||
<div className={styles.pageInfo}>
|
||||
{t('auth_files.pagination_info', {
|
||||
current: currentPage,
|
||||
total: totalPages,
|
||||
count: sorted.length,
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage >= totalPages}
|
||||
>
|
||||
{t('auth_files.pagination_next')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : pageItems.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('auth_files.search_empty_title')}
|
||||
description={t('auth_files.search_empty_desc')}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`${styles.fileGrid} ${quotaFilterType ? styles.fileGridQuotaManaged : ''}`}
|
||||
>
|
||||
{pageItems.map((file) => (
|
||||
<AuthFileCard
|
||||
key={file.name}
|
||||
file={file}
|
||||
selected={selectedFiles.has(file.name)}
|
||||
resolvedTheme={resolvedTheme}
|
||||
disableControls={disableControls}
|
||||
deleting={deleting}
|
||||
statusUpdating={statusUpdating}
|
||||
quotaFilterType={quotaFilterType}
|
||||
keyStats={keyStats}
|
||||
statusBarCache={statusBarCache}
|
||||
onShowModels={showModels}
|
||||
onShowDetails={showDetails}
|
||||
onDownload={handleDownload}
|
||||
onOpenPrefixProxyEditor={openPrefixProxyEditor}
|
||||
onDelete={handleDelete}
|
||||
onToggleStatus={handleStatusToggle}
|
||||
onToggleSelect={toggleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filtered.length > pageSize && (
|
||||
<div className={styles.pagination}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
{t('auth_files.pagination_prev')}
|
||||
</Button>
|
||||
<div className={styles.pageInfo}>
|
||||
{t('auth_files.pagination_info', {
|
||||
current: currentPage,
|
||||
total: totalPages,
|
||||
count: filtered.length,
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage >= totalPages}
|
||||
>
|
||||
{t('auth_files.pagination_next')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<OAuthExcludedCard
|
||||
@@ -670,13 +824,6 @@ export function AuthFilesPage() {
|
||||
onDeleteAlias={handleDeleteAlias}
|
||||
/>
|
||||
|
||||
<AuthFileDetailModal
|
||||
open={detailModalOpen}
|
||||
file={selectedFile}
|
||||
onClose={() => setDetailModalOpen(false)}
|
||||
onCopyText={copyTextWithNotification}
|
||||
/>
|
||||
|
||||
<AuthFileModelsModal
|
||||
open={modelsModalOpen}
|
||||
fileName={modelsFileName}
|
||||
@@ -695,6 +842,7 @@ export function AuthFilesPage() {
|
||||
updatedText={prefixProxyUpdatedText}
|
||||
dirty={prefixProxyDirty}
|
||||
onClose={closePrefixProxyEditor}
|
||||
onCopyText={copyTextWithNotification}
|
||||
onSave={handlePrefixProxySave}
|
||||
onChange={handlePrefixProxyChange}
|
||||
/>
|
||||
@@ -713,17 +861,41 @@ export function AuthFilesPage() {
|
||||
onClick={() => selectAllVisible(pageItems)}
|
||||
disabled={selectablePageItems.length === 0}
|
||||
>
|
||||
{t('auth_files.batch_select_all')}
|
||||
{t('auth_files.batch_select_page')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => selectAllVisible(sorted)}
|
||||
disabled={selectableFilteredItems.length === 0}
|
||||
>
|
||||
{t('auth_files.batch_select_filtered')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => invertVisibleSelection(pageItems)}
|
||||
disabled={selectablePageItems.length === 0}
|
||||
>
|
||||
{t('auth_files.batch_invert_page')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={deselectAll}>
|
||||
{t('auth_files.batch_deselect')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.batchActionRight}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void batchDownload(selectedNames)}
|
||||
disabled={disableControls || selectedNames.length === 0}
|
||||
>
|
||||
{t('auth_files.batch_download')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => batchSetStatus(selectedNames, true)}
|
||||
disabled={disableControls || selectedNames.length === 0}
|
||||
disabled={batchStatusButtonsDisabled}
|
||||
>
|
||||
{t('auth_files.batch_enable')}
|
||||
</Button>
|
||||
@@ -731,7 +903,7 @@ export function AuthFilesPage() {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => batchSetStatus(selectedNames, false)}
|
||||
disabled={disableControls || selectedNames.length === 0}
|
||||
disabled={batchStatusButtonsDisabled}
|
||||
>
|
||||
{t('auth_files.batch_disable')}
|
||||
</Button>
|
||||
|
||||
+216
-143
@@ -1,27 +1,123 @@
|
||||
@use '../styles/mixins' as *;
|
||||
@use '../styles/variables' as *;
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
overflow-y: auto;
|
||||
padding-bottom: calc(
|
||||
var(--config-action-bar-height, 0px) + 16px + env(safe-area-inset-bottom) + #{$spacing-md}
|
||||
);
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 28px;
|
||||
.pageHeader {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) auto;
|
||||
gap: 24px;
|
||||
align-items: end;
|
||||
padding: clamp(22px, 3vw, 30px);
|
||||
border-radius: 30px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at top right,
|
||||
color-mix(in srgb, var(--primary-color) 14%, transparent),
|
||||
transparent 34%
|
||||
),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--bg-primary) 94%, transparent),
|
||||
color-mix(in srgb, var(--bg-secondary) 96%, transparent)
|
||||
);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
width: min(260px, 48vw);
|
||||
height: min(260px, 48vw);
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--primary-color) 12%, transparent),
|
||||
transparent 70%
|
||||
);
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.pageHeaderCopy {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pageEyebrow {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 $spacing-md 0;
|
||||
font-size: clamp(32px, 4vw, 52px);
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
max-width: 46rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 $spacing-xl 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.pageMeta {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
|
||||
@include mobile {
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 86%, transparent);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
|
||||
@include mobile {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.tabBar {
|
||||
@@ -29,10 +125,9 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
margin-bottom: $spacing-lg;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: $radius-full;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
@@ -44,53 +139,57 @@
|
||||
|
||||
@include mobile {
|
||||
width: 100%;
|
||||
|
||||
.tabItem {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabItem {
|
||||
@include button-reset;
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $radius-full;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
transform 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 92%, transparent);
|
||||
border-color: color-mix(in srgb, var(--border-color) 88%, transparent);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
@include mobile {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--border-color);
|
||||
border-color: color-mix(in srgb, var(--primary-color) 24%, var(--border-color));
|
||||
box-shadow: 0 16px 32px -28px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.workspaceShell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
padding: clamp(18px, 3vw, 28px);
|
||||
border-radius: 32px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 74%, transparent);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
@@ -98,57 +197,84 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sourceWorkspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sourceToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.searchInputWrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
// The shared Input component adds a wrapper (.form-group) with margin-bottom.
|
||||
// In the floating toolbar we want the input to be compact.
|
||||
:global(.form-group) {
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
border-radius: $radius-full !important;
|
||||
border-radius: 16px !important;
|
||||
padding-right: 132px !important;
|
||||
}
|
||||
|
||||
.searchCount {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
padding: 2px 8px;
|
||||
border-radius: $radius-full;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.searchRight {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.searchCount {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--bg-primary) 88%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
@include button-reset;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: $radius-full;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
background: var(--primary-color);
|
||||
border: 1px solid var(--primary-color);
|
||||
color: #fff;
|
||||
transition: background-color $transition-fast, border-color $transition-fast, opacity $transition-fast;
|
||||
transition:
|
||||
background-color $transition-fast,
|
||||
border-color $transition-fast,
|
||||
opacity $transition-fast;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
@@ -163,94 +289,58 @@
|
||||
|
||||
.searchActions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
|
||||
button {
|
||||
min-width: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 36px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0 !important;
|
||||
border-radius: $radius-full;
|
||||
border-radius: 999px;
|
||||
}
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
justify-content: stretch;
|
||||
|
||||
.status {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
&.modified {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
&.saved {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: var(--danger-color);
|
||||
button {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editorWrapper {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
height: clamp(360px, 60vh, 920px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-lg;
|
||||
height: clamp(420px, 64vh, 980px);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
--floating-controls-height: 0px;
|
||||
background: color-mix(in srgb, var(--bg-primary) 90%, transparent);
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
height: clamp(360px, 60dvh, 920px);
|
||||
height: clamp(420px, 64dvh, 980px);
|
||||
}
|
||||
|
||||
// Floating search toolbar on top of the editor (but not covering content).
|
||||
.floatingControls {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
// CodeMirror theme overrides
|
||||
:global {
|
||||
.cm-editor {
|
||||
height: 100%;
|
||||
font-size: 14px;
|
||||
font-family: 'Consolas', 'Monaco', 'Menlo', monospace;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
overflow: auto;
|
||||
padding-top: calc(var(--floating-controls-height, 0px) + #{$spacing-md});
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-x pan-y;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.cm-gutters {
|
||||
border-right: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 88%, transparent);
|
||||
}
|
||||
|
||||
.cm-lineNumbers .cm-gutterElement {
|
||||
@@ -280,7 +370,6 @@
|
||||
background: rgba(255, 152, 0, 0.5);
|
||||
}
|
||||
|
||||
// Dark theme adjustments
|
||||
[data-theme='dark'] & {
|
||||
.cm-gutters {
|
||||
background: var(--bg-tertiary);
|
||||
@@ -293,26 +382,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
.configCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
.modified {
|
||||
color: var(--warning-text);
|
||||
background: var(--warning-bg);
|
||||
border-color: var(--warning-border);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
justify-content: flex-end;
|
||||
.saved {
|
||||
color: var(--success-color);
|
||||
background: color-mix(in srgb, var(--success-color) 12%, var(--bg-primary));
|
||||
border-color: color-mix(in srgb, var(--success-color) 28%, var(--border-color));
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
justify-content: stretch;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
.error {
|
||||
color: var(--warning-text);
|
||||
background: var(--warning-bg);
|
||||
border-color: var(--warning-border);
|
||||
}
|
||||
|
||||
.floatingActionContainer {
|
||||
@@ -347,11 +432,16 @@
|
||||
}
|
||||
|
||||
.floatingStatus {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 5px 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
background: color-mix(in srgb, var(--text-primary) 6%, transparent);
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
max-width: min(280px, 46vw);
|
||||
white-space: nowrap;
|
||||
@@ -370,7 +460,9 @@
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
transition: background-color 0.2s ease, transform 0.15s ease;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
transform 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--text-primary) 10%, transparent);
|
||||
@@ -420,22 +512,3 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 820px) {
|
||||
.pageTitle {
|
||||
font-size: 24px;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.configCard {
|
||||
padding: $spacing-md;
|
||||
}
|
||||
}
|
||||
|
||||
+162
-152
@@ -6,10 +6,15 @@ import { yaml } from '@codemirror/lang-yaml';
|
||||
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||
import { keymap } from '@codemirror/view';
|
||||
import { parse as parseYaml, parseDocument } from 'yaml';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { IconCheck, IconChevronDown, IconChevronUp, IconRefreshCw, IconSearch } from '@/components/ui/icons';
|
||||
import {
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconRefreshCw,
|
||||
IconSearch,
|
||||
} from '@/components/ui/icons';
|
||||
import { VisualConfigEditor } from '@/components/config/VisualConfigEditor';
|
||||
import { DiffModal } from '@/components/config/DiffModal';
|
||||
import { useVisualConfig } from '@/hooks/useVisualConfig';
|
||||
@@ -44,7 +49,7 @@ export function ConfigPage() {
|
||||
visualHasPayloadValidationErrors,
|
||||
loadVisualValuesFromYaml,
|
||||
applyVisualChangesToYaml,
|
||||
setVisualValues
|
||||
setVisualValues,
|
||||
} = useVisualConfig();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ConfigEditorTab>(() => {
|
||||
@@ -64,11 +69,12 @@ export function ConfigPage() {
|
||||
|
||||
// Search state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<{ current: number; total: number }>({ current: 0, total: 0 });
|
||||
const [searchResults, setSearchResults] = useState<{ current: number; total: number }>({
|
||||
current: 0,
|
||||
total: 0,
|
||||
});
|
||||
const [lastSearchedQuery, setLastSearchedQuery] = useState('');
|
||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||
const floatingControlsRef = useRef<HTMLDivElement>(null);
|
||||
const editorWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const floatingActionsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
@@ -154,7 +160,9 @@ export function ConfigPage() {
|
||||
if (latestDocument.errors.length > 0) {
|
||||
showNotification(
|
||||
t('config_management.visual_mode_latest_yaml_invalid', {
|
||||
message: latestDocument.errors[0]?.message ?? t('config_management.visual_mode_save_blocked')
|
||||
message:
|
||||
latestDocument.errors[0]?.message ??
|
||||
t('config_management.visual_mode_save_blocked'),
|
||||
}),
|
||||
'error'
|
||||
);
|
||||
@@ -174,7 +182,9 @@ export function ConfigPage() {
|
||||
try {
|
||||
const doc = parseDocument(latestServerYaml);
|
||||
diffOriginal = doc.toString({ indent: 2, lineWidth: 120, minContentWidth: 0 });
|
||||
} catch { /* keep raw on parse failure */ }
|
||||
} catch {
|
||||
/* keep raw on parse failure */
|
||||
}
|
||||
}
|
||||
|
||||
if (diffOriginal === nextMergedYaml) {
|
||||
@@ -203,32 +213,43 @@ export function ConfigPage() {
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleTabChange = useCallback((tab: ConfigEditorTab) => {
|
||||
if (tab === activeTab) return;
|
||||
const handleTabChange = useCallback(
|
||||
(tab: ConfigEditorTab) => {
|
||||
if (tab === activeTab) return;
|
||||
|
||||
if (tab === 'source') {
|
||||
// Only rewrite YAML when there are pending visual changes; otherwise preserve raw YAML + comments.
|
||||
if (visualDirty) {
|
||||
const nextContent = applyVisualChangesToYaml(content);
|
||||
if (nextContent !== content) {
|
||||
setContent(nextContent);
|
||||
setDirty(true);
|
||||
if (tab === 'source') {
|
||||
// Only rewrite YAML when there are pending visual changes; otherwise preserve raw YAML + comments.
|
||||
if (visualDirty) {
|
||||
const nextContent = applyVisualChangesToYaml(content);
|
||||
if (nextContent !== content) {
|
||||
setContent(nextContent);
|
||||
setDirty(true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const result = loadVisualValuesFromYaml(content);
|
||||
if (!result.ok) {
|
||||
showNotification(
|
||||
t('config_management.visual_mode_unavailable_detail', { message: result.error }),
|
||||
'error'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const result = loadVisualValuesFromYaml(content);
|
||||
if (!result.ok) {
|
||||
showNotification(
|
||||
t('config_management.visual_mode_unavailable_detail', { message: result.error }),
|
||||
'error'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setActiveTab(tab);
|
||||
localStorage.setItem('config-management:tab', tab);
|
||||
}, [activeTab, applyVisualChangesToYaml, content, loadVisualValuesFromYaml, showNotification, t, visualDirty]);
|
||||
setActiveTab(tab);
|
||||
localStorage.setItem('config-management:tab', tab);
|
||||
},
|
||||
[
|
||||
activeTab,
|
||||
applyVisualChangesToYaml,
|
||||
content,
|
||||
loadVisualValuesFromYaml,
|
||||
showNotification,
|
||||
t,
|
||||
visualDirty,
|
||||
]
|
||||
);
|
||||
|
||||
// Search functionality
|
||||
const performSearch = useCallback((query: string, direction: 'next' | 'prev' = 'next') => {
|
||||
@@ -290,7 +311,7 @@ export function ConfigPage() {
|
||||
// Scroll to and select the match
|
||||
view.dispatch({
|
||||
selection: { anchor: matchPos, head: matchPos + query.length },
|
||||
scrollIntoView: true
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
}, []);
|
||||
@@ -306,18 +327,24 @@ export function ConfigPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const executeSearch = useCallback((direction: 'next' | 'prev' = 'next') => {
|
||||
if (!searchQuery) return;
|
||||
setLastSearchedQuery(searchQuery);
|
||||
performSearch(searchQuery, direction);
|
||||
}, [searchQuery, performSearch]);
|
||||
const executeSearch = useCallback(
|
||||
(direction: 'next' | 'prev' = 'next') => {
|
||||
if (!searchQuery) return;
|
||||
setLastSearchedQuery(searchQuery);
|
||||
performSearch(searchQuery, direction);
|
||||
},
|
||||
[searchQuery, performSearch]
|
||||
);
|
||||
|
||||
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
executeSearch(e.shiftKey ? 'prev' : 'next');
|
||||
}
|
||||
}, [executeSearch]);
|
||||
const handleSearchKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
executeSearch(e.shiftKey ? 'prev' : 'next');
|
||||
}
|
||||
},
|
||||
[executeSearch]
|
||||
);
|
||||
|
||||
const handlePrevMatch = useCallback(() => {
|
||||
if (!lastSearchedQuery) return;
|
||||
@@ -329,31 +356,6 @@ export function ConfigPage() {
|
||||
performSearch(lastSearchedQuery, 'next');
|
||||
}, [lastSearchedQuery, performSearch]);
|
||||
|
||||
// Keep floating controls from covering editor content by syncing its height to a CSS variable.
|
||||
useLayoutEffect(() => {
|
||||
if (activeTab !== 'source') return;
|
||||
|
||||
const controlsEl = floatingControlsRef.current;
|
||||
const wrapperEl = editorWrapperRef.current;
|
||||
if (!controlsEl || !wrapperEl) return;
|
||||
|
||||
const updatePadding = () => {
|
||||
const height = controlsEl.getBoundingClientRect().height;
|
||||
wrapperEl.style.setProperty('--floating-controls-height', `${height}px`);
|
||||
};
|
||||
|
||||
updatePadding();
|
||||
window.addEventListener('resize', updatePadding);
|
||||
|
||||
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updatePadding);
|
||||
ro?.observe(controlsEl);
|
||||
|
||||
return () => {
|
||||
ro?.disconnect();
|
||||
window.removeEventListener('resize', updatePadding);
|
||||
};
|
||||
}, [activeTab]);
|
||||
|
||||
// Keep bottom floating actions from covering page content by syncing its height to a CSS variable.
|
||||
useLayoutEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
@@ -380,12 +382,10 @@ export function ConfigPage() {
|
||||
}, []);
|
||||
|
||||
// CodeMirror extensions
|
||||
const extensions = useMemo(() => [
|
||||
yaml(),
|
||||
search(),
|
||||
highlightSelectionMatches(),
|
||||
keymap.of(searchKeymap)
|
||||
], []);
|
||||
const extensions = useMemo(
|
||||
() => [yaml(), search(), highlightSelectionMatches(), keymap.of(searchKeymap)],
|
||||
[]
|
||||
);
|
||||
|
||||
// Status text
|
||||
const getStatusText = () => {
|
||||
@@ -393,21 +393,13 @@ export function ConfigPage() {
|
||||
if (loading) return t('config_management.status_loading');
|
||||
if (error) return t('config_management.status_load_failed');
|
||||
if (hasVisualModeError) return t('config_management.visual_mode_unavailable');
|
||||
if (hasVisualValidationErrors) return t('config_management.visual.validation.validation_blocked');
|
||||
if (hasVisualValidationErrors)
|
||||
return t('config_management.visual.validation.validation_blocked');
|
||||
if (saving) return t('config_management.status_saving');
|
||||
if (isDirty) return t('config_management.status_dirty');
|
||||
return t('config_management.status_loaded');
|
||||
};
|
||||
|
||||
const isLoadedStatus =
|
||||
!disableControls &&
|
||||
!loading &&
|
||||
!error &&
|
||||
!saving &&
|
||||
!isDirty &&
|
||||
!hasVisualModeError &&
|
||||
!hasVisualValidationErrors;
|
||||
|
||||
const getStatusClass = () => {
|
||||
if (error || hasVisualModeError || hasVisualValidationErrors) return styles.error;
|
||||
if (isDirty) return styles.modified;
|
||||
@@ -436,7 +428,7 @@ export function ConfigPage() {
|
||||
const floatingActions = (
|
||||
<div className={styles.floatingActionContainer} ref={floatingActionsRef}>
|
||||
<div className={styles.floatingActionList}>
|
||||
<div className={`${styles.floatingStatus} ${styles.status} ${getStatusClass()}`}>{getStatusText()}</div>
|
||||
<div className={`${styles.floatingStatus} ${getStatusClass()}`}>{getStatusText()}</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.floatingActionButton}
|
||||
@@ -470,31 +462,48 @@ export function ConfigPage() {
|
||||
</div>
|
||||
);
|
||||
|
||||
const pageEyebrow =
|
||||
activeTab === 'visual'
|
||||
? t('config_management.tabs.visual', { defaultValue: '可视化编辑' })
|
||||
: t('config_management.tabs.source', { defaultValue: '源文件编辑' });
|
||||
const pageDescription =
|
||||
activeTab === 'visual'
|
||||
? t('config_management.visual.notice')
|
||||
: t('config_management.description');
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.pageTitle}>{t('config_management.title')}</h1>
|
||||
<p className={styles.description}>{t('config_management.description')}</p>
|
||||
<div className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderCopy}>
|
||||
<span className={styles.pageEyebrow}>{pageEyebrow}</span>
|
||||
<h1 className={styles.pageTitle}>{t('config_management.title')}</h1>
|
||||
<p className={styles.description}>{pageDescription}</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.tabBar}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.tabItem} ${activeTab === 'visual' ? styles.tabActive : ''}`}
|
||||
onClick={() => handleTabChange('visual')}
|
||||
disabled={saving || loading}
|
||||
>
|
||||
{t('config_management.tabs.visual', { defaultValue: '可视化编辑' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.tabItem} ${activeTab === 'source' ? styles.tabActive : ''}`}
|
||||
onClick={() => handleTabChange('source')}
|
||||
disabled={saving || loading}
|
||||
>
|
||||
{t('config_management.tabs.source', { defaultValue: '源代码编辑' })}
|
||||
</button>
|
||||
<div className={styles.pageMeta}>
|
||||
<div className={`${styles.statusBadge} ${getStatusClass()}`}>{getStatusText()}</div>
|
||||
<div className={styles.tabBar}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.tabItem} ${activeTab === 'visual' ? styles.tabActive : ''}`}
|
||||
onClick={() => handleTabChange('visual')}
|
||||
disabled={saving || loading}
|
||||
>
|
||||
{t('config_management.tabs.visual', { defaultValue: '可视化编辑' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.tabItem} ${activeTab === 'source' ? styles.tabActive : ''}`}
|
||||
onClick={() => handleTabChange('source')}
|
||||
disabled={saving || loading}
|
||||
>
|
||||
{t('config_management.tabs.source', { defaultValue: '源代码编辑' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className={styles.configCard}>
|
||||
<div className={styles.workspaceShell}>
|
||||
<div className={styles.content}>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{!error && visualParseError && (
|
||||
@@ -507,20 +516,20 @@ export function ConfigPage() {
|
||||
<VisualConfigEditor
|
||||
values={visualValues}
|
||||
validationErrors={visualValidationErrors}
|
||||
hasPayloadValidationErrors={visualHasPayloadValidationErrors}
|
||||
disabled={disableControls || loading}
|
||||
onChange={setVisualValues}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.editorWrapper} ref={editorWrapperRef}>
|
||||
{/* Floating search controls */}
|
||||
<div className={styles.floatingControls} ref={floatingControlsRef}>
|
||||
<div className={styles.sourceWorkspace}>
|
||||
<div className={styles.sourceToolbar}>
|
||||
<div className={styles.searchInputWrapper}>
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder={t('config_management.search_placeholder', {
|
||||
defaultValue: '搜索配置内容...'
|
||||
defaultValue: '搜索配置内容...',
|
||||
})}
|
||||
disabled={disableControls || loading}
|
||||
className={styles.searchInput}
|
||||
@@ -530,7 +539,9 @@ export function ConfigPage() {
|
||||
<span className={styles.searchCount}>
|
||||
{searchResults.total > 0
|
||||
? `${searchResults.current} / ${searchResults.total}`
|
||||
: t('config_management.search_no_results', { defaultValue: '无结果' })}
|
||||
: t('config_management.search_no_results', {
|
||||
defaultValue: '无结果',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
@@ -546,12 +557,15 @@ export function ConfigPage() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.searchActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handlePrevMatch}
|
||||
disabled={!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0}
|
||||
disabled={
|
||||
!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0
|
||||
}
|
||||
title={t('config_management.search_prev', { defaultValue: '上一个' })}
|
||||
>
|
||||
<IconChevronUp size={16} />
|
||||
@@ -560,57 +574,53 @@ export function ConfigPage() {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleNextMatch}
|
||||
disabled={!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0}
|
||||
disabled={
|
||||
!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0
|
||||
}
|
||||
title={t('config_management.search_next', { defaultValue: '下一个' })}
|
||||
>
|
||||
<IconChevronDown size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={content}
|
||||
onChange={handleChange}
|
||||
extensions={extensions}
|
||||
theme={resolvedTheme}
|
||||
editable={!disableControls && !loading}
|
||||
placeholder={t('config_management.editor_placeholder')}
|
||||
height="100%"
|
||||
style={{ height: '100%' }}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
highlightActiveLineGutter: true,
|
||||
highlightActiveLine: true,
|
||||
foldGutter: true,
|
||||
dropCursor: true,
|
||||
allowMultipleSelections: true,
|
||||
indentOnInput: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: false,
|
||||
rectangularSelection: true,
|
||||
crosshairCursor: false,
|
||||
highlightSelectionMatches: true,
|
||||
closeBracketsKeymap: true,
|
||||
searchKeymap: true,
|
||||
foldKeymap: true,
|
||||
completionKeymap: false,
|
||||
lintKeymap: true
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={styles.editorWrapper}>
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={content}
|
||||
onChange={handleChange}
|
||||
extensions={extensions}
|
||||
theme={resolvedTheme}
|
||||
editable={!disableControls && !loading}
|
||||
placeholder={t('config_management.editor_placeholder')}
|
||||
height="100%"
|
||||
style={{ height: '100%' }}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
highlightActiveLineGutter: true,
|
||||
highlightActiveLine: true,
|
||||
foldGutter: true,
|
||||
dropCursor: true,
|
||||
allowMultipleSelections: true,
|
||||
indentOnInput: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: false,
|
||||
rectangularSelection: true,
|
||||
crosshairCursor: false,
|
||||
highlightSelectionMatches: true,
|
||||
closeBracketsKeymap: true,
|
||||
searchKeymap: true,
|
||||
foldKeymap: true,
|
||||
completionKeymap: false,
|
||||
lintKeymap: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className={styles.controls}>
|
||||
{!isLoadedStatus && (
|
||||
<span className={`${styles.status} ${getStatusClass()}`}>
|
||||
{getStatusText()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{typeof document !== 'undefined' ? createPortal(floatingActions, document.body) : null}
|
||||
<DiffModal
|
||||
|
||||
@@ -8,8 +8,7 @@ import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/se
|
||||
import { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import styles from './OAuthPage.module.scss';
|
||||
import iconCodexLight from '@/assets/icons/codex_light.svg';
|
||||
import iconCodexDark from '@/assets/icons/codex_drak.svg';
|
||||
import iconCodex from '@/assets/icons/codex.svg';
|
||||
import iconClaude from '@/assets/icons/claude.svg';
|
||||
import iconAntigravity from '@/assets/icons/antigravity.svg';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
@@ -73,7 +72,7 @@ function getErrorStatus(error: unknown): number | undefined {
|
||||
}
|
||||
|
||||
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [
|
||||
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconCodexLight, dark: iconCodexDark } },
|
||||
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: iconCodex },
|
||||
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
|
||||
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity },
|
||||
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini },
|
||||
|
||||
@@ -151,10 +151,11 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// 卡片渐变背景 — 基于 TYPE_COLORS light.bg 转 rgba
|
||||
.claudeCard {
|
||||
background-image: linear-gradient(180deg,
|
||||
rgba(252, 228, 236, 0.18),
|
||||
rgba(252, 228, 236, 0));
|
||||
rgba(251, 236, 228, 0.18),
|
||||
rgba(251, 236, 228, 0));
|
||||
}
|
||||
|
||||
.antigravityCard {
|
||||
@@ -165,20 +166,20 @@
|
||||
|
||||
.codexCard {
|
||||
background-image: linear-gradient(180deg,
|
||||
rgba(255, 243, 224, 0.18),
|
||||
rgba(255, 243, 224, 0));
|
||||
rgba(234, 231, 255, 0.18),
|
||||
rgba(234, 231, 255, 0));
|
||||
}
|
||||
|
||||
.geminiCliCard {
|
||||
background-image: linear-gradient(180deg,
|
||||
rgba(231, 239, 255, 0.2),
|
||||
rgba(231, 239, 255, 0));
|
||||
rgba(224, 232, 255, 0.2),
|
||||
rgba(224, 232, 255, 0));
|
||||
}
|
||||
|
||||
.kimiCard {
|
||||
background-image: linear-gradient(180deg,
|
||||
rgba(255, 244, 229, 0.2),
|
||||
rgba(255, 244, 229, 0));
|
||||
rgba(220, 232, 255, 0.2),
|
||||
rgba(220, 232, 255, 0));
|
||||
}
|
||||
|
||||
.quotaSection {
|
||||
@@ -318,6 +319,165 @@
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.premiumPlanValue {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
overflow: visible;
|
||||
isolation: isolate;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 18% 24%,
|
||||
rgba(255, 255, 255, 0.96) 0%,
|
||||
rgba(255, 255, 255, 0.72) 18%,
|
||||
rgba(255, 255, 255, 0) 42%
|
||||
),
|
||||
linear-gradient(135deg, #fff9e3 0%, #ffe07f 52%, #e0aa14 100%);
|
||||
border: 1px solid rgba(217, 165, 22, 0.72);
|
||||
box-shadow:
|
||||
0 1px 3px rgba(133, 92, 0, 0.16),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.22) inset,
|
||||
0 0 16px rgba(255, 214, 98, 0.28);
|
||||
color: #6b4b00;
|
||||
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.55);
|
||||
text-transform: capitalize;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -6px -9px;
|
||||
border-radius: inherit;
|
||||
background: radial-gradient(
|
||||
circle at 18% 22%,
|
||||
rgba(255, 255, 255, 0.9) 0%,
|
||||
rgba(255, 237, 158, 0.58) 32%,
|
||||
rgba(255, 215, 91, 0) 72%
|
||||
);
|
||||
filter: blur(9px);
|
||||
opacity: 0.75;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@media (dynamic-range: high) {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 18% 24%,
|
||||
color(display-p3 1 0.99 0.94) 0%,
|
||||
color(display-p3 1 0.97 0.82 / 0.82) 18%,
|
||||
color(display-p3 1 0.95 0.7 / 0) 42%
|
||||
),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
color(display-p3 1 0.98 0.88),
|
||||
color(display-p3 0.99 0.86 0.34),
|
||||
color(display-p3 0.92 0.68 0.05)
|
||||
);
|
||||
border-color: color(display-p3 0.9 0.73 0.12 / 0.85);
|
||||
box-shadow:
|
||||
0 1px 4px color(display-p3 0.45 0.28 0 / 0.22),
|
||||
0 0 0 1px color(display-p3 1 0.98 0.86 / 0.3) inset,
|
||||
0 0 18px color(display-p3 1 0.89 0.2 / 0.36);
|
||||
color: color(display-p3 0.43 0.3 0);
|
||||
}
|
||||
|
||||
@supports (color: #{'color(rec2100-linear 1 1 1)'}) {
|
||||
@media (dynamic-range: high) {
|
||||
dynamic-range-limit: no-limit;
|
||||
box-shadow:
|
||||
0 1px 4px color(display-p3 0.45 0.28 0 / 0.22),
|
||||
0 0 0 1px color(display-p3 1 0.98 0.86 / 0.3) inset,
|
||||
0 0 14px color(display-p3 1 0.89 0.2 / 0.36),
|
||||
0 0 30px #{'color(rec2100-linear 3.6 2.9 0.7 / 0.48)'};
|
||||
|
||||
&::before {
|
||||
background: radial-gradient(
|
||||
circle at 18% 22%,
|
||||
#{'color(rec2100-linear 6.5 6.2 5.4 / 0.92)'} 0%,
|
||||
#{'color(rec2100-linear 2.4 2 0.6 / 0.62)'} 34%,
|
||||
#{'color(rec2100-linear 1 0.85 0.12 / 0)'} 76%
|
||||
);
|
||||
opacity: 0.92;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global([data-theme='dark']) .premiumPlanValue {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 18% 24%,
|
||||
rgba(255, 229, 138, 0.32) 0%,
|
||||
rgba(255, 214, 98, 0.18) 18%,
|
||||
rgba(255, 214, 98, 0) 44%
|
||||
),
|
||||
linear-gradient(135deg, #4f3d0b 0%, #6e5510 48%, #8f6d10 100%);
|
||||
border-color: rgba(226, 180, 50, 0.7);
|
||||
box-shadow:
|
||||
0 1px 6px rgba(0, 0, 0, 0.28),
|
||||
0 0 0 1px rgba(255, 220, 120, 0.16) inset,
|
||||
0 0 18px rgba(255, 196, 44, 0.22);
|
||||
color: #fff0a8;
|
||||
text-shadow: 0 0 10px rgba(255, 212, 79, 0.22);
|
||||
|
||||
&::before {
|
||||
background: radial-gradient(
|
||||
circle at 18% 24%,
|
||||
rgba(255, 229, 138, 0.42) 0%,
|
||||
rgba(255, 197, 66, 0.28) 34%,
|
||||
rgba(255, 187, 0, 0) 74%
|
||||
);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media (dynamic-range: high) {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 18% 24%,
|
||||
color(display-p3 1 0.89 0.38 / 0.38) 0%,
|
||||
color(display-p3 1 0.82 0.2 / 0.2) 18%,
|
||||
color(display-p3 1 0.82 0.2 / 0) 44%
|
||||
),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
color(display-p3 0.33 0.25 0.04),
|
||||
color(display-p3 0.48 0.36 0.08),
|
||||
color(display-p3 0.62 0.46 0.09)
|
||||
);
|
||||
border-color: color(display-p3 0.78 0.6 0.16 / 0.78);
|
||||
box-shadow:
|
||||
0 1px 8px color(display-p3 0 0 0 / 0.32),
|
||||
0 0 0 1px color(display-p3 0.95 0.8 0.24 / 0.18) inset,
|
||||
0 0 20px color(display-p3 1 0.76 0.1 / 0.28);
|
||||
color: color(display-p3 1 0.93 0.58);
|
||||
}
|
||||
|
||||
@supports (color: #{'color(rec2100-linear 1 1 1)'}) {
|
||||
@media (dynamic-range: high) {
|
||||
dynamic-range-limit: no-limit;
|
||||
box-shadow:
|
||||
0 1px 8px color(display-p3 0 0 0 / 0.32),
|
||||
0 0 0 1px color(display-p3 0.95 0.8 0.24 / 0.18) inset,
|
||||
0 0 16px color(display-p3 1 0.76 0.1 / 0.28),
|
||||
0 0 28px #{'color(rec2100-linear 4.8 3.5 0.45 / 0.4)'};
|
||||
|
||||
&::before {
|
||||
background: radial-gradient(
|
||||
circle at 18% 24%,
|
||||
#{'color(rec2100-linear 5.6 4.1 0.75 / 0.64)'} 0%,
|
||||
#{'color(rec2100-linear 2.2 1.5 0.2 / 0.34)'} 34%,
|
||||
#{'color(rec2100-linear 1.2 0.9 0.06 / 0)'} 74%
|
||||
);
|
||||
opacity: 0.95;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fileCard {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
@@ -78,7 +78,10 @@
|
||||
color: inherit;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
@@ -97,6 +100,20 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tileHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.tileAction {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
margin: -4px -8px 0 0;
|
||||
}
|
||||
|
||||
.tileValue {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
@@ -111,12 +128,6 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.aboutActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: $spacing-lg;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -212,7 +223,9 @@
|
||||
border-radius: $radius-full;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
.modelName {
|
||||
|
||||
+243
-154
@@ -5,8 +5,14 @@ import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { IconGithub, IconBookOpen, IconExternalLink, IconCode } from '@/components/ui/icons';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore, useThemeStore } from '@/stores';
|
||||
import { configApi } from '@/services/api';
|
||||
import {
|
||||
useAuthStore,
|
||||
useConfigStore,
|
||||
useNotificationStore,
|
||||
useModelsStore,
|
||||
useThemeStore,
|
||||
} from '@/stores';
|
||||
import { configApi, versionApi } from '@/services/api';
|
||||
import { apiKeysApi } from '@/services/api/apiKeys';
|
||||
import { classifyModels } from '@/utils/models';
|
||||
import { STORAGE_KEY_AUTH } from '@/utils/constants';
|
||||
@@ -36,6 +42,32 @@ const MODEL_CATEGORY_ICONS: Record<string, string | { light: string; dark: strin
|
||||
minimax: iconMinimax,
|
||||
};
|
||||
|
||||
const parseVersionSegments = (version?: string | null) => {
|
||||
if (!version) return null;
|
||||
const cleaned = version.trim().replace(/^v/i, '');
|
||||
if (!cleaned) return null;
|
||||
const parts = cleaned
|
||||
.split(/[^0-9]+/)
|
||||
.filter(Boolean)
|
||||
.map((segment) => Number.parseInt(segment, 10))
|
||||
.filter(Number.isFinite);
|
||||
return parts.length ? parts : null;
|
||||
};
|
||||
|
||||
const compareVersions = (latest?: string | null, current?: string | null) => {
|
||||
const latestParts = parseVersionSegments(latest);
|
||||
const currentParts = parseVersionSegments(current);
|
||||
if (!latestParts || !currentParts) return null;
|
||||
const length = Math.max(latestParts.length, currentParts.length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
const l = latestParts[i] || 0;
|
||||
const c = currentParts[i] || 0;
|
||||
if (l > c) return 1;
|
||||
if (l < c) return -1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export function SystemPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { showNotification, showConfirmation } = useNotificationStore();
|
||||
@@ -51,11 +83,15 @@ export function SystemPage() {
|
||||
const modelsError = useModelsStore((state) => state.error);
|
||||
const fetchModelsFromStore = useModelsStore((state) => state.fetchModels);
|
||||
|
||||
const [modelStatus, setModelStatus] = useState<{ type: 'success' | 'warning' | 'error' | 'muted'; message: string }>();
|
||||
const [modelStatus, setModelStatus] = useState<{
|
||||
type: 'success' | 'warning' | 'error' | 'muted';
|
||||
message: string;
|
||||
}>();
|
||||
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
|
||||
const [requestLogDraft, setRequestLogDraft] = useState(false);
|
||||
const [requestLogTouched, setRequestLogTouched] = useState(false);
|
||||
const [requestLogSaving, setRequestLogSaving] = useState(false);
|
||||
const [checkingVersion, setCheckingVersion] = useState(false);
|
||||
|
||||
const apiKeysCache = useRef<string[]>([]);
|
||||
const versionTapCount = useRef(0);
|
||||
@@ -136,7 +172,7 @@ export function SystemPage() {
|
||||
if (auth.connectionStatus !== 'connected') {
|
||||
setModelStatus({
|
||||
type: 'warning',
|
||||
message: t('notification.connection_required')
|
||||
message: t('notification.connection_required'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -158,11 +194,12 @@ export function SystemPage() {
|
||||
const hasModels = list.length > 0;
|
||||
setModelStatus({
|
||||
type: hasModels ? 'success' : 'warning',
|
||||
message: hasModels ? t('system_info.models_count', { count: list.length }) : t('system_info.models_empty')
|
||||
message: hasModels
|
||||
? t('system_info.models_count', { count: list.length })
|
||||
: t('system_info.models_empty'),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : typeof err === 'string' ? err : '';
|
||||
const message = err instanceof Error ? err.message : typeof err === 'string' ? err : '';
|
||||
const suffix = message ? `: ${message}` : '';
|
||||
const text = `${t('system_info.models_error')}${suffix}`;
|
||||
setModelStatus({ type: 'error', message: text });
|
||||
@@ -244,6 +281,39 @@ export function SystemPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleVersionCheck = useCallback(async () => {
|
||||
setCheckingVersion(true);
|
||||
try {
|
||||
const data = await versionApi.checkLatest();
|
||||
const latestRaw = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
|
||||
const latest = typeof latestRaw === 'string' ? latestRaw : String(latestRaw ?? '');
|
||||
const comparison = compareVersions(latest, auth.serverVersion);
|
||||
|
||||
if (!latest) {
|
||||
showNotification(t('system_info.version_check_error'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (comparison === null) {
|
||||
showNotification(t('system_info.version_current_missing'), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (comparison > 0) {
|
||||
showNotification(t('system_info.version_update_available', { version: latest }), 'warning');
|
||||
} else {
|
||||
showNotification(t('system_info.version_is_latest'), 'success');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : typeof error === 'string' ? error : '';
|
||||
const suffix = message ? `: ${message}` : '';
|
||||
showNotification(`${t('system_info.version_check_error')}${suffix}`, 'error');
|
||||
} finally {
|
||||
setCheckingVersion(false);
|
||||
}
|
||||
}, [auth.serverVersion, showNotification, t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig().catch(() => {
|
||||
// ignore
|
||||
@@ -273,160 +343,179 @@ export function SystemPage() {
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.pageTitle}>{t('system_info.title')}</h1>
|
||||
<div className={styles.content}>
|
||||
<Card className={styles.aboutCard}>
|
||||
<div className={styles.aboutHeader}>
|
||||
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className={styles.aboutLogo} />
|
||||
<div className={styles.aboutTitle}>{t('system_info.about_title')}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.aboutInfoGrid}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.infoTile} ${styles.tapTile}`}
|
||||
onClick={handleInfoVersionTap}
|
||||
>
|
||||
<div className={styles.tileLabel}>{t('footer.version')}</div>
|
||||
<div className={styles.tileValue}>{appVersion}</div>
|
||||
</button>
|
||||
|
||||
<div className={styles.infoTile}>
|
||||
<div className={styles.tileLabel}>{t('footer.api_version')}</div>
|
||||
<div className={styles.tileValue}>{apiVersion}</div>
|
||||
<Card className={styles.aboutCard}>
|
||||
<div className={styles.aboutHeader}>
|
||||
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className={styles.aboutLogo} />
|
||||
<div className={styles.aboutTitle}>{t('system_info.about_title')}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.infoTile}>
|
||||
<div className={styles.tileLabel}>{t('footer.build_date')}</div>
|
||||
<div className={styles.tileValue}>{buildTime}</div>
|
||||
<div className={styles.aboutInfoGrid}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.infoTile} ${styles.tapTile}`}
|
||||
onClick={handleInfoVersionTap}
|
||||
>
|
||||
<div className={styles.tileHeader}>
|
||||
<div className={styles.tileLabel}>{t('footer.version')}</div>
|
||||
</div>
|
||||
<div className={styles.tileValue}>{appVersion}</div>
|
||||
</button>
|
||||
|
||||
<div className={styles.infoTile}>
|
||||
<div className={styles.tileHeader}>
|
||||
<div className={styles.tileLabel}>{t('footer.api_version')}</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.tileAction}
|
||||
onClick={() => void handleVersionCheck()}
|
||||
loading={checkingVersion}
|
||||
title={t('system_info.version_check_button')}
|
||||
aria-label={t('system_info.version_check_button')}
|
||||
>
|
||||
{t('system_info.version_check_button')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.tileValue}>{apiVersion}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.infoTile}>
|
||||
<div className={styles.tileLabel}>{t('footer.build_date')}</div>
|
||||
<div className={styles.tileValue}>{buildTime}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.infoTile}>
|
||||
<div className={styles.tileLabel}>{t('connection.status')}</div>
|
||||
<div className={styles.tileValue}>{t(`common.${auth.connectionStatus}_status`)}</div>
|
||||
<div className={styles.tileSub}>{auth.apiBase || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className={styles.infoTile}>
|
||||
<div className={styles.tileLabel}>{t('connection.status')}</div>
|
||||
<div className={styles.tileValue}>{t(`common.${auth.connectionStatus}_status`)}</div>
|
||||
<div className={styles.tileSub}>{auth.apiBase || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.aboutActions}>
|
||||
<Button variant="secondary" size="sm" onClick={() => fetchConfig(undefined, true)}>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title={t('system_info.quick_links_title')}>
|
||||
<p className={styles.sectionDescription}>{t('system_info.quick_links_desc')}</p>
|
||||
<div className={styles.quickLinks}>
|
||||
<a
|
||||
href="https://github.com/router-for-me/CLIProxyAPI"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.linkCard}
|
||||
>
|
||||
<div className={`${styles.linkIcon} ${styles.github}`}>
|
||||
<IconGithub size={22} />
|
||||
</div>
|
||||
<div className={styles.linkContent}>
|
||||
<div className={styles.linkTitle}>
|
||||
{t('system_info.link_main_repo')}
|
||||
<IconExternalLink size={14} />
|
||||
<Card title={t('system_info.quick_links_title')}>
|
||||
<p className={styles.sectionDescription}>{t('system_info.quick_links_desc')}</p>
|
||||
<div className={styles.quickLinks}>
|
||||
<a
|
||||
href="https://github.com/router-for-me/CLIProxyAPI"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.linkCard}
|
||||
>
|
||||
<div className={`${styles.linkIcon} ${styles.github}`}>
|
||||
<IconGithub size={22} />
|
||||
</div>
|
||||
<div className={styles.linkDesc}>{t('system_info.link_main_repo_desc')}</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://github.com/router-for-me/Cli-Proxy-API-Management-Center"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.linkCard}
|
||||
>
|
||||
<div className={`${styles.linkIcon} ${styles.github}`}>
|
||||
<IconCode size={22} />
|
||||
</div>
|
||||
<div className={styles.linkContent}>
|
||||
<div className={styles.linkTitle}>
|
||||
{t('system_info.link_webui_repo')}
|
||||
<IconExternalLink size={14} />
|
||||
</div>
|
||||
<div className={styles.linkDesc}>{t('system_info.link_webui_repo_desc')}</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://help.router-for.me/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.linkCard}
|
||||
>
|
||||
<div className={`${styles.linkIcon} ${styles.docs}`}>
|
||||
<IconBookOpen size={22} />
|
||||
</div>
|
||||
<div className={styles.linkContent}>
|
||||
<div className={styles.linkTitle}>
|
||||
{t('system_info.link_docs')}
|
||||
<IconExternalLink size={14} />
|
||||
</div>
|
||||
<div className={styles.linkDesc}>{t('system_info.link_docs_desc')}</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={t('system_info.models_title')}
|
||||
extra={
|
||||
<Button variant="secondary" size="sm" onClick={() => fetchModels({ forceRefresh: true })} loading={modelsLoading}>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className={styles.sectionDescription}>{t('system_info.models_desc')}</p>
|
||||
{modelStatus && <div className={`status-badge ${modelStatus.type}`}>{modelStatus.message}</div>}
|
||||
{modelsError && <div className="error-box">{modelsError}</div>}
|
||||
{modelsLoading ? (
|
||||
<div className="hint">{t('common.loading')}</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className="hint">{t('system_info.models_empty')}</div>
|
||||
) : (
|
||||
<div className="item-list">
|
||||
{groupedModels.map((group) => {
|
||||
const iconSrc = getIconForCategory(group.id);
|
||||
return (
|
||||
<div key={group.id} className="item-row">
|
||||
<div className="item-meta">
|
||||
<div className={styles.groupTitle}>
|
||||
{iconSrc && <img src={iconSrc} alt="" className={styles.groupIcon} />}
|
||||
<span className="item-title">{group.label}</span>
|
||||
</div>
|
||||
<div className="item-subtitle">{t('system_info.models_count', { count: group.items.length })}</div>
|
||||
</div>
|
||||
<div className={styles.modelTags}>
|
||||
{group.items.map((model) => (
|
||||
<span
|
||||
key={`${model.name}-${model.alias ?? 'default'}`}
|
||||
className={styles.modelTag}
|
||||
title={model.description || ''}
|
||||
>
|
||||
<span className={styles.modelName}>{model.name}</span>
|
||||
{model.alias && <span className={styles.modelAlias}>{model.alias}</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.linkContent}>
|
||||
<div className={styles.linkTitle}>
|
||||
{t('system_info.link_main_repo')}
|
||||
<IconExternalLink size={14} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<div className={styles.linkDesc}>{t('system_info.link_main_repo_desc')}</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<Card title={t('system_info.clear_login_title')}>
|
||||
<p className={styles.sectionDescription}>{t('system_info.clear_login_desc')}</p>
|
||||
<div className={styles.clearLoginActions}>
|
||||
<Button variant="danger" onClick={handleClearLoginStorage}>
|
||||
{t('system_info.clear_login_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<a
|
||||
href="https://github.com/router-for-me/Cli-Proxy-API-Management-Center"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.linkCard}
|
||||
>
|
||||
<div className={`${styles.linkIcon} ${styles.github}`}>
|
||||
<IconCode size={22} />
|
||||
</div>
|
||||
<div className={styles.linkContent}>
|
||||
<div className={styles.linkTitle}>
|
||||
{t('system_info.link_webui_repo')}
|
||||
<IconExternalLink size={14} />
|
||||
</div>
|
||||
<div className={styles.linkDesc}>{t('system_info.link_webui_repo_desc')}</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://help.router-for.me/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.linkCard}
|
||||
>
|
||||
<div className={`${styles.linkIcon} ${styles.docs}`}>
|
||||
<IconBookOpen size={22} />
|
||||
</div>
|
||||
<div className={styles.linkContent}>
|
||||
<div className={styles.linkTitle}>
|
||||
{t('system_info.link_docs')}
|
||||
<IconExternalLink size={14} />
|
||||
</div>
|
||||
<div className={styles.linkDesc}>{t('system_info.link_docs_desc')}</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={t('system_info.models_title')}
|
||||
extra={
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => fetchModels({ forceRefresh: true })}
|
||||
loading={modelsLoading}
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className={styles.sectionDescription}>{t('system_info.models_desc')}</p>
|
||||
{modelStatus && (
|
||||
<div className={`status-badge ${modelStatus.type}`}>{modelStatus.message}</div>
|
||||
)}
|
||||
{modelsError && <div className="error-box">{modelsError}</div>}
|
||||
{modelsLoading ? (
|
||||
<div className="hint">{t('common.loading')}</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className="hint">{t('system_info.models_empty')}</div>
|
||||
) : (
|
||||
<div className="item-list">
|
||||
{groupedModels.map((group) => {
|
||||
const iconSrc = getIconForCategory(group.id);
|
||||
return (
|
||||
<div key={group.id} className="item-row">
|
||||
<div className="item-meta">
|
||||
<div className={styles.groupTitle}>
|
||||
{iconSrc && <img src={iconSrc} alt="" className={styles.groupIcon} />}
|
||||
<span className="item-title">{group.label}</span>
|
||||
</div>
|
||||
<div className="item-subtitle">
|
||||
{t('system_info.models_count', { count: group.items.length })}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.modelTags}>
|
||||
{group.items.map((model) => (
|
||||
<span
|
||||
key={`${model.name}-${model.alias ?? 'default'}`}
|
||||
className={styles.modelTag}
|
||||
title={model.description || ''}
|
||||
>
|
||||
<span className={styles.modelName}>{model.name}</span>
|
||||
{model.alias && <span className={styles.modelAlias}>{model.alias}</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title={t('system_info.clear_login_title')}>
|
||||
<p className={styles.sectionDescription}>{t('system_info.clear_login_desc')}</p>
|
||||
<div className={styles.clearLoginActions}>
|
||||
<Button variant="danger" onClick={handleClearLoginStorage}>
|
||||
{t('system_info.clear_login_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { OAuthModelAliasEntry } from '@/types';
|
||||
|
||||
type StatusError = { status?: number };
|
||||
type AuthFileStatusResponse = { status: string; disabled: boolean };
|
||||
type AuthFileEntry = AuthFilesResponse['files'][number];
|
||||
|
||||
export const AUTH_FILE_INVALID_JSON_OBJECT_ERROR = 'AUTH_FILE_INVALID_JSON_OBJECT';
|
||||
|
||||
@@ -17,6 +18,122 @@ const getStatusCode = (err: unknown): number | undefined => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const readTextField = (entry: AuthFileEntry, key: string): string => {
|
||||
const value = entry[key];
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
};
|
||||
|
||||
const readDateField = (entry: AuthFileEntry): number => {
|
||||
const candidates = [entry['modtime'], entry.modified, entry['updated_at'], entry['last_refresh']];
|
||||
|
||||
for (const value of candidates) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value < 1e12 ? value * 1000 : value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) continue;
|
||||
const asNumber = Number(trimmed);
|
||||
if (Number.isFinite(asNumber)) {
|
||||
return asNumber < 1e12 ? asNumber * 1000 : asNumber;
|
||||
}
|
||||
const parsed = Date.parse(trimmed);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const isRuntimeOnlyEntry = (entry: AuthFileEntry): boolean => {
|
||||
const value = entry['runtime_only'] ?? entry.runtimeOnly;
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'string') return value.trim().toLowerCase() === 'true';
|
||||
return false;
|
||||
};
|
||||
|
||||
const hasMeaningfulValue = (value: unknown): boolean => {
|
||||
if (value == null) return false;
|
||||
if (typeof value === 'string') return value.trim().length > 0;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
return true;
|
||||
};
|
||||
|
||||
const countMeaningfulFields = (entry: AuthFileEntry): number =>
|
||||
Object.values(entry).reduce<number>(
|
||||
(count, value) => count + (hasMeaningfulValue(value) ? 1 : 0),
|
||||
0
|
||||
);
|
||||
|
||||
const authFilePriorityScore = (entry: AuthFileEntry): number => {
|
||||
let score = 0;
|
||||
if (readTextField(entry, 'source').toLowerCase() === 'file') score += 32;
|
||||
if (readTextField(entry, 'path')) score += 16;
|
||||
if (!isRuntimeOnlyEntry(entry)) score += 8;
|
||||
if (entry.disabled !== true) score += 4;
|
||||
if (readDateField(entry) > 0) score += 2;
|
||||
return score;
|
||||
};
|
||||
|
||||
const compareAuthFileEntries = (left: AuthFileEntry, right: AuthFileEntry): number => {
|
||||
const scoreDiff = authFilePriorityScore(right) - authFilePriorityScore(left);
|
||||
if (scoreDiff !== 0) return scoreDiff;
|
||||
|
||||
const dateDiff = readDateField(right) - readDateField(left);
|
||||
if (dateDiff !== 0) return dateDiff;
|
||||
|
||||
const fieldDiff = countMeaningfulFields(right) - countMeaningfulFields(left);
|
||||
if (fieldDiff !== 0) return fieldDiff;
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const mergeAuthFileEntries = (entries: AuthFileEntry[]): AuthFileEntry => {
|
||||
const [primary, ...rest] = [...entries].sort(compareAuthFileEntries);
|
||||
const merged: AuthFileEntry = { ...primary };
|
||||
|
||||
rest.forEach((entry) => {
|
||||
Object.entries(entry).forEach(([key, value]) => {
|
||||
if (!hasMeaningfulValue(merged[key]) && hasMeaningfulValue(value)) {
|
||||
merged[key] = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
const dedupeAuthFilesResponse = (payload: AuthFilesResponse): AuthFilesResponse => {
|
||||
const files = Array.isArray(payload?.files) ? payload.files : [];
|
||||
const grouped = new Map<string, AuthFileEntry[]>();
|
||||
|
||||
files.forEach((entry) => {
|
||||
const name = readTextField(entry, 'name');
|
||||
const key = name || JSON.stringify(entry);
|
||||
const bucket = grouped.get(key);
|
||||
if (bucket) {
|
||||
bucket.push(entry);
|
||||
return;
|
||||
}
|
||||
grouped.set(key, [entry]);
|
||||
});
|
||||
|
||||
const normalizedFiles = Array.from(grouped.values()).map(mergeAuthFileEntries);
|
||||
normalizedFiles.sort((left, right) =>
|
||||
readTextField(left, 'name').localeCompare(readTextField(right, 'name'), undefined, {
|
||||
sensitivity: 'accent',
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
...payload,
|
||||
files: normalizedFiles,
|
||||
total: normalizedFiles.length,
|
||||
};
|
||||
};
|
||||
|
||||
const parseAuthFileJsonObject = (rawText: string): Record<string, unknown> => {
|
||||
const trimmed = rawText.trim();
|
||||
|
||||
@@ -130,7 +247,7 @@ const normalizeOauthModelAlias = (payload: unknown): Record<string, OAuthModelAl
|
||||
const OAUTH_MODEL_ALIAS_ENDPOINT = '/oauth-model-alias';
|
||||
|
||||
export const authFilesApi = {
|
||||
list: () => apiClient.get<AuthFilesResponse>('/auth-files'),
|
||||
list: async () => dedupeAuthFilesResponse(await apiClient.get<AuthFilesResponse>('/auth-files')),
|
||||
|
||||
setStatus: (name: string, disabled: boolean) =>
|
||||
apiClient.patch<AuthFileStatusResponse>('/auth-files/status', { name, disabled }),
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Theme } from '@/types';
|
||||
import { STORAGE_KEY_THEME } from '@/utils/constants';
|
||||
|
||||
type ResolvedTheme = 'light' | 'dark';
|
||||
type AppliedTheme = ResolvedTheme | 'white';
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme;
|
||||
@@ -25,9 +26,17 @@ const getSystemTheme = (): ResolvedTheme => {
|
||||
return 'light';
|
||||
};
|
||||
|
||||
const resolveTheme = (theme: Theme): ResolvedTheme | 'white' => {
|
||||
const resolveAutoTheme = (): AppliedTheme => {
|
||||
return getSystemTheme() === 'dark' ? 'dark' : 'white';
|
||||
};
|
||||
|
||||
const normalizeResolvedTheme = (theme: AppliedTheme): ResolvedTheme => {
|
||||
return theme === 'dark' ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
const resolveTheme = (theme: Theme): AppliedTheme => {
|
||||
if (theme === 'auto') {
|
||||
return getSystemTheme();
|
||||
return resolveAutoTheme();
|
||||
}
|
||||
if (theme === 'white') {
|
||||
return 'white';
|
||||
@@ -35,7 +44,7 @@ const resolveTheme = (theme: Theme): ResolvedTheme | 'white' => {
|
||||
return theme;
|
||||
};
|
||||
|
||||
const applyTheme = (resolved: ResolvedTheme | 'white') => {
|
||||
const applyTheme = (resolved: AppliedTheme) => {
|
||||
if (resolved === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
return;
|
||||
@@ -60,7 +69,7 @@ export const useThemeStore = create<ThemeState>()(
|
||||
applyTheme(resolved);
|
||||
set({
|
||||
theme,
|
||||
resolvedTheme: resolved === 'white' ? 'light' : resolved,
|
||||
resolvedTheme: normalizeResolvedTheme(resolved),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -87,9 +96,9 @@ export const useThemeStore = create<ThemeState>()(
|
||||
const listener = () => {
|
||||
const { theme: currentTheme } = get();
|
||||
if (currentTheme === 'auto') {
|
||||
const resolved = getSystemTheme();
|
||||
const resolved = resolveAutoTheme();
|
||||
applyTheme(resolved);
|
||||
set({ resolvedTheme: resolved });
|
||||
set({ resolvedTheme: normalizeResolvedTheme(resolved) });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -130,9 +130,17 @@ textarea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: $spacing-md;
|
||||
|
||||
> * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
|
||||
+69
-10
@@ -81,7 +81,9 @@
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
transition: background $transition-fast, color $transition-fast;
|
||||
transition:
|
||||
background $transition-fast,
|
||||
color $transition-fast;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -118,7 +120,10 @@
|
||||
max-width: 320px;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transition: max-width 0.4s ease, opacity 0.4s ease, transform 0.4s ease;
|
||||
transition:
|
||||
max-width 0.4s ease,
|
||||
opacity 0.4s ease,
|
||||
transform 0.4s ease;
|
||||
}
|
||||
|
||||
.brand-abbr {
|
||||
@@ -126,7 +131,9 @@
|
||||
transform: translateX(12px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.4s ease, transform 0.4s ease;
|
||||
transition:
|
||||
opacity 0.4s ease,
|
||||
transform 0.4s ease;
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
@@ -220,7 +227,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background-color $transition-fast, color $transition-fast;
|
||||
transition:
|
||||
background-color $transition-fast,
|
||||
color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-secondary);
|
||||
@@ -278,7 +287,9 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: border-color $transition-fast, background-color $transition-fast;
|
||||
transition:
|
||||
border-color $transition-fast,
|
||||
background-color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-secondary);
|
||||
@@ -431,6 +442,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-backdrop {
|
||||
display: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: rgb(15 23 42 / 0.18);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity $transition-fast;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: var(--header-height) 0 0;
|
||||
z-index: $z-dropdown - 1;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: var(--bg-primary);
|
||||
@@ -439,7 +473,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
transition: width $transition-normal, transform $transition-normal;
|
||||
transition:
|
||||
width $transition-normal,
|
||||
transform $transition-normal;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
@@ -464,22 +500,33 @@
|
||||
.nav-item {
|
||||
padding: 10px 12px;
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast, color $transition-fast;
|
||||
transition:
|
||||
background $transition-fast,
|
||||
color $transition-fast;
|
||||
|
||||
.nav-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.9;
|
||||
opacity: 0.96;
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(180deg, rgb(255 255 255 / 0.18), rgb(255 255 255 / 0))
|
||||
var(--bg-secondary);
|
||||
box-shadow: inset 0 0 0 1px var(--border-primary);
|
||||
transition:
|
||||
background $transition-fast,
|
||||
box-shadow $transition-fast,
|
||||
color $transition-fast;
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
@@ -496,12 +543,24 @@
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-secondary);
|
||||
|
||||
.nav-icon {
|
||||
background: linear-gradient(180deg, rgb(255 255 255 / 0.24), rgb(255 255 255 / 0))
|
||||
var(--bg-primary);
|
||||
box-shadow: inset 0 0 0 1px var(--border-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba($primary-color, 0.14);
|
||||
color: var(--primary-color);
|
||||
border: 1px solid rgba($primary-color, 0.35);
|
||||
|
||||
.nav-icon {
|
||||
background: linear-gradient(180deg, rgb(255 255 255 / 0.22), rgb(255 255 255 / 0))
|
||||
rgba($primary-color, 0.1);
|
||||
box-shadow: inset 0 0 0 1px rgba($primary-color, 0.26);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,28 @@ export interface GeminiCliQuotaPayload {
|
||||
buckets?: GeminiCliQuotaBucket[];
|
||||
}
|
||||
|
||||
export interface GeminiCliCredits {
|
||||
creditType?: string;
|
||||
credit_type?: string;
|
||||
creditAmount?: string | number;
|
||||
credit_amount?: string | number;
|
||||
}
|
||||
|
||||
export interface GeminiCliUserTier {
|
||||
id?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
availableCredits?: GeminiCliCredits[];
|
||||
available_credits?: GeminiCliCredits[];
|
||||
}
|
||||
|
||||
export interface GeminiCliCodeAssistPayload {
|
||||
currentTier?: GeminiCliUserTier | null;
|
||||
current_tier?: GeminiCliUserTier | null;
|
||||
paidTier?: GeminiCliUserTier | null;
|
||||
paid_tier?: GeminiCliUserTier | null;
|
||||
}
|
||||
|
||||
export interface AntigravityQuotaInfo {
|
||||
displayName?: string;
|
||||
quotaInfo?: {
|
||||
@@ -200,6 +222,9 @@ export interface GeminiCliQuotaBucketState {
|
||||
export interface GeminiCliQuotaState {
|
||||
status: 'idle' | 'loading' | 'success' | 'error';
|
||||
buckets: GeminiCliQuotaBucketState[];
|
||||
tierLabel?: string | null;
|
||||
tierId?: string | null;
|
||||
creditBalance?: number | null;
|
||||
error?: string;
|
||||
errorStatus?: number;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export type VisualConfigFieldPath =
|
||||
| 'port'
|
||||
| 'logsMaxTotalSizeMb'
|
||||
| 'requestRetry'
|
||||
| 'maxRetryCredentials'
|
||||
| 'maxRetryInterval'
|
||||
| 'streaming.keepaliveSeconds'
|
||||
| 'streaming.bootstrapRetries'
|
||||
@@ -29,7 +30,7 @@ export type PayloadParamEntry = {
|
||||
export type PayloadModelEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
protocol?: 'openai' | 'openai-response' | 'gemini' | 'claude' | 'codex' | 'antigravity';
|
||||
protocol?: string;
|
||||
};
|
||||
|
||||
export type PayloadRule = {
|
||||
@@ -70,13 +71,16 @@ export type VisualConfigValues = {
|
||||
proxyUrl: string;
|
||||
forceModelPrefix: boolean;
|
||||
requestRetry: string;
|
||||
maxRetryCredentials: string;
|
||||
maxRetryInterval: string;
|
||||
quotaSwitchProject: boolean;
|
||||
quotaSwitchPreviewModel: boolean;
|
||||
routingStrategy: 'round-robin' | 'fill-first';
|
||||
wsAuth: boolean;
|
||||
payloadDefaultRules: PayloadRule[];
|
||||
payloadDefaultRawRules: PayloadRule[];
|
||||
payloadOverrideRules: PayloadRule[];
|
||||
payloadOverrideRawRules: PayloadRule[];
|
||||
payloadFilterRules: PayloadFilterRule[];
|
||||
streaming: StreamingConfig;
|
||||
};
|
||||
@@ -106,13 +110,16 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
|
||||
proxyUrl: '',
|
||||
forceModelPrefix: false,
|
||||
requestRetry: '',
|
||||
maxRetryCredentials: '',
|
||||
maxRetryInterval: '',
|
||||
quotaSwitchProject: true,
|
||||
quotaSwitchPreviewModel: true,
|
||||
routingStrategy: 'round-robin',
|
||||
wsAuth: false,
|
||||
payloadDefaultRules: [],
|
||||
payloadDefaultRawRules: [],
|
||||
payloadOverrideRules: [],
|
||||
payloadOverrideRawRules: [],
|
||||
payloadFilterRules: [],
|
||||
streaming: {
|
||||
keepaliveSeconds: '',
|
||||
|
||||
@@ -8,18 +8,18 @@ import type {
|
||||
TypeColorSet,
|
||||
} from '@/types';
|
||||
|
||||
// Theme colors for type badges
|
||||
// Theme colors for type badges — 与 authFiles/constants.ts 保持同步
|
||||
export const TYPE_COLORS: Record<string, TypeColorSet> = {
|
||||
qwen: {
|
||||
light: { bg: '#e8f5e9', text: '#2e7d32' },
|
||||
dark: { bg: '#1b5e20', text: '#81c784' },
|
||||
light: { bg: '#ede5fd', text: '#5530c7' },
|
||||
dark: { bg: '#36208a', text: '#b5a3f0' },
|
||||
},
|
||||
gemini: {
|
||||
light: { bg: '#e3f2fd', text: '#1565c0' },
|
||||
dark: { bg: '#0d47a1', text: '#64b5f6' },
|
||||
},
|
||||
'gemini-cli': {
|
||||
light: { bg: '#e7efff', text: '#1e4fa3' },
|
||||
light: { bg: '#e0e8ff', text: '#1e4fa3' },
|
||||
dark: { bg: '#1c3f73', text: '#a8c7ff' },
|
||||
},
|
||||
aistudio: {
|
||||
@@ -27,24 +27,28 @@ export const TYPE_COLORS: Record<string, TypeColorSet> = {
|
||||
dark: { bg: '#373c42', text: '#cfd3db' },
|
||||
},
|
||||
claude: {
|
||||
light: { bg: '#fce4ec', text: '#c2185b' },
|
||||
dark: { bg: '#880e4f', text: '#f48fb1' },
|
||||
light: { bg: '#fbece4', text: '#c05621' },
|
||||
dark: { bg: '#5e2c14', text: '#e8a882' },
|
||||
},
|
||||
codex: {
|
||||
light: { bg: '#fff3e0', text: '#ef6c00' },
|
||||
dark: { bg: '#e65100', text: '#ffb74d' },
|
||||
light: { bg: '#eae7ff', text: '#3538d4' },
|
||||
dark: { bg: '#262395', text: '#b5b0ff' },
|
||||
},
|
||||
kimi: {
|
||||
light: { bg: '#fff4e5', text: '#ad6800' },
|
||||
dark: { bg: '#7c4a03', text: '#ffd591' },
|
||||
light: { bg: '#dce8ff', text: '#0560cf' },
|
||||
dark: { bg: '#003880', text: '#70b5ff' },
|
||||
},
|
||||
antigravity: {
|
||||
light: { bg: '#e0f7fa', text: '#006064' },
|
||||
dark: { bg: '#004d40', text: '#80deea' },
|
||||
},
|
||||
iflow: {
|
||||
light: { bg: '#f3e5f5', text: '#7b1fa2' },
|
||||
dark: { bg: '#4a148c', text: '#ce93d8' },
|
||||
light: { bg: '#f5e3fc', text: '#9025c8' },
|
||||
dark: { bg: '#521490', text: '#d49cf5' },
|
||||
},
|
||||
vertex: {
|
||||
light: { bg: '#e4edfd', text: '#2b5fbc' },
|
||||
dark: { bg: '#1a3d80', text: '#89b3f7' },
|
||||
},
|
||||
empty: {
|
||||
light: { bg: '#f5f5f5', text: '#616161' },
|
||||
@@ -117,6 +121,9 @@ export const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [
|
||||
export const GEMINI_CLI_QUOTA_URL =
|
||||
'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota';
|
||||
|
||||
export const GEMINI_CLI_CODE_ASSIST_URL =
|
||||
'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist';
|
||||
|
||||
export const GEMINI_CLI_REQUEST_HEADERS = {
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Normalization and parsing functions for quota data.
|
||||
*/
|
||||
|
||||
import type { ClaudeUsagePayload, CodexUsagePayload, GeminiCliQuotaPayload, KimiUsagePayload } from '@/types';
|
||||
import type { ClaudeUsagePayload, CodexUsagePayload, GeminiCliCodeAssistPayload, GeminiCliQuotaPayload, KimiUsagePayload } from '@/types';
|
||||
import { normalizeAuthIndex } from '@/utils/usage';
|
||||
|
||||
const GEMINI_CLI_MODEL_SUFFIX = '_vertex';
|
||||
@@ -191,6 +191,23 @@ export function parseGeminiCliQuotaPayload(payload: unknown): GeminiCliQuotaPayl
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseGeminiCliCodeAssistPayload(payload: unknown): GeminiCliCodeAssistPayload | null {
|
||||
if (payload === undefined || payload === null) return null;
|
||||
if (typeof payload === 'string') {
|
||||
const trimmed = payload.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return JSON.parse(trimmed) as GeminiCliCodeAssistPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof payload === 'object') {
|
||||
return payload as GeminiCliCodeAssistPayload;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseKimiUsagePayload(payload: unknown): KimiUsagePayload | null {
|
||||
if (payload === undefined || payload === null) return null;
|
||||
if (typeof payload === 'string') {
|
||||
|
||||
Reference in New Issue
Block a user