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
25 Commits
+2
-1
@@ -1,7 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" translate="no" class="notranslate">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="google" content="notranslate" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20aria-hidden%3D%22true%22%20role%3D%22img%22%20class%3D%22iconify%20iconify--logos%22%20width%3D%2231.88%22%20height%3D%2232%22%20preserveAspectRatio%3D%22xMidYMid%20meet%22%20viewBox%3D%220%200%20256%20257%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb466%22%20x1%3D%22-.828%25%22%20x2%3D%2257.636%25%22%20y1%3D%227.652%25%22%20y2%3D%2278.411%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%2341D1FF%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23BD34FE%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb467%22%20x1%3D%2243.376%25%22%20x2%3D%2250.316%25%22%20y1%3D%222.242%25%22%20y2%3D%2289.03%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%23FFEA83%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%228.333%25%22%20stop-color%3D%22%23FFDD35%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23FFA800%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb466)%22%20d%3D%22M255.153%2037.938L134.897%20252.976c-2.483%204.44-8.862%204.466-11.382.048L.875%2037.958c-2.746-4.814%201.371-10.646%206.827-9.67l120.385%2021.517a6.537%206.537%200%200%200%202.322-.004l117.867-21.483c5.438-.991%209.574%204.796%206.877%209.62Z%22%3E%3C%2Fpath%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb467)%22%20d%3D%22M185.432.063L96.44%2017.501a3.268%203.268%200%200%200-2.634%203.014l-5.474%2092.456a3.268%203.268%200%200%200%203.997%203.378l24.777-5.718c2.318-.535%204.413%201.507%203.936%203.838l-7.361%2036.047c-.495%202.426%201.782%204.5%204.151%203.78l15.304-4.649c2.372-.72%204.652%201.36%204.15%203.788l-11.698%2056.621c-.732%203.542%203.979%205.473%205.943%202.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505%204.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z%22%3E%3C%2Fpath%3E%3C%2Fsvg%3E" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CLI Proxy API Management Center</title>
|
||||
|
||||
@@ -72,6 +72,61 @@
|
||||
}
|
||||
}
|
||||
|
||||
.expandableInputWrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.expandableInputWrapper > .expandableTextarea,
|
||||
.expandableInputWrapper > :global(.input) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
.expandableTextarea {
|
||||
resize: none;
|
||||
min-height: 60px;
|
||||
overflow: hidden;
|
||||
line-height: 1.5;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.expandableToggle {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
padding: 2px;
|
||||
color: var(--text-secondary, #999);
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.expandableInputExpanded .expandableToggle {
|
||||
top: 8px;
|
||||
transform: none;
|
||||
right: 14px;
|
||||
}
|
||||
|
||||
.overview {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -229,6 +284,90 @@
|
||||
@media (max-width: 1024px) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.mobileSectionNav {
|
||||
display: none;
|
||||
|
||||
@include mobile {
|
||||
position: sticky;
|
||||
top: calc(var(--header-height, 64px) + 12px);
|
||||
z-index: 4;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.mobileSectionNavScroller {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 4px 2px 10px;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mobileSectionNavButton {
|
||||
@include button-reset;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: max-content;
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 88%, transparent);
|
||||
box-shadow: 0 18px 36px -30px rgba(0, 0, 0, 0.28);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobileSectionNavButtonActive {
|
||||
border-color: color-mix(in srgb, var(--primary-color) 24%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--bg-primary) 96%, transparent);
|
||||
}
|
||||
|
||||
.mobileSectionNavIndex {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 88%, transparent);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.mobileSectionNavLabel {
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mobileSectionNavBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
color: var(--warning-text);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
@@ -269,10 +408,9 @@
|
||||
|
||||
.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));
|
||||
left: 0;
|
||||
top: 0;
|
||||
will-change: transform, width, max-height;
|
||||
z-index: 45;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
@@ -290,9 +428,7 @@
|
||||
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);
|
||||
background: color-mix(in srgb, var(--bg-primary) 96%, transparent);
|
||||
box-shadow: 0 24px 56px -34px rgba(0, 0, 0, 0.42);
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
@@ -447,6 +583,10 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.sectionStack {
|
||||
@@ -729,9 +869,19 @@
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.overview {
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.overviewFocusLink {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.sections {
|
||||
border-radius: 26px;
|
||||
padding: 18px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.subsection {
|
||||
@@ -739,7 +889,41 @@
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.toggleRow {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.blockHeaderRow,
|
||||
.ruleCardHeader {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.blockHeaderRow :global(.btn),
|
||||
.ruleCardHeader :global(.btn),
|
||||
.actionRow :global(.btn),
|
||||
.stringListRow :global(.btn) {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.actionRow {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.stringListRow {
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.overview,
|
||||
.sections {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.subsection,
|
||||
.ruleCard,
|
||||
.toggleRow {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +196,10 @@ export function VisualConfigEditor({
|
||||
const sidebarAnchorRef = useRef<HTMLElement | null>(null);
|
||||
const floatingSidebarRef = useRef<HTMLDivElement | null>(null);
|
||||
const sectionRefs = useRef<Partial<Record<VisualSectionId, HTMLElement | null>>>({});
|
||||
const mobileNavScrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
const mobileNavButtonRefs = useRef<Partial<Record<VisualSectionId, HTMLButtonElement | null>>>(
|
||||
{}
|
||||
);
|
||||
|
||||
const isKeepaliveDisabled =
|
||||
values.streaming.keepaliveSeconds === '' || values.streaming.keepaliveSeconds === '0';
|
||||
@@ -355,6 +359,27 @@ export function VisualConfigEditor({
|
||||
return () => observer.disconnect();
|
||||
}, [sections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
const scroller = mobileNavScrollerRef.current;
|
||||
const button = mobileNavButtonRefs.current[activeSectionId];
|
||||
if (!scroller || !button) return;
|
||||
|
||||
const scrollerRect = scroller.getBoundingClientRect();
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const centeredLeft =
|
||||
scroller.scrollLeft +
|
||||
(buttonRect.left - scrollerRect.left) -
|
||||
(scroller.clientWidth - buttonRect.width) / 2;
|
||||
const maxScrollLeft = Math.max(scroller.scrollWidth - scroller.clientWidth, 0);
|
||||
const targetLeft = Math.min(Math.max(centeredLeft, 0), maxScrollLeft);
|
||||
|
||||
scroller.scrollTo({
|
||||
left: targetLeft,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [activeSectionId, isMobile]);
|
||||
|
||||
const handleSectionJump = useCallback((sectionId: VisualSectionId) => {
|
||||
setActiveSectionId(sectionId);
|
||||
sectionRefs.current[sectionId]?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
@@ -367,10 +392,9 @@ export function VisualConfigEditor({
|
||||
if (!floatingElement) return undefined;
|
||||
|
||||
const clearFloatingStyles = () => {
|
||||
floatingElement.style.removeProperty('--visual-config-floating-left');
|
||||
floatingElement.style.removeProperty('--visual-config-floating-top');
|
||||
floatingElement.style.removeProperty('--visual-config-floating-width');
|
||||
floatingElement.style.removeProperty('--visual-config-floating-max-height');
|
||||
floatingElement.style.removeProperty('transform');
|
||||
floatingElement.style.removeProperty('width');
|
||||
floatingElement.style.removeProperty('max-height');
|
||||
floatingElement.style.removeProperty('opacity');
|
||||
floatingElement.style.removeProperty('pointer-events');
|
||||
};
|
||||
@@ -380,7 +404,8 @@ export function VisualConfigEditor({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const getHeaderHeight = () => {
|
||||
/* ---- Cache header height – recomputed only on resize ---- */
|
||||
const computeHeaderHeight = () => {
|
||||
const header = document.querySelector('.main-header') as HTMLElement | null;
|
||||
if (header) return header.getBoundingClientRect().height;
|
||||
|
||||
@@ -388,8 +413,14 @@ export function VisualConfigEditor({
|
||||
const parsed = Number.parseFloat(raw);
|
||||
return Number.isFinite(parsed) ? parsed : 64;
|
||||
};
|
||||
let headerHeight = computeHeaderHeight();
|
||||
|
||||
/* ---- Cache content scroller – resolved once ---- */
|
||||
const contentScroller = document.querySelector('.content') as HTMLElement | null;
|
||||
|
||||
/* ---- Cache floating height from previous frame ---- */
|
||||
let cachedFloatingHeight = floatingElement.getBoundingClientRect().height || 200;
|
||||
|
||||
const getContentScroller = () => document.querySelector('.content') as HTMLElement | null;
|
||||
let frameId = 0;
|
||||
|
||||
const updateFloatingPosition = () => {
|
||||
@@ -397,10 +428,9 @@ export function VisualConfigEditor({
|
||||
|
||||
const anchorRect = anchorElement.getBoundingClientRect();
|
||||
const workspaceRect = workspaceElement.getBoundingClientRect();
|
||||
const floatingHeight = floatingElement.getBoundingClientRect().height;
|
||||
const stickyTop = getHeaderHeight() + 20;
|
||||
const stickyTop = headerHeight + 20;
|
||||
const viewportPadding = 16;
|
||||
const maxTop = workspaceRect.bottom - floatingHeight;
|
||||
const maxTop = workspaceRect.bottom - cachedFloatingHeight;
|
||||
const unclampedTop = Math.min(Math.max(anchorRect.top, stickyTop), maxTop);
|
||||
const top = Math.max(unclampedTop, viewportPadding);
|
||||
const left = Math.max(anchorRect.left, viewportPadding);
|
||||
@@ -411,10 +441,9 @@ export function VisualConfigEditor({
|
||||
const maxHeight = Math.max(window.innerHeight - top - viewportPadding, 160);
|
||||
const isVisible = workspaceRect.bottom > stickyTop + 24 && anchorRect.top < window.innerHeight;
|
||||
|
||||
floatingElement.style.setProperty('--visual-config-floating-left', `${left}px`);
|
||||
floatingElement.style.setProperty('--visual-config-floating-top', `${top}px`);
|
||||
floatingElement.style.setProperty('--visual-config-floating-width', `${width}px`);
|
||||
floatingElement.style.setProperty('--visual-config-floating-max-height', `${maxHeight}px`);
|
||||
floatingElement.style.transform = `translate3d(${left}px, ${top}px, 0)`;
|
||||
floatingElement.style.width = `${width}px`;
|
||||
floatingElement.style.maxHeight = `${maxHeight}px`;
|
||||
floatingElement.style.opacity = isVisible ? '1' : '0';
|
||||
floatingElement.style.pointerEvents = isVisible ? 'auto' : 'none';
|
||||
};
|
||||
@@ -424,10 +453,15 @@ export function VisualConfigEditor({
|
||||
frameId = requestAnimationFrame(updateFloatingPosition);
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
headerHeight = computeHeaderHeight();
|
||||
cachedFloatingHeight = floatingElement.getBoundingClientRect().height || cachedFloatingHeight;
|
||||
requestPositionUpdate();
|
||||
};
|
||||
|
||||
requestPositionUpdate();
|
||||
|
||||
const contentScroller = getContentScroller();
|
||||
window.addEventListener('resize', requestPositionUpdate);
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('scroll', requestPositionUpdate, { passive: true });
|
||||
contentScroller?.addEventListener('scroll', requestPositionUpdate, { passive: true });
|
||||
|
||||
@@ -435,12 +469,11 @@ export function VisualConfigEditor({
|
||||
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(requestPositionUpdate);
|
||||
resizeObserver?.observe(anchorElement);
|
||||
resizeObserver?.observe(workspaceElement);
|
||||
resizeObserver?.observe(floatingElement);
|
||||
|
||||
return () => {
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener('resize', requestPositionUpdate);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('scroll', requestPositionUpdate);
|
||||
contentScroller?.removeEventListener('scroll', requestPositionUpdate);
|
||||
clearFloatingStyles();
|
||||
@@ -532,6 +565,40 @@ export function VisualConfigEditor({
|
||||
</div>
|
||||
|
||||
<div ref={workspaceRef} className={styles.workspace}>
|
||||
{isMobile ? (
|
||||
<div className={styles.mobileSectionNav}>
|
||||
<div
|
||||
ref={mobileNavScrollerRef}
|
||||
className={styles.mobileSectionNavScroller}
|
||||
aria-label={t('config_management.visual.quick_jump', { defaultValue: '快速跳转' })}
|
||||
>
|
||||
{sections.map((section, index) => (
|
||||
<button
|
||||
key={section.id}
|
||||
ref={(node) => {
|
||||
mobileNavButtonRefs.current[section.id] = node;
|
||||
}}
|
||||
type="button"
|
||||
className={`${styles.mobileSectionNavButton} ${
|
||||
activeSectionId === section.id ? styles.mobileSectionNavButtonActive : ''
|
||||
}`}
|
||||
onClick={() => handleSectionJump(section.id)}
|
||||
>
|
||||
<span className={styles.mobileSectionNavIndex}>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<span className={styles.mobileSectionNavLabel}>{section.title}</span>
|
||||
{section.errorCount > 0 ? (
|
||||
<span className={styles.mobileSectionNavBadge} aria-hidden="true">
|
||||
{section.errorCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<aside ref={sidebarAnchorRef} className={styles.sidebar}>
|
||||
{isFloatingSidebar ? (
|
||||
<div className={styles.sidebarPlaceholder} aria-hidden="true" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useId, useMemo, useState } from 'react';
|
||||
import { memo, useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
@@ -23,6 +23,109 @@ import {
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { isValidApiKeyCharset } from '@/utils/validation';
|
||||
|
||||
/** Minimum character count before the expand/collapse toggle appears. */
|
||||
const EXPAND_THRESHOLD = 30;
|
||||
|
||||
/** Auto-expanding textarea that collapses back to a single-line input on demand. */
|
||||
function ExpandableInput({
|
||||
value,
|
||||
placeholder,
|
||||
ariaLabel,
|
||||
disabled,
|
||||
className,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
ariaLabel?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onChange: (nextValue: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const autoResize = useCallback((el: HTMLTextAreaElement) => {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, []);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
// Strip newlines — these fields are single-line identifiers/paths that
|
||||
// would break YAML serialization if they contained line breaks.
|
||||
const sanitized = e.target.value.replace(/[\r\n]/g, '');
|
||||
onChange(sanitized);
|
||||
// autoResize is handled by useLayoutEffect after React syncs the
|
||||
// sanitized value back to the DOM — calling it here would measure
|
||||
// stale content.
|
||||
};
|
||||
|
||||
// Resize synchronously before paint to avoid visual flicker.
|
||||
useLayoutEffect(() => {
|
||||
if (!collapsed && textareaRef.current) {
|
||||
autoResize(textareaRef.current);
|
||||
}
|
||||
}, [collapsed, value, autoResize]);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className={styles.expandableInputWrapper}>
|
||||
<input
|
||||
className={`input ${className ?? ''}`}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value.replace(/[\r\n]/g, ''))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{value.length > EXPAND_THRESHOLD && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.expandableToggle}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setCollapsed(false);
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.focus();
|
||||
});
|
||||
}}
|
||||
title={t('common.expand')}
|
||||
aria-label={t('common.expand')}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.expandableInputWrapper} ${styles.expandableInputExpanded}`}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={`input ${styles.expandableTextarea} ${className ?? ''}`}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
rows={2}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.expandableToggle}
|
||||
disabled={disabled}
|
||||
onClick={() => setCollapsed(true)}
|
||||
title={t('common.collapse')}
|
||||
aria-label={t('common.collapse')}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getValidationMessage(
|
||||
t: ReturnType<typeof useTranslation>['t'],
|
||||
errorCode?: PayloadParamValidationErrorCode
|
||||
@@ -325,14 +428,12 @@ const StringListEditor = memo(function StringListEditor({
|
||||
<div className={styles.stringList}>
|
||||
{items.map((item, index) => (
|
||||
<div key={renderItemIds[index] ?? `item-${index}`} className={styles.stringListRow}>
|
||||
<input
|
||||
className="input"
|
||||
<ExpandableInput
|
||||
placeholder={placeholder}
|
||||
aria-label={inputAriaLabel ?? placeholder}
|
||||
ariaLabel={inputAriaLabel ?? placeholder}
|
||||
value={item}
|
||||
onChange={(e) => updateItem(index, e.target.value)}
|
||||
onChange={(nextValue) => updateItem(index, nextValue)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeItem(index)} disabled={disabled}>
|
||||
{t('config_management.visual.common.delete')}
|
||||
@@ -508,12 +609,11 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
className="input"
|
||||
<ExpandableInput
|
||||
placeholder={getValuePlaceholder(param.valueType)}
|
||||
aria-label={t('config_management.visual.payload_rules.param_value')}
|
||||
ariaLabel={t('config_management.visual.payload_rules.param_value')}
|
||||
value={param.value}
|
||||
onChange={(e) => updateParam(ruleIndex, paramIndex, { value: e.target.value })}
|
||||
onChange={(nextValue) => updateParam(ruleIndex, paramIndex, { value: nextValue })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
@@ -564,23 +664,21 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
<ExpandableInput
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
aria-label={t('config_management.visual.payload_rules.model_name')}
|
||||
ariaLabel={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||
onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
<ExpandableInput
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
aria-label={t('config_management.visual.payload_rules.model_name')}
|
||||
ariaLabel={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||
onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
@@ -629,12 +727,11 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
|
||||
return (
|
||||
<div key={param.id} className={styles.payloadRuleParamGroup}>
|
||||
<div className={styles.payloadRuleParamRow}>
|
||||
<input
|
||||
className="input"
|
||||
<ExpandableInput
|
||||
placeholder={t('config_management.visual.payload_rules.json_path')}
|
||||
aria-label={t('config_management.visual.payload_rules.json_path')}
|
||||
ariaLabel={t('config_management.visual.payload_rules.json_path')}
|
||||
value={param.path}
|
||||
onChange={(e) => updateParam(ruleIndex, paramIndex, { path: e.target.value })}
|
||||
onChange={(nextValue) => updateParam(ruleIndex, paramIndex, { path: nextValue })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{rawJsonValues ? null : (
|
||||
@@ -767,12 +864,11 @@ export const PayloadFilterRulesEditor = memo(function PayloadFilterRulesEditor({
|
||||
</div>
|
||||
{rule.models.map((model, modelIndex) => (
|
||||
<div key={model.id} className={styles.payloadFilterModelRow}>
|
||||
<input
|
||||
className="input"
|
||||
<ExpandableInput
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
aria-label={t('config_management.visual.payload_rules.model_name')}
|
||||
ariaLabel={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||
onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
|
||||
@@ -64,6 +64,8 @@ interface QuotaCardProps<TState extends QuotaStatusState> {
|
||||
cardIdleMessageKey?: string;
|
||||
cardClassName: string;
|
||||
defaultType: string;
|
||||
canRefresh?: boolean;
|
||||
onRefresh?: () => void;
|
||||
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
|
||||
}
|
||||
|
||||
@@ -75,6 +77,8 @@ export function QuotaCard<TState extends QuotaStatusState>({
|
||||
cardIdleMessageKey,
|
||||
cardClassName,
|
||||
defaultType,
|
||||
canRefresh = false,
|
||||
onRefresh,
|
||||
renderQuotaItems
|
||||
}: QuotaCardProps<TState>) {
|
||||
const { t } = useTranslation();
|
||||
@@ -90,7 +94,7 @@ export function QuotaCard<TState extends QuotaStatusState>({
|
||||
quota?.errorStatus,
|
||||
quota?.error || t('common.unknown_error')
|
||||
);
|
||||
const idleMessageKey = cardIdleMessageKey ?? `${i18nPrefix}.idle`;
|
||||
const idleMessageKey = onRefresh ? `${i18nPrefix}.idle` : (cardIdleMessageKey ?? `${i18nPrefix}.idle`);
|
||||
|
||||
const getTypeLabel = (type: string): string => {
|
||||
const key = `auth_files.filter_${type}`;
|
||||
@@ -120,7 +124,18 @@ export function QuotaCard<TState extends QuotaStatusState>({
|
||||
{quotaStatus === 'loading' ? (
|
||||
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.loading`)}</div>
|
||||
) : quotaStatus === 'idle' ? (
|
||||
<div className={styles.quotaMessage}>{t(idleMessageKey)}</div>
|
||||
onRefresh ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.quotaMessage} ${styles.quotaMessageAction}`}
|
||||
onClick={onRefresh}
|
||||
disabled={!canRefresh}
|
||||
>
|
||||
{t(idleMessageKey)}
|
||||
</button>
|
||||
) : (
|
||||
<div className={styles.quotaMessage}>{t(idleMessageKey)}</div>
|
||||
)
|
||||
) : quotaStatus === 'error' ? (
|
||||
<div className={styles.quotaError}>
|
||||
{t(`${i18nPrefix}.load_failed`, {
|
||||
|
||||
@@ -8,8 +8,9 @@ import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { useQuotaStore, useThemeStore } from '@/stores';
|
||||
import { useNotificationStore, useQuotaStore, useThemeStore } from '@/stores';
|
||||
import type { AuthFileItem, ResolvedTheme } from '@/types';
|
||||
import { getStatusFromError } from '@/utils/quota';
|
||||
import { QuotaCard } from './QuotaCard';
|
||||
import type { QuotaStatusState } from './QuotaCard';
|
||||
import { useQuotaLoader } from './useQuotaLoader';
|
||||
@@ -105,6 +106,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
||||
}: QuotaSectionProps<TState, TData>) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const showNotification = useNotificationStore((state) => state.showNotification);
|
||||
const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter<
|
||||
Record<string, TState>
|
||||
>;
|
||||
@@ -202,6 +204,39 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
||||
});
|
||||
}, [filteredFiles, loading, setQuota]);
|
||||
|
||||
const refreshQuotaForFile = useCallback(
|
||||
async (file: AuthFileItem) => {
|
||||
if (disabled || file.disabled) return;
|
||||
if (quota[file.name]?.status === 'loading') return;
|
||||
|
||||
setQuota((prev) => ({
|
||||
...prev,
|
||||
[file.name]: config.buildLoadingState()
|
||||
}));
|
||||
|
||||
try {
|
||||
const data = await config.fetchQuota(file, t);
|
||||
setQuota((prev) => ({
|
||||
...prev,
|
||||
[file.name]: config.buildSuccessState(data)
|
||||
}));
|
||||
showNotification(t('auth_files.quota_refresh_success', { name: file.name }), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('common.unknown_error');
|
||||
const status = getStatusFromError(err);
|
||||
setQuota((prev) => ({
|
||||
...prev,
|
||||
[file.name]: config.buildErrorState(message, status)
|
||||
}));
|
||||
showNotification(
|
||||
t('auth_files.quota_refresh_failed', { name: file.name, message }),
|
||||
'error'
|
||||
);
|
||||
}
|
||||
},
|
||||
[config, disabled, quota, setQuota, showNotification, t]
|
||||
);
|
||||
|
||||
const titleNode = (
|
||||
<div className={styles.titleWrapper}>
|
||||
<span>{t(`${config.i18nPrefix}.title`)}</span>
|
||||
@@ -222,15 +257,21 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
||||
<div className={styles.headerActions}>
|
||||
<div className={styles.viewModeToggle}>
|
||||
<Button
|
||||
variant={effectiveViewMode === 'paged' ? 'primary' : 'secondary'}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className={`${styles.viewModeButton} ${
|
||||
effectiveViewMode === 'paged' ? styles.viewModeButtonActive : ''
|
||||
}`}
|
||||
onClick={() => setViewMode('paged')}
|
||||
>
|
||||
{t('auth_files.view_mode_paged')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={effectiveViewMode === 'all' ? 'primary' : 'secondary'}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className={`${styles.viewModeButton} ${
|
||||
effectiveViewMode === 'all' ? styles.viewModeButtonActive : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (filteredFiles.length > MAX_SHOW_ALL_THRESHOLD) {
|
||||
setShowTooManyWarning(true);
|
||||
@@ -245,13 +286,15 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className={styles.refreshAllButton}
|
||||
onClick={handleRefresh}
|
||||
disabled={disabled || isRefreshing}
|
||||
loading={isRefreshing}
|
||||
title={t('quota_management.refresh_files_and_quota')}
|
||||
aria-label={t('quota_management.refresh_files_and_quota')}
|
||||
title={t('quota_management.refresh_all_credentials')}
|
||||
aria-label={t('quota_management.refresh_all_credentials')}
|
||||
>
|
||||
{!isRefreshing && <IconRefreshCw size={16} />}
|
||||
{t('quota_management.refresh_all_credentials')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
@@ -274,6 +317,8 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
||||
cardIdleMessageKey={config.cardIdleMessageKey}
|
||||
cardClassName={config.cardClassName}
|
||||
defaultType={config.type}
|
||||
canRefresh={!disabled && !item.disabled}
|
||||
onRefresh={() => void refreshQuotaForFile(item)}
|
||||
renderQuotaItems={config.renderQuotaItems}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -84,6 +84,8 @@ type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||
type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli' | 'kimi';
|
||||
|
||||
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
|
||||
const QUOTA_PROGRESS_HIGH_THRESHOLD = 70;
|
||||
const QUOTA_PROGRESS_MEDIUM_THRESHOLD = 30;
|
||||
const geminiCliSupplementaryRequestIds = new Map<string, number>();
|
||||
const geminiCliSupplementaryCache = new Map<
|
||||
string,
|
||||
@@ -721,7 +723,11 @@ const renderAntigravityItems = (
|
||||
h('span', { className: styleMap.quotaReset }, resetLabel)
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
|
||||
h(QuotaProgressBar, {
|
||||
percent,
|
||||
highThreshold: QUOTA_PROGRESS_HIGH_THRESHOLD,
|
||||
mediumThreshold: QUOTA_PROGRESS_MEDIUM_THRESHOLD,
|
||||
})
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -795,7 +801,11 @@ const renderCodexItems = (
|
||||
h('span', { className: styleMap.quotaReset }, window.resetLabel)
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 })
|
||||
h(QuotaProgressBar, {
|
||||
percent: remaining,
|
||||
highThreshold: QUOTA_PROGRESS_HIGH_THRESHOLD,
|
||||
mediumThreshold: QUOTA_PROGRESS_MEDIUM_THRESHOLD,
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
@@ -886,7 +896,11 @@ const renderGeminiCliItems = (
|
||||
h('span', { className: styleMap.quotaReset }, resetLabel)
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
|
||||
h(QuotaProgressBar, {
|
||||
percent,
|
||||
highThreshold: QUOTA_PROGRESS_HIGH_THRESHOLD,
|
||||
mediumThreshold: QUOTA_PROGRESS_MEDIUM_THRESHOLD,
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
@@ -1078,7 +1092,11 @@ const renderClaudeItems = (
|
||||
h('span', { className: styleMap.quotaReset }, window.resetLabel)
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 })
|
||||
h(QuotaProgressBar, {
|
||||
percent: remaining,
|
||||
highThreshold: QUOTA_PROGRESS_HIGH_THRESHOLD,
|
||||
mediumThreshold: QUOTA_PROGRESS_MEDIUM_THRESHOLD,
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
@@ -1293,7 +1311,11 @@ const renderKimiItems = (
|
||||
: null
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent: remaining, highThreshold: 60, mediumThreshold: 20 })
|
||||
h(QuotaProgressBar, {
|
||||
percent: remaining,
|
||||
highThreshold: QUOTA_PROGRESS_HIGH_THRESHOLD,
|
||||
mediumThreshold: QUOTA_PROGRESS_MEDIUM_THRESHOLD,
|
||||
})
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -277,7 +277,7 @@ export function Modal({
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={modalClass}
|
||||
style={{ width }}
|
||||
style={{ width, maxWidth: '100%' }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? titleId : undefined}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
collectUsageDetails,
|
||||
@@ -10,11 +11,28 @@ import type { UsagePayload } from './hooks/useUsageData';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
const COLOR_STOPS = [
|
||||
{ r: 239, g: 68, b: 68 }, // #ef4444
|
||||
{ r: 250, g: 204, b: 21 }, // #facc15
|
||||
{ r: 34, g: 197, b: 94 }, // #22c55e
|
||||
{ r: 239, g: 68, b: 68 }, // #ef4444
|
||||
{ r: 250, g: 204, b: 21 }, // #facc15
|
||||
{ r: 34, g: 197, b: 94 }, // #22c55e
|
||||
] as const;
|
||||
|
||||
const TOOLTIP_OFFSET = 8;
|
||||
const TOOLTIP_SAFE_WIDTH = 180;
|
||||
const TOOLTIP_SAFE_HEIGHT = 72;
|
||||
|
||||
type TooltipHorizontalPosition = 'center' | 'left' | 'right';
|
||||
type TooltipVerticalPosition = 'above' | 'below';
|
||||
|
||||
interface ActiveTooltipState {
|
||||
idx: number;
|
||||
anchorEl: HTMLDivElement;
|
||||
horizontal: TooltipHorizontalPosition;
|
||||
vertical: TooltipVerticalPosition;
|
||||
left: number;
|
||||
top: number;
|
||||
transform: string;
|
||||
}
|
||||
|
||||
function rateToColor(rate: number): string {
|
||||
const t = Math.max(0, Math.min(1, rate));
|
||||
const segment = t < 0.5 ? 0 : 1;
|
||||
@@ -43,7 +61,7 @@ export interface ServiceHealthCardProps {
|
||||
|
||||
export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [activeTooltip, setActiveTooltip] = useState<number | null>(null);
|
||||
const [activeTooltip, setActiveTooltip] = useState<ActiveTooltipState | null>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const healthData: ServiceHealthData = useMemo(() => {
|
||||
@@ -64,11 +82,74 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
|
||||
return () => document.removeEventListener('pointerdown', handler);
|
||||
}, [activeTooltip]);
|
||||
|
||||
const handlePointerEnter = useCallback((e: React.PointerEvent, idx: number) => {
|
||||
if (e.pointerType === 'mouse') {
|
||||
setActiveTooltip(idx);
|
||||
}
|
||||
}, []);
|
||||
const buildTooltipState = useCallback(
|
||||
(idx: number, anchorEl: HTMLDivElement): ActiveTooltipState => {
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
|
||||
let horizontal: TooltipHorizontalPosition = 'center';
|
||||
let left = centerX;
|
||||
|
||||
if (centerX <= TOOLTIP_SAFE_WIDTH / 2) {
|
||||
horizontal = 'left';
|
||||
left = rect.left;
|
||||
} else if (centerX >= window.innerWidth - TOOLTIP_SAFE_WIDTH / 2) {
|
||||
horizontal = 'right';
|
||||
left = rect.right;
|
||||
}
|
||||
|
||||
const vertical: TooltipVerticalPosition = rect.top <= TOOLTIP_SAFE_HEIGHT ? 'below' : 'above';
|
||||
const top = vertical === 'below' ? rect.bottom + TOOLTIP_OFFSET : rect.top - TOOLTIP_OFFSET;
|
||||
const translateX = horizontal === 'center' ? '-50%' : horizontal === 'right' ? '-100%' : '0';
|
||||
const translateY = vertical === 'below' ? '0' : '-100%';
|
||||
|
||||
return {
|
||||
idx,
|
||||
anchorEl,
|
||||
horizontal,
|
||||
vertical,
|
||||
left: Math.round(left),
|
||||
top: Math.round(top),
|
||||
transform: `translate(${translateX}, ${translateY})`,
|
||||
};
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTooltip) return;
|
||||
|
||||
const updateTooltipPosition = () => {
|
||||
if (!document.body.contains(activeTooltip.anchorEl)) {
|
||||
setActiveTooltip(null);
|
||||
return;
|
||||
}
|
||||
setActiveTooltip(buildTooltipState(activeTooltip.idx, activeTooltip.anchorEl));
|
||||
};
|
||||
|
||||
window.addEventListener('resize', updateTooltipPosition);
|
||||
window.addEventListener('scroll', updateTooltipPosition, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateTooltipPosition);
|
||||
window.removeEventListener('scroll', updateTooltipPosition, true);
|
||||
};
|
||||
}, [activeTooltip, buildTooltipState]);
|
||||
|
||||
const openTooltip = useCallback(
|
||||
(idx: number, anchorEl: HTMLDivElement) => {
|
||||
setActiveTooltip(buildTooltipState(idx, anchorEl));
|
||||
},
|
||||
[buildTooltipState]
|
||||
);
|
||||
|
||||
const handlePointerEnter = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>, idx: number) => {
|
||||
if (e.pointerType === 'mouse') {
|
||||
openTooltip(idx, e.currentTarget);
|
||||
}
|
||||
},
|
||||
[openTooltip]
|
||||
);
|
||||
|
||||
const handlePointerLeave = useCallback((e: React.PointerEvent) => {
|
||||
if (e.pointerType === 'mouse') {
|
||||
@@ -76,39 +157,49 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent, idx: number) => {
|
||||
if (e.pointerType === 'touch') {
|
||||
e.preventDefault();
|
||||
setActiveTooltip((prev) => (prev === idx ? null : idx));
|
||||
}
|
||||
}, []);
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>, idx: number) => {
|
||||
if (e.pointerType === 'touch') {
|
||||
e.preventDefault();
|
||||
setActiveTooltip((prev) =>
|
||||
prev?.idx === idx ? null : buildTooltipState(idx, e.currentTarget)
|
||||
);
|
||||
}
|
||||
},
|
||||
[buildTooltipState]
|
||||
);
|
||||
|
||||
const getTooltipPositionClass = (idx: number): string => {
|
||||
const col = Math.floor(idx / healthData.rows);
|
||||
if (col <= 2) return styles.healthTooltipLeft;
|
||||
if (col >= healthData.cols - 3) return styles.healthTooltipRight;
|
||||
return '';
|
||||
};
|
||||
|
||||
const getTooltipVerticalClass = (idx: number): string => {
|
||||
const row = idx % healthData.rows;
|
||||
if (row <= 1) return styles.healthTooltipBelow;
|
||||
return '';
|
||||
};
|
||||
|
||||
const renderTooltip = (detail: StatusBlockDetail, idx: number) => {
|
||||
const renderTooltip = (detail: StatusBlockDetail, tooltipState: ActiveTooltipState) => {
|
||||
const total = detail.success + detail.failure;
|
||||
const posClass = getTooltipPositionClass(idx);
|
||||
const vertClass = getTooltipVerticalClass(idx);
|
||||
const posClass =
|
||||
tooltipState.horizontal === 'left'
|
||||
? styles.healthTooltipLeft
|
||||
: tooltipState.horizontal === 'right'
|
||||
? styles.healthTooltipRight
|
||||
: '';
|
||||
const vertClass = tooltipState.vertical === 'below' ? styles.healthTooltipBelow : '';
|
||||
const timeRange = `${formatDateTime(detail.startTime)} – ${formatDateTime(detail.endTime)}`;
|
||||
|
||||
return (
|
||||
<div className={`${styles.healthTooltip} ${posClass} ${vertClass}`}>
|
||||
const tooltip = (
|
||||
<div
|
||||
className={`${styles.healthTooltip} ${posClass} ${vertClass}`}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${tooltipState.left}px`,
|
||||
top: `${tooltipState.top}px`,
|
||||
bottom: 'auto',
|
||||
right: 'auto',
|
||||
transform: tooltipState.transform,
|
||||
}}
|
||||
>
|
||||
<span className={styles.healthTooltipTime}>{timeRange}</span>
|
||||
{total > 0 ? (
|
||||
<span className={styles.healthTooltipStats}>
|
||||
<span className={styles.healthTooltipSuccess}>{t('status_bar.success_short')} {detail.success}</span>
|
||||
<span className={styles.healthTooltipFailure}>{t('status_bar.failure_short')} {detail.failure}</span>
|
||||
<span className={styles.healthTooltipSuccess}>
|
||||
{t('status_bar.success_short')} {detail.success}
|
||||
</span>
|
||||
<span className={styles.healthTooltipFailure}>
|
||||
{t('status_bar.failure_short')} {detail.failure}
|
||||
</span>
|
||||
<span className={styles.healthTooltipRate}>({(detail.rate * 100).toFixed(1)}%)</span>
|
||||
</span>
|
||||
) : (
|
||||
@@ -116,6 +207,8 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof document === 'undefined' ? tooltip : createPortal(tooltip, document.body);
|
||||
};
|
||||
|
||||
const rateClass = !hasData
|
||||
@@ -138,32 +231,29 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.healthGridScroller}>
|
||||
<div
|
||||
className={styles.healthGrid}
|
||||
ref={gridRef}
|
||||
>
|
||||
{healthData.blockDetails.map((detail, idx) => {
|
||||
const isIdle = detail.rate === -1;
|
||||
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
|
||||
const isActive = activeTooltip === idx;
|
||||
<div className={styles.healthGrid} ref={gridRef}>
|
||||
{healthData.blockDetails.map((detail, idx) => {
|
||||
const isIdle = detail.rate === -1;
|
||||
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
|
||||
const isActive = activeTooltip?.idx === idx;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`${styles.healthBlockWrapper} ${isActive ? styles.healthBlockActive : ''}`}
|
||||
onPointerEnter={(e) => handlePointerEnter(e, idx)}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
onPointerDown={(e) => handlePointerDown(e, idx)}
|
||||
>
|
||||
return (
|
||||
<div
|
||||
className={`${styles.healthBlock} ${isIdle ? styles.healthBlockIdle : ''}`}
|
||||
style={blockStyle}
|
||||
/>
|
||||
{isActive && renderTooltip(detail, idx)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
key={idx}
|
||||
className={`${styles.healthBlockWrapper} ${isActive ? styles.healthBlockActive : ''}`}
|
||||
onPointerEnter={(e) => handlePointerEnter(e, idx)}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
onPointerDown={(e) => handlePointerDown(e, idx)}
|
||||
>
|
||||
<div
|
||||
className={`${styles.healthBlock} ${isIdle ? styles.healthBlockIdle : ''}`}
|
||||
style={blockStyle}
|
||||
/>
|
||||
{isActive && activeTooltip && renderTooltip(detail, activeTooltip)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.healthLegend}>
|
||||
<span className={styles.healthLegendLabel}>{t('service_health.oldest')}</span>
|
||||
|
||||
@@ -117,6 +117,33 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
setSelectedFiles(new Set());
|
||||
}, []);
|
||||
|
||||
const applyDeletedFiles = useCallback((names: string[]) => {
|
||||
const deletedNames = Array.from(
|
||||
new Set(
|
||||
names
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
if (deletedNames.length === 0) return;
|
||||
|
||||
const deletedSet = new Set(deletedNames);
|
||||
setFiles((prev) => prev.filter((file) => !deletedSet.has(file.name)));
|
||||
setSelectedFiles((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (deletedSet.has(name)) {
|
||||
changed = true;
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFiles.size === 0) return;
|
||||
const existingNames = new Set(files.map((file) => file.name));
|
||||
@@ -190,36 +217,33 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
let successCount = 0;
|
||||
const failed: { name: string; message: string }[] = [];
|
||||
try {
|
||||
const result = await authFilesApi.uploadFiles(validFiles);
|
||||
const successCount = result.uploaded;
|
||||
|
||||
for (const file of validFiles) {
|
||||
try {
|
||||
await authFilesApi.upload(file);
|
||||
successCount++;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
failed.push({ name: file.name, message: errorMessage });
|
||||
if (successCount > 0) {
|
||||
const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : '';
|
||||
showNotification(
|
||||
`${t('auth_files.upload_success')}${suffix}`,
|
||||
result.failed.length ? 'warning' : 'success'
|
||||
);
|
||||
await loadFiles();
|
||||
await refreshKeyStats();
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : '';
|
||||
showNotification(
|
||||
`${t('auth_files.upload_success')}${suffix}`,
|
||||
failed.length ? 'warning' : 'success'
|
||||
);
|
||||
await loadFiles();
|
||||
await refreshKeyStats();
|
||||
if (result.failed.length > 0) {
|
||||
const details = result.failed
|
||||
.map((item) => `${item.name}: ${item.error}`)
|
||||
.join('; ');
|
||||
showNotification(`${t('notification.upload_failed')}: ${details}`, 'error');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
showNotification(`${t('notification.upload_failed')}: ${errorMessage}`, 'error');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
const details = failed.map((item) => `${item.name}: ${item.message}`).join('; ');
|
||||
showNotification(`${t('notification.upload_failed')}: ${details}`, 'error');
|
||||
}
|
||||
|
||||
setUploading(false);
|
||||
event.target.value = '';
|
||||
},
|
||||
[loadFiles, refreshKeyStats, showNotification, t]
|
||||
);
|
||||
@@ -234,15 +258,9 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
onConfirm: async () => {
|
||||
setDeleting(name);
|
||||
try {
|
||||
await authFilesApi.deleteFile(name);
|
||||
const result = await authFilesApi.deleteFile(name);
|
||||
showNotification(t('auth_files.delete_success'), 'success');
|
||||
setFiles((prev) => prev.filter((item) => item.name !== name));
|
||||
setSelectedFiles((prev) => {
|
||||
if (!prev.has(name)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(name);
|
||||
return next;
|
||||
});
|
||||
applyDeletedFiles(result.files.length > 0 ? result.files : [name]);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
|
||||
@@ -252,7 +270,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
},
|
||||
});
|
||||
},
|
||||
[showConfirmation, showNotification, t]
|
||||
[applyDeletedFiles, showConfirmation, showNotification, t]
|
||||
);
|
||||
|
||||
const handleDeleteAll = useCallback(
|
||||
@@ -301,35 +319,13 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
return;
|
||||
}
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
const deletedNames: string[] = [];
|
||||
const result = await authFilesApi.deleteFiles(
|
||||
filesToDelete.map((file) => file.name)
|
||||
);
|
||||
const success = result.deleted;
|
||||
const failed = result.failed.length;
|
||||
|
||||
for (const file of filesToDelete) {
|
||||
try {
|
||||
await authFilesApi.deleteFile(file.name);
|
||||
success++;
|
||||
deletedNames.push(file.name);
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
setFiles((prev) => prev.filter((f) => !deletedNames.includes(f.name)));
|
||||
setSelectedFiles((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const deletedSet = new Set(deletedNames);
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (deletedSet.has(name)) {
|
||||
changed = true;
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
applyDeletedFiles(result.files);
|
||||
|
||||
if (failed === 0 && isProblemOnly) {
|
||||
showNotification(
|
||||
@@ -380,7 +376,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
},
|
||||
});
|
||||
},
|
||||
[deselectAll, files, showConfirmation, showNotification, t]
|
||||
[applyDeletedFiles, deselectAll, files, showConfirmation, showNotification, t]
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
@@ -579,59 +575,33 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
variant: 'danger',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: async () => {
|
||||
const results = await Promise.allSettled(
|
||||
uniqueNames.map((name) => authFilesApi.deleteFile(name))
|
||||
);
|
||||
try {
|
||||
const result = await authFilesApi.deleteFiles(uniqueNames);
|
||||
applyDeletedFiles(result.files);
|
||||
|
||||
const deleted: string[] = [];
|
||||
let failCount = 0;
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
deleted.push(uniqueNames[index]);
|
||||
if (result.failed.length === 0) {
|
||||
showNotification(
|
||||
`${t('auth_files.delete_all_success')} (${result.deleted})`,
|
||||
'success'
|
||||
);
|
||||
} else {
|
||||
failCount++;
|
||||
showNotification(
|
||||
t('auth_files.delete_filtered_partial', {
|
||||
success: result.deleted,
|
||||
failed: result.failed.length,
|
||||
type: t('auth_files.filter_all'),
|
||||
}),
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (deleted.length > 0) {
|
||||
const deletedSet = new Set(deleted);
|
||||
setFiles((prev) => prev.filter((file) => !deletedSet.has(file.name)));
|
||||
}
|
||||
|
||||
setSelectedFiles((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const deletedSet = new Set(deleted);
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (deletedSet.has(name)) {
|
||||
changed = true;
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
|
||||
if (failCount === 0) {
|
||||
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'),
|
||||
}),
|
||||
'warning'
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[showConfirmation, showNotification, t]
|
||||
[applyDeletedFiles, showConfirmation, showNotification, t]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"quota_update_required": "Please update the CPA version or check for updates",
|
||||
"quota_check_credential": "Please check the credential status",
|
||||
"copy": "Copy",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse",
|
||||
"status": "Status",
|
||||
"action": "Action",
|
||||
"custom_headers_label": "Custom Headers",
|
||||
@@ -138,7 +140,17 @@
|
||||
"edit_settings": "Edit Settings",
|
||||
"routing_strategy": "Routing Strategy",
|
||||
"available_models": "Available Models",
|
||||
"available_models_desc": "Total models from all providers"
|
||||
"available_models_desc": "Total models from all providers",
|
||||
"welcome_back": "Welcome Back",
|
||||
"greeting_morning": "Good Morning",
|
||||
"greeting_afternoon": "Good Afternoon",
|
||||
"greeting_evening": "Good Evening",
|
||||
"greeting_night": "Good Night",
|
||||
"caring_morning": "A fresh start — let's make today count.",
|
||||
"caring_afternoon": "Steady progress — you're doing great.",
|
||||
"caring_evening": "Wrapping up nicely — almost there.",
|
||||
"caring_night": "Burning the midnight oil? Don't forget to rest.",
|
||||
"system_overview": "System Overview"
|
||||
},
|
||||
"basic_settings": {
|
||||
"title": "Basic Settings",
|
||||
@@ -495,7 +507,7 @@
|
||||
"pagination_next": "Next",
|
||||
"pagination_info": "Page {{current}} / {{total}} · {{count}} files",
|
||||
"search_label": "Search configs",
|
||||
"search_placeholder": "Filter by name, type, or provider",
|
||||
"search_placeholder": "Filter by name, type, or provider. Use * as a wildcard",
|
||||
"problem_filter_label": "Problem Filter",
|
||||
"problem_filter_only": "Only show problematic credentials",
|
||||
"display_options_label": "Display options",
|
||||
@@ -1133,16 +1145,24 @@
|
||||
"description": "Edit config.yaml via visual editor or source file",
|
||||
"status_idle": "Waiting for action",
|
||||
"status_loading": "Loading configuration...",
|
||||
"status_loading_short": "Loading",
|
||||
"status_loaded": "Configuration loaded",
|
||||
"status_loaded_short": "Loaded",
|
||||
"status_dirty": "Unsaved changes",
|
||||
"status_dirty_short": "Unsaved",
|
||||
"status_disconnected": "Connect to the server to load the configuration",
|
||||
"status_disconnected_short": "Disconnected",
|
||||
"status_load_failed": "Load failed",
|
||||
"status_load_failed_short": "Failed",
|
||||
"status_saving": "Saving configuration...",
|
||||
"status_saving_short": "Saving",
|
||||
"status_saved": "Configuration saved",
|
||||
"status_save_failed": "Save failed",
|
||||
"save_success": "Configuration saved successfully",
|
||||
"error_yaml_not_supported": "Server did not return YAML. Verify the /config.yaml endpoint is available.",
|
||||
"visual_mode_unavailable": "Visual editor unavailable until YAML syntax is fixed",
|
||||
"visual_mode_unavailable_short": "YAML issue",
|
||||
"validation_blocked_short": "Fix errors",
|
||||
"visual_mode_unavailable_detail": "Visual editor is unavailable because the configuration contains invalid YAML: {{message}}",
|
||||
"visual_mode_save_blocked": "Cannot save from visual mode until the YAML syntax is fixed",
|
||||
"visual_mode_latest_yaml_invalid": "The latest server configuration contains invalid YAML. Review it in source mode before saving visual changes: {{message}}",
|
||||
@@ -1333,7 +1353,8 @@
|
||||
"description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.",
|
||||
"refresh_files": "Refresh auth files",
|
||||
"refresh_files_and_quota": "Refresh files & quota",
|
||||
"card_idle_hint": "Use the top \"Refresh files & quota\" button to fetch the latest quota data."
|
||||
"refresh_all_credentials": "Refresh all credentials",
|
||||
"card_idle_hint": "Use the top \"Refresh all credentials\" button to fetch the latest quota data."
|
||||
},
|
||||
"system_info": {
|
||||
"title": "Management Center Info",
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"quota_update_required": "Пожалуйста, обновите CPA или проверьте наличие обновлений",
|
||||
"quota_check_credential": "Пожалуйста, проверьте статус учётных данных",
|
||||
"copy": "Копировать",
|
||||
"expand": "Развернуть",
|
||||
"collapse": "Свернуть",
|
||||
"status": "Статус",
|
||||
"action": "Действие",
|
||||
"custom_headers_label": "Пользовательские заголовки",
|
||||
@@ -138,7 +140,17 @@
|
||||
"edit_settings": "Изменить настройки",
|
||||
"routing_strategy": "Стратегия маршрутизации",
|
||||
"available_models": "Доступные модели",
|
||||
"available_models_desc": "Всего моделей от всех провайдеров"
|
||||
"available_models_desc": "Всего моделей от всех провайдеров",
|
||||
"welcome_back": "С возвращением",
|
||||
"greeting_morning": "Доброе утро",
|
||||
"greeting_afternoon": "Добрый день",
|
||||
"greeting_evening": "Добрый вечер",
|
||||
"greeting_night": "Доброй ночи",
|
||||
"caring_morning": "Новый день — начнём продуктивно.",
|
||||
"caring_afternoon": "Уверенный прогресс — отличная работа.",
|
||||
"caring_evening": "День подходит к концу — финальный рывок.",
|
||||
"caring_night": "Поздняя работа? Не забудьте отдохнуть.",
|
||||
"system_overview": "Обзор системы"
|
||||
},
|
||||
"basic_settings": {
|
||||
"title": "Основные настройки",
|
||||
@@ -495,7 +507,7 @@
|
||||
"pagination_next": "Следующая",
|
||||
"pagination_info": "Страница {{current}} / {{total}} · {{count}} файлов",
|
||||
"search_label": "Поиск конфигов",
|
||||
"search_placeholder": "Фильтр по имени, типу или провайдеру",
|
||||
"search_placeholder": "Фильтр по имени, типу или провайдеру, поддерживается wildcard *",
|
||||
"problem_filter_label": "Фильтр проблем",
|
||||
"problem_filter_only": "Показывать только проблемные учётные данные",
|
||||
"display_options_label": "Параметры отображения",
|
||||
@@ -1136,16 +1148,24 @@
|
||||
"description": "Редактируйте config.yaml через визуальный редактор или исходный файл",
|
||||
"status_idle": "Ожидание действия",
|
||||
"status_loading": "Загрузка конфигурации...",
|
||||
"status_loading_short": "Загрузка",
|
||||
"status_loaded": "Конфигурация загружена",
|
||||
"status_loaded_short": "Загружено",
|
||||
"status_dirty": "Есть несохранённые изменения",
|
||||
"status_dirty_short": "Несохранено",
|
||||
"status_disconnected": "Подключитесь к серверу, чтобы загрузить конфигурацию",
|
||||
"status_disconnected_short": "Нет связи",
|
||||
"status_load_failed": "Не удалось загрузить",
|
||||
"status_load_failed_short": "Ошибка",
|
||||
"status_saving": "Сохранение конфигурации...",
|
||||
"status_saving_short": "Сохранение",
|
||||
"status_saved": "Конфигурация сохранена",
|
||||
"status_save_failed": "Не удалось сохранить",
|
||||
"save_success": "Конфигурация успешно сохранена",
|
||||
"error_yaml_not_supported": "Сервер не вернул YAML. Убедитесь, что доступна конечная точка /config.yaml.",
|
||||
"visual_mode_unavailable": "Визуальный редактор недоступен, пока не исправлен синтаксис YAML",
|
||||
"visual_mode_unavailable_short": "Ошибка YAML",
|
||||
"validation_blocked_short": "Есть ошибки",
|
||||
"visual_mode_unavailable_detail": "Визуальный редактор недоступен, потому что в конфигурации есть некорректный YAML: {{message}}",
|
||||
"visual_mode_save_blocked": "Нельзя сохранять из визуального режима, пока не исправлен синтаксис YAML",
|
||||
"visual_mode_latest_yaml_invalid": "Последняя конфигурация на сервере содержит некорректный YAML. Проверьте её в режиме исходника перед сохранением визуальных изменений: {{message}}",
|
||||
@@ -1338,7 +1358,8 @@
|
||||
"description": "Следите за статусом квот OAuth для учётных данных Antigravity, Codex и Gemini CLI.",
|
||||
"refresh_files": "Обновить файлы авторизации",
|
||||
"refresh_files_and_quota": "Обновить файлы и квоты",
|
||||
"card_idle_hint": "Используйте кнопку «Обновить файлы и квоты» сверху, чтобы загрузить актуальные данные по квотам."
|
||||
"refresh_all_credentials": "Обновить все учётные данные",
|
||||
"card_idle_hint": "Используйте кнопку «Обновить все учётные данные» сверху, чтобы загрузить актуальные данные по квотам."
|
||||
},
|
||||
"system_info": {
|
||||
"title": "Информация о центре управления",
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"quota_update_required": "请更新 CPA 版本或检查更新",
|
||||
"quota_check_credential": "请检查凭证状态",
|
||||
"copy": "复制",
|
||||
"expand": "展开",
|
||||
"collapse": "收起",
|
||||
"status": "状态",
|
||||
"action": "操作",
|
||||
"custom_headers_label": "自定义请求头",
|
||||
@@ -138,7 +140,17 @@
|
||||
"edit_settings": "编辑设置",
|
||||
"routing_strategy": "路由策略",
|
||||
"available_models": "可用模型",
|
||||
"available_models_desc": "所有提供商的模型总数"
|
||||
"available_models_desc": "所有提供商的模型总数",
|
||||
"welcome_back": "欢迎回来",
|
||||
"greeting_morning": "早上好",
|
||||
"greeting_afternoon": "下午好",
|
||||
"greeting_evening": "晚上好",
|
||||
"greeting_night": "夜深了",
|
||||
"caring_morning": "新的一天,准备大展身手吧。",
|
||||
"caring_afternoon": "稳步推进中,继续加油。",
|
||||
"caring_evening": "今天辛苦了,收尾工作做好哦。",
|
||||
"caring_night": "夜深了,别忘了早些休息。",
|
||||
"system_overview": "系统概览"
|
||||
},
|
||||
"basic_settings": {
|
||||
"title": "基础设置",
|
||||
@@ -495,7 +507,7 @@
|
||||
"pagination_next": "下一页",
|
||||
"pagination_info": "第 {{current}} / {{total}} 页 · 共 {{count}} 个文件",
|
||||
"search_label": "搜索配置文件",
|
||||
"search_placeholder": "输入名称、类型或提供方关键字",
|
||||
"search_placeholder": "输入名称、类型或提供方关键字,支持 * 通配",
|
||||
"problem_filter_label": "问题筛选",
|
||||
"problem_filter_only": "仅显示有问题凭证",
|
||||
"display_options_label": "显示选项",
|
||||
@@ -1133,16 +1145,24 @@
|
||||
"description": "通过可视化或者源文件方式编辑 config.yaml 配置文件",
|
||||
"status_idle": "等待操作",
|
||||
"status_loading": "加载配置中...",
|
||||
"status_loading_short": "加载中",
|
||||
"status_loaded": "配置已加载",
|
||||
"status_loaded_short": "已加载",
|
||||
"status_dirty": "有未保存的更改",
|
||||
"status_dirty_short": "未保存",
|
||||
"status_disconnected": "请先连接服务器以加载配置",
|
||||
"status_disconnected_short": "未连接",
|
||||
"status_load_failed": "加载失败",
|
||||
"status_load_failed_short": "失败",
|
||||
"status_saving": "正在保存配置...",
|
||||
"status_saving_short": "保存中",
|
||||
"status_saved": "配置保存完成",
|
||||
"status_save_failed": "保存失败",
|
||||
"save_success": "配置已保存",
|
||||
"error_yaml_not_supported": "服务器未返回 YAML 格式,请确认 /config.yaml 接口可用",
|
||||
"visual_mode_unavailable": "YAML 语法修复前无法使用可视化编辑",
|
||||
"visual_mode_unavailable_short": "YAML错误",
|
||||
"validation_blocked_short": "待修复",
|
||||
"visual_mode_unavailable_detail": "当前配置存在无效 YAML,暂时无法使用可视化编辑:{{message}}",
|
||||
"visual_mode_save_blocked": "请先修复 YAML 语法错误,再从可视化模式保存",
|
||||
"visual_mode_latest_yaml_invalid": "服务端最新配置包含无效 YAML,请先切回源码模式检查后再保存可视化修改:{{message}}",
|
||||
@@ -1333,7 +1353,8 @@
|
||||
"description": "集中查看 OAuth 额度与剩余情况",
|
||||
"refresh_files": "刷新认证文件",
|
||||
"refresh_files_and_quota": "刷新认证文件&额度",
|
||||
"card_idle_hint": "请使用顶部“刷新认证文件&额度”按钮获取最新额度。"
|
||||
"refresh_all_credentials": "刷新全部凭证",
|
||||
"card_idle_hint": "请使用顶部“刷新全部凭证”按钮获取最新额度。"
|
||||
},
|
||||
"system_info": {
|
||||
"title": "管理中心信息",
|
||||
|
||||
@@ -5,6 +5,8 @@ import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||
import App from './App.tsx';
|
||||
|
||||
document.title = 'CLI Proxy API Management Center';
|
||||
document.documentElement.setAttribute('translate', 'no');
|
||||
document.documentElement.classList.add('notranslate');
|
||||
|
||||
const faviconEl = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||
if (faviconEl) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
||||
import type { ApiKeyEntry } from '@/types';
|
||||
import { buildHeaderObject } from '@/utils/headers';
|
||||
import { buildHeaderObject, hasHeader } from '@/utils/headers';
|
||||
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '@/components/providers/utils';
|
||||
import type { OpenAIEditOutletContext } from './AiProvidersOpenAIEditLayout';
|
||||
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
|
||||
@@ -213,7 +213,7 @@ export function AiProvidersOpenAIEditPage() {
|
||||
'Content-Type': 'application/json',
|
||||
...customHeaders,
|
||||
};
|
||||
if (!headers.Authorization && !headers['authorization']) {
|
||||
if (!hasHeader(headers, 'authorization')) {
|
||||
headers.Authorization = `Bearer ${keyEntry.apiKey.trim()}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { modelsApi } from '@/services/api';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import { buildHeaderObject } from '@/utils/headers';
|
||||
import { buildHeaderObject, hasHeader } from '@/utils/headers';
|
||||
import { buildOpenAIModelsEndpoint } from '@/components/providers/utils';
|
||||
import type { OpenAIEditOutletContext } from './AiProvidersOpenAIEditLayout';
|
||||
import styles from './AiProvidersPage.module.scss';
|
||||
@@ -68,7 +68,7 @@ export function AiProvidersOpenAIModelsPage() {
|
||||
try {
|
||||
const headerObject = buildHeaderObject(form.headers);
|
||||
const firstKey = form.apiKeyEntries.find((entry) => entry.apiKey?.trim())?.apiKey?.trim();
|
||||
const hasAuthHeader = Boolean(headerObject.Authorization || headerObject['authorization']);
|
||||
const hasAuthHeader = hasHeader(headerObject, 'authorization');
|
||||
const list = await modelsApi.fetchModelsViaApiCall(
|
||||
trimmedBaseUrl,
|
||||
hasAuthHeader ? undefined : firstKey,
|
||||
|
||||
+205
-416
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,15 @@ const BATCH_BAR_HIDDEN_TRANSFORM = 'translateX(-50%) translateY(56px)';
|
||||
const DEFAULT_REGULAR_PAGE_SIZE = 9;
|
||||
const DEFAULT_COMPACT_PAGE_SIZE = 12;
|
||||
|
||||
const escapeWildcardSearchSegment = (value: string) =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
const buildWildcardSearch = (value: string): RegExp | null => {
|
||||
if (!value.includes('*')) return null;
|
||||
const pattern = value.split('*').map(escapeWildcardSearchSegment).join('.*');
|
||||
return new RegExp(pattern, 'i');
|
||||
};
|
||||
|
||||
export function AuthFilesPage() {
|
||||
const { t } = useTranslation();
|
||||
const showNotification = useNotificationStore((state) => state.showNotification);
|
||||
@@ -368,18 +377,25 @@ export function AuthFilesPage() {
|
||||
return counts;
|
||||
}, [filesMatchingProblemFilter]);
|
||||
|
||||
const normalizedSearch = search.trim();
|
||||
const wildcardSearch = useMemo(() => buildWildcardSearch(normalizedSearch), [normalizedSearch]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const normalizedTerm = normalizedSearch.toLowerCase();
|
||||
|
||||
return filesMatchingProblemFilter.filter((item) => {
|
||||
const matchType = filter === 'all' || item.type === filter;
|
||||
const term = search.trim().toLowerCase();
|
||||
const matchSearch =
|
||||
!term ||
|
||||
item.name.toLowerCase().includes(term) ||
|
||||
(item.type || '').toString().toLowerCase().includes(term) ||
|
||||
(item.provider || '').toString().toLowerCase().includes(term);
|
||||
!normalizedSearch ||
|
||||
[item.name, item.type, item.provider].some((value) => {
|
||||
const content = (value || '').toString();
|
||||
return wildcardSearch
|
||||
? wildcardSearch.test(content)
|
||||
: content.toLowerCase().includes(normalizedTerm);
|
||||
});
|
||||
return matchType && matchSearch;
|
||||
});
|
||||
}, [filesMatchingProblemFilter, filter, search]);
|
||||
}, [filesMatchingProblemFilter, filter, normalizedSearch, wildcardSearch]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const copy = [...filtered];
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
@include mobile {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +192,11 @@
|
||||
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);
|
||||
|
||||
@include mobile {
|
||||
padding: 16px;
|
||||
border-radius: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -434,6 +440,7 @@
|
||||
.floatingStatus {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
@@ -449,6 +456,12 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.floatingStatusCompact {
|
||||
max-width: 112px;
|
||||
padding: 0 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.floatingActionButton {
|
||||
@include button-reset;
|
||||
position: relative;
|
||||
@@ -503,7 +516,9 @@
|
||||
}
|
||||
|
||||
.floatingStatus {
|
||||
display: none;
|
||||
max-width: min(180px, 40vw);
|
||||
font-size: 10px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.floatingActionButton {
|
||||
@@ -512,3 +527,16 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.floatingStatus {
|
||||
max-width: min(132px, 38vw);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.pageHeader,
|
||||
.workspaceShell {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,9 @@ import {
|
||||
} from '@/components/ui/icons';
|
||||
import { VisualConfigEditor } from '@/components/config/VisualConfigEditor';
|
||||
import { DiffModal } from '@/components/config/DiffModal';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { useVisualConfig } from '@/hooks/useVisualConfig';
|
||||
import { useNotificationStore, useAuthStore, useThemeStore } from '@/stores';
|
||||
import { useNotificationStore, useAuthStore, useThemeStore, useConfigStore } from '@/stores';
|
||||
import { configFileApi } from '@/services/api/configFile';
|
||||
import styles from './ConfigPage.module.scss';
|
||||
|
||||
@@ -43,6 +44,7 @@ export function ConfigPage() {
|
||||
const showConfirmation = useNotificationStore((state) => state.showConfirmation);
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
const {
|
||||
visualValues,
|
||||
@@ -137,6 +139,24 @@ export function ConfigPage() {
|
||||
setServerYaml(latestContent);
|
||||
setMergedYaml(latestContent);
|
||||
loadVisualValuesFromYaml(latestContent);
|
||||
|
||||
// Keep the global config store in sync so sidebar / other pages reflect YAML changes immediately.
|
||||
try {
|
||||
useConfigStore.getState().clearCache();
|
||||
await useConfigStore.getState().fetchConfig(undefined, true);
|
||||
} catch (refreshError: unknown) {
|
||||
const message =
|
||||
refreshError instanceof Error
|
||||
? refreshError.message
|
||||
: typeof refreshError === 'string'
|
||||
? refreshError
|
||||
: '';
|
||||
showNotification(
|
||||
`${t('notification.refresh_failed')}${message ? `: ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
|
||||
showNotification(t('config_management.save_success'), 'success');
|
||||
if (commercialModeChanged) {
|
||||
showNotification(t('notification.commercial_mode_restart_required'), 'warning');
|
||||
@@ -411,6 +431,21 @@ export function ConfigPage() {
|
||||
return '';
|
||||
};
|
||||
|
||||
const getFloatingStatusText = () => {
|
||||
if (!isMobile) return getStatusText();
|
||||
if (disableControls)
|
||||
return t('config_management.status_disconnected_short', { defaultValue: 'Disconnected' });
|
||||
if (loading) return t('config_management.status_loading_short', { defaultValue: 'Loading' });
|
||||
if (error) return t('config_management.status_load_failed_short', { defaultValue: 'Failed' });
|
||||
if (hasVisualModeError)
|
||||
return t('config_management.visual_mode_unavailable_short', { defaultValue: 'YAML issue' });
|
||||
if (hasVisualValidationErrors)
|
||||
return t('config_management.visual.validation_blocked_short', { defaultValue: 'Fix errors' });
|
||||
if (saving) return t('config_management.status_saving_short', { defaultValue: 'Saving' });
|
||||
if (isDirty) return t('config_management.status_dirty_short', { defaultValue: 'Unsaved' });
|
||||
return t('config_management.status_loaded_short', { defaultValue: 'Loaded' });
|
||||
};
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
if (!isDirty) {
|
||||
void loadConfig();
|
||||
@@ -432,7 +467,13 @@ export function ConfigPage() {
|
||||
const floatingActions = (
|
||||
<div className={styles.floatingActionContainer} ref={floatingActionsRef}>
|
||||
<div className={styles.floatingActionList}>
|
||||
<div className={`${styles.floatingStatus} ${getStatusClass()}`}>{getStatusText()}</div>
|
||||
<div
|
||||
className={`${styles.floatingStatus} ${
|
||||
isMobile ? styles.floatingStatusCompact : ''
|
||||
} ${getStatusClass()}`}
|
||||
>
|
||||
{getFloatingStatusText()}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.floatingActionButton}
|
||||
|
||||
+364
-173
@@ -1,59 +1,234 @@
|
||||
@use 'sass:color';
|
||||
@use '../styles/variables.scss' as *;
|
||||
|
||||
// ─── Container ──────────────────────────────────────────
|
||||
|
||||
.dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
gap: $spacing-xl;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: $spacing-sm;
|
||||
// ─── Decorative Background Orbs ─────────────────────────
|
||||
|
||||
.backgroundOrbs {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
.orb1 {
|
||||
position: absolute;
|
||||
width: 420px;
|
||||
height: 420px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--primary-color) 8%, transparent),
|
||||
transparent 70%
|
||||
);
|
||||
top: -140px;
|
||||
right: -80px;
|
||||
animation: orbFloat 22s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.orb2 {
|
||||
position: absolute;
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--success-color) 6%, transparent),
|
||||
transparent 70%
|
||||
);
|
||||
bottom: 18%;
|
||||
left: -100px;
|
||||
animation: orbFloat 28s ease-in-out infinite alternate-reverse;
|
||||
}
|
||||
|
||||
@keyframes orbFloat {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: translate(30px, -20px) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hero Welcome Section ───────────────────────────────
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-lg;
|
||||
padding: $spacing-2xl $spacing-xl;
|
||||
border-radius: $radius-lg;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--bg-primary) 92%, transparent),
|
||||
color-mix(in srgb, var(--bg-secondary) 80%, transparent)
|
||||
);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
overflow: hidden;
|
||||
animation: heroEnter 0.6s ease-out both;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: $spacing-xl $spacing-lg;
|
||||
}
|
||||
}
|
||||
|
||||
// Large watermark text behind hero content
|
||||
.heroWatermark {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: $spacing-xl;
|
||||
transform: translateY(-50%);
|
||||
font-size: clamp(64px, 12vw, 120px);
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
opacity: 0.04;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
animation: watermarkEnter 0.8s ease-out 0.1s both;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
font-size: clamp(48px, 14vw, 80px);
|
||||
left: $spacing-lg;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
@keyframes watermarkEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-50%) translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 0.04;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.heroContent {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.heroGreeting {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--primary-color);
|
||||
animation: fadeSlideUp 0.5s ease-out 0.1s both;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
margin: 0;
|
||||
font-size: clamp(32px, 5vw, 48px);
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text-primary);
|
||||
animation: fadeSlideUp 0.5s ease-out 0.2s both;
|
||||
}
|
||||
|
||||
.heroCaring {
|
||||
margin: $spacing-xs 0 0;
|
||||
font-size: 15px;
|
||||
color: var(--text-secondary);
|
||||
margin: $spacing-xs 0 0 0;
|
||||
line-height: 1.5;
|
||||
animation: fadeSlideUp 0.5s ease-out 0.3s both;
|
||||
}
|
||||
|
||||
.connectionCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-lg;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
// ─── Hero Meta (right side) ─────────────────────────────
|
||||
|
||||
.connectionStatus {
|
||||
.heroMeta {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: $spacing-sm;
|
||||
flex-shrink: 0;
|
||||
animation: fadeSlideUp 0.5s ease-out 0.35s both;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
align-items: flex-start;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.dateTimeBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
// ─── Connection Pill ────────────────────────────────────
|
||||
|
||||
.connectionPill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border-radius: $radius-full;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: $gray-400;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.connected {
|
||||
background: $success-color;
|
||||
box-shadow: 0 0 8px rgba($success-color, 0.5);
|
||||
box-shadow: 0 0 6px rgba($success-color, 0.5);
|
||||
}
|
||||
|
||||
&.connecting {
|
||||
@@ -66,54 +241,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.statusText {
|
||||
.pillText {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.connectionInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.serverUrl {
|
||||
font-family: $font-mono;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-primary);
|
||||
padding: 4px 10px;
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.serverVersion {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
background: rgba($primary-color, 0.1);
|
||||
padding: 4px 10px;
|
||||
border-radius: $radius-full;
|
||||
}
|
||||
|
||||
.buildDate {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: right;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.statsGrid {
|
||||
// ─── Bento Stats Grid ───────────────────────────────────
|
||||
|
||||
.statsSection {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sectionHeading {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0 0 $spacing-md;
|
||||
}
|
||||
|
||||
.bentoGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: auto auto;
|
||||
gap: $spacing-md;
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@@ -125,150 +287,175 @@
|
||||
}
|
||||
}
|
||||
|
||||
.statCard {
|
||||
.bentoCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
padding: $spacing-lg;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-lg;
|
||||
text-decoration: none;
|
||||
transition: all $transition-fast;
|
||||
transition:
|
||||
border-color $transition-fast,
|
||||
box-shadow $transition-fast,
|
||||
transform $transition-fast;
|
||||
animation: cardEnter 0.4s ease-out both;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
.statIcon {
|
||||
// First card spans 2 rows — the "hero stat"
|
||||
.bentoLarge {
|
||||
grid-row: 1 / 3;
|
||||
justify-content: center;
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
color-mix(in srgb, var(--primary-color) 6%, var(--bg-primary)),
|
||||
var(--bg-primary)
|
||||
);
|
||||
|
||||
.bentoValue {
|
||||
font-size: 44px;
|
||||
}
|
||||
|
||||
.bentoIcon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
grid-row: auto;
|
||||
|
||||
.bentoValue {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.bentoIcon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bentoIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: $radius-md;
|
||||
background: var(--bg-secondary);
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, var(--bg-secondary));
|
||||
color: var(--primary-color);
|
||||
transition: background $transition-fast;
|
||||
}
|
||||
|
||||
.statContent {
|
||||
.bentoCard:hover .bentoIcon {
|
||||
background: color-mix(in srgb, var(--primary-color) 16%, var(--bg-secondary));
|
||||
}
|
||||
|
||||
.bentoContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: 24px;
|
||||
.bentoValue {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
.bentoLabel {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.statSublabel {
|
||||
.bentoSublabel {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.8;
|
||||
opacity: 0.7;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.section {
|
||||
// ─── Config Pills Section ───────────────────────────────
|
||||
|
||||
.configSection {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
animation: cardEnter 0.4s ease-out 0.5s both;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actionsGrid {
|
||||
.configPillGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-sm;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
.configPill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
|
||||
// Button 内部的 span 需要 flex 对齐图标和文字
|
||||
> span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.configGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.configItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
padding: 6px 14px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-full;
|
||||
font-size: 13px;
|
||||
transition: border-color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.configPillWide {
|
||||
flex-basis: 100%;
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
.configLabel {
|
||||
font-size: 13px;
|
||||
.configPillLabel {
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.configValue {
|
||||
font-size: 13px;
|
||||
.configPillValue {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
|
||||
&.enabled {
|
||||
&.on {
|
||||
color: $success-color;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
&.off {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.configValueMono {
|
||||
.configPillMono {
|
||||
font-size: 12px;
|
||||
font-family: $font-mono;
|
||||
color: var(--text-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
// Config badges (routing strategy)
|
||||
.configBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: $radius-full;
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
max-width: 100%;
|
||||
@@ -294,50 +481,7 @@
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.configItemFull {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
// Usage stats section
|
||||
.usageGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.usageCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-md;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.usageValue {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.usageLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.usageLoading,
|
||||
.usageEmpty {
|
||||
padding: $spacing-lg;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
// ─── View More Link ─────────────────────────────────────
|
||||
|
||||
.viewMoreLink {
|
||||
display: inline-flex;
|
||||
@@ -346,8 +490,55 @@
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
margin-top: $spacing-xs;
|
||||
transition: color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Animations ─────────────────────────────────────────
|
||||
|
||||
@keyframes heroEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeSlideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cardEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
+129
-68
@@ -27,6 +27,16 @@ interface ProviderStats {
|
||||
openai: number | null;
|
||||
}
|
||||
|
||||
type TimeOfDay = 'morning' | 'afternoon' | 'evening' | 'night';
|
||||
|
||||
function getTimeOfDay(): TimeOfDay {
|
||||
const hour = new Date().getHours();
|
||||
if (hour >= 5 && hour < 12) return 'morning';
|
||||
if (hour >= 12 && hour < 17) return 'afternoon';
|
||||
if (hour >= 17 && hour < 21) return 'evening';
|
||||
return 'night';
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
@@ -56,12 +66,25 @@ export function DashboardPage() {
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Time-of-day state for dynamic greeting
|
||||
const [timeOfDay, setTimeOfDay] = useState<TimeOfDay>(getTimeOfDay);
|
||||
const [currentTime, setCurrentTime] = useState(() => new Date());
|
||||
|
||||
const apiKeysCache = useRef<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
apiKeysCache.current = [];
|
||||
}, [apiBase, config?.apiKeys]);
|
||||
|
||||
// Update time every 60 seconds
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setTimeOfDay(getTimeOfDay());
|
||||
setCurrentTime(new Date());
|
||||
}, 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const normalizeApiKeyList = (input: unknown): string[] => {
|
||||
if (!Array.isArray(input)) return [];
|
||||
const seen = new Set<string>();
|
||||
@@ -237,113 +260,151 @@ export function DashboardPage() {
|
||||
? styles.configBadgeFillFirst
|
||||
: styles.configBadgeUnknown;
|
||||
|
||||
// Derived time-based values
|
||||
const greetingKey = `dashboard.greeting_${timeOfDay}`;
|
||||
const caringKey = `dashboard.caring_${timeOfDay}`;
|
||||
|
||||
const formattedDate = currentTime.toLocaleDateString(i18n.language, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
const formattedTime = currentTime.toLocaleTimeString(i18n.language, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.dashboard}>
|
||||
<div className={styles.header}>
|
||||
<h1 className={styles.title}>{t('dashboard.title')}</h1>
|
||||
<p className={styles.subtitle}>{t('dashboard.subtitle')}</p>
|
||||
{/* Decorative background orbs */}
|
||||
<div className={styles.backgroundOrbs} aria-hidden="true">
|
||||
<div className={styles.orb1} />
|
||||
<div className={styles.orb2} />
|
||||
</div>
|
||||
|
||||
<div className={styles.connectionCard}>
|
||||
<div className={styles.connectionStatus}>
|
||||
<span
|
||||
className={`${styles.statusDot} ${
|
||||
connectionStatus === 'connected'
|
||||
? styles.connected
|
||||
: connectionStatus === 'connecting'
|
||||
? styles.connecting
|
||||
: styles.disconnected
|
||||
}`}
|
||||
/>
|
||||
<span className={styles.statusText}>
|
||||
{t(
|
||||
connectionStatus === 'connected'
|
||||
? 'common.connected'
|
||||
: connectionStatus === 'connecting'
|
||||
? 'common.connecting'
|
||||
: 'common.disconnected'
|
||||
)}
|
||||
</span>
|
||||
{/* Hero welcome section */}
|
||||
<section className={styles.hero}>
|
||||
<span className={styles.heroWatermark} aria-hidden="true">
|
||||
OVERVIEW
|
||||
</span>
|
||||
<div className={styles.heroContent}>
|
||||
<span className={styles.heroGreeting}>{t(greetingKey)}</span>
|
||||
<h1 className={styles.heroTitle}>{t('dashboard.welcome_back')}</h1>
|
||||
<p className={styles.heroCaring}>{t(caringKey)}</p>
|
||||
</div>
|
||||
<div className={styles.connectionInfo}>
|
||||
<span className={styles.serverUrl}>{apiBase || '-'}</span>
|
||||
{serverVersion && (
|
||||
<span className={styles.serverVersion}>
|
||||
v{serverVersion.trim().replace(/^[vV]+/, '')}
|
||||
<div className={styles.heroMeta}>
|
||||
<div className={styles.dateTimeBlock}>
|
||||
<span className={styles.time}>{formattedTime}</span>
|
||||
<span className={styles.date}>{formattedDate}</span>
|
||||
</div>
|
||||
<div className={styles.connectionPill}>
|
||||
<span
|
||||
className={`${styles.statusDot} ${
|
||||
connectionStatus === 'connected'
|
||||
? styles.connected
|
||||
: connectionStatus === 'connecting'
|
||||
? styles.connecting
|
||||
: styles.disconnected
|
||||
}`}
|
||||
/>
|
||||
<span className={styles.pillText}>
|
||||
{serverVersion
|
||||
? `v${serverVersion.trim().replace(/^[vV]+/, '')}`
|
||||
: t(
|
||||
connectionStatus === 'connected'
|
||||
? 'common.connected'
|
||||
: connectionStatus === 'connecting'
|
||||
? 'common.connecting'
|
||||
: 'common.disconnected'
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{serverBuildDate && (
|
||||
<span className={styles.buildDate}>
|
||||
{new Date(serverBuildDate).toLocaleDateString(i18n.language)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className={styles.statsGrid}>
|
||||
{quickStats.map((stat) => (
|
||||
<Link key={stat.path} to={stat.path} className={styles.statCard}>
|
||||
<div className={styles.statIcon}>{stat.icon}</div>
|
||||
<div className={styles.statContent}>
|
||||
<span className={styles.statValue}>{stat.loading ? '...' : stat.value}</span>
|
||||
<span className={styles.statLabel}>{stat.label}</span>
|
||||
{stat.sublabel && !stat.loading && (
|
||||
<span className={styles.statSublabel}>{stat.sublabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{/* Bento stats grid */}
|
||||
<section className={styles.statsSection}>
|
||||
<h2 className={styles.sectionHeading}>{t('dashboard.system_overview')}</h2>
|
||||
<div className={styles.bentoGrid}>
|
||||
{quickStats.map((stat, index) => (
|
||||
<Link
|
||||
key={stat.path}
|
||||
to={stat.path}
|
||||
className={`${styles.bentoCard} ${index === 0 ? styles.bentoLarge : ''}`}
|
||||
style={{ animationDelay: `${index * 80}ms` }}
|
||||
>
|
||||
<div className={styles.bentoIcon}>{stat.icon}</div>
|
||||
<div className={styles.bentoContent}>
|
||||
<span className={styles.bentoValue}>
|
||||
{stat.loading ? '...' : stat.value}
|
||||
</span>
|
||||
<span className={styles.bentoLabel}>{stat.label}</span>
|
||||
{stat.sublabel && !stat.loading && (
|
||||
<span className={styles.bentoSublabel}>{stat.sublabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Config pills section */}
|
||||
{config && (
|
||||
<div className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>{t('dashboard.current_config')}</h2>
|
||||
<div className={styles.configGrid}>
|
||||
<div className={styles.configItem}>
|
||||
<span className={styles.configLabel}>{t('basic_settings.debug_enable')}</span>
|
||||
<span className={`${styles.configValue} ${config.debug ? styles.enabled : styles.disabled}`}>
|
||||
<section className={styles.configSection}>
|
||||
<h2 className={styles.sectionHeading}>{t('dashboard.current_config')}</h2>
|
||||
<div className={styles.configPillGrid}>
|
||||
<div className={styles.configPill}>
|
||||
<span className={styles.configPillLabel}>{t('basic_settings.debug_enable')}</span>
|
||||
<span className={`${styles.configPillValue} ${config.debug ? styles.on : styles.off}`}>
|
||||
{config.debug ? t('common.yes') : t('common.no')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.configItem}>
|
||||
<span className={styles.configLabel}>{t('basic_settings.usage_statistics_enable')}</span>
|
||||
<span className={`${styles.configValue} ${config.usageStatisticsEnabled ? styles.enabled : styles.disabled}`}>
|
||||
<div className={styles.configPill}>
|
||||
<span className={styles.configPillLabel}>{t('basic_settings.usage_statistics_enable')}</span>
|
||||
<span className={`${styles.configPillValue} ${config.usageStatisticsEnabled ? styles.on : styles.off}`}>
|
||||
{config.usageStatisticsEnabled ? t('common.yes') : t('common.no')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.configItem}>
|
||||
<span className={styles.configLabel}>{t('basic_settings.logging_to_file_enable')}</span>
|
||||
<span className={`${styles.configValue} ${config.loggingToFile ? styles.enabled : styles.disabled}`}>
|
||||
<div className={styles.configPill}>
|
||||
<span className={styles.configPillLabel}>{t('basic_settings.logging_to_file_enable')}</span>
|
||||
<span className={`${styles.configPillValue} ${config.loggingToFile ? styles.on : styles.off}`}>
|
||||
{config.loggingToFile ? t('common.yes') : t('common.no')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.configItem}>
|
||||
<span className={styles.configLabel}>{t('basic_settings.retry_count_label')}</span>
|
||||
<span className={styles.configValue}>{config.requestRetry ?? 0}</span>
|
||||
<div className={styles.configPill}>
|
||||
<span className={styles.configPillLabel}>{t('basic_settings.retry_count_label')}</span>
|
||||
<span className={styles.configPillValue}>{config.requestRetry ?? 0}</span>
|
||||
</div>
|
||||
<div className={styles.configItem}>
|
||||
<span className={styles.configLabel}>{t('basic_settings.ws_auth_enable')}</span>
|
||||
<span className={`${styles.configValue} ${config.wsAuth ? styles.enabled : styles.disabled}`}>
|
||||
<div className={styles.configPill}>
|
||||
<span className={styles.configPillLabel}>{t('basic_settings.ws_auth_enable')}</span>
|
||||
<span className={`${styles.configPillValue} ${config.wsAuth ? styles.on : styles.off}`}>
|
||||
{config.wsAuth ? t('common.yes') : t('common.no')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.configItem}>
|
||||
<span className={styles.configLabel}>{t('dashboard.routing_strategy')}</span>
|
||||
<div className={styles.configPill}>
|
||||
<span className={styles.configPillLabel}>{t('dashboard.routing_strategy')}</span>
|
||||
<span className={`${styles.configBadge} ${routingStrategyBadgeClass}`}>
|
||||
{routingStrategyDisplay}
|
||||
</span>
|
||||
</div>
|
||||
{config.proxyUrl && (
|
||||
<div className={`${styles.configItem} ${styles.configItemFull}`}>
|
||||
<span className={styles.configLabel}>{t('basic_settings.proxy_url_label')}</span>
|
||||
<span className={styles.configValueMono}>{config.proxyUrl}</span>
|
||||
<div className={`${styles.configPill} ${styles.configPillWide}`}>
|
||||
<span className={styles.configPillLabel}>{t('basic_settings.proxy_url_label')}</span>
|
||||
<span className={styles.configPillMono}>{config.proxyUrl}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link to="/config" className={styles.viewMoreLink}>
|
||||
{t('dashboard.edit_settings')} →
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -31,14 +31,22 @@
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
|
||||
:global(.btn-sm) {
|
||||
:global(.btn.btn-sm) {
|
||||
line-height: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(svg) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.titleWrapper {
|
||||
@@ -146,9 +154,95 @@
|
||||
}
|
||||
|
||||
.viewModeToggle {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
gap: $spacing-xs;
|
||||
align-items: center;
|
||||
padding: 3px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 92%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16);
|
||||
|
||||
@include mobile {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.viewModeButton:global(.btn.btn-sm) {
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
box-shadow: none;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: transparent;
|
||||
background: color-mix(in srgb, var(--bg-hover) 72%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
> span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
}
|
||||
|
||||
.viewModeButtonActive:global(.btn.btn-sm) {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-contrast, #fff);
|
||||
box-shadow: 0 8px 18px -14px rgba(0, 0, 0, 0.45);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
color: var(--primary-contrast, #fff);
|
||||
}
|
||||
}
|
||||
|
||||
.refreshAllButton:global(.btn.btn-sm) {
|
||||
padding-inline: 14px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-secondary));
|
||||
border-color: color-mix(in srgb, var(--primary-color) 22%, var(--border-color));
|
||||
color: var(--text-primary);
|
||||
|
||||
> span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--primary-color) 16%, var(--bg-secondary));
|
||||
border-color: color-mix(in srgb, var(--primary-color) 34%, var(--border-color));
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
:global([data-theme='dark']) .viewModeToggle {
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
:global([data-theme='dark']) .refreshAllButton:global(.btn.btn-sm) {
|
||||
background: color-mix(in srgb, var(--primary-color) 18%, var(--bg-secondary));
|
||||
border-color: color-mix(in srgb, var(--primary-color) 32%, var(--border-color));
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.headerActions {
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
}
|
||||
|
||||
// 卡片渐变背景 — 基于 TYPE_COLORS light.bg 转 rgba
|
||||
@@ -243,7 +337,7 @@
|
||||
}
|
||||
|
||||
.quotaBarFillMedium {
|
||||
background-color: var(--warning-color);
|
||||
background-color: var(--quota-medium-color, #e0aa14);
|
||||
}
|
||||
|
||||
.quotaBarFillLow {
|
||||
@@ -283,6 +377,24 @@
|
||||
padding: $spacing-sm 0;
|
||||
}
|
||||
|
||||
.quotaMessageAction {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.quotaError {
|
||||
font-size: 12px;
|
||||
color: var(--danger-color);
|
||||
|
||||
@@ -1112,17 +1112,20 @@
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-secondary, #e5e7eb);
|
||||
background-color: var(--floating-surface, #ffffff);
|
||||
background-image: none;
|
||||
border: 1px solid var(--floating-border, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
box-shadow: var(--floating-shadow, 0 10px 24px rgba(0, 0, 0, 0.16));
|
||||
z-index: $z-dropdown;
|
||||
pointer-events: none;
|
||||
color: var(--text-primary);
|
||||
opacity: 1;
|
||||
background-clip: padding-box;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
@@ -1131,7 +1134,7 @@
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: var(--bg-primary, #fff);
|
||||
border-top-color: var(--floating-surface, #ffffff);
|
||||
}
|
||||
|
||||
&::before {
|
||||
@@ -1141,7 +1144,7 @@
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: var(--border-secondary, #e5e7eb);
|
||||
border-top-color: var(--floating-border, #e5e7eb);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1154,14 +1157,14 @@
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
border-top-color: transparent;
|
||||
border-bottom-color: var(--bg-primary, #fff);
|
||||
border-bottom-color: var(--floating-surface, #ffffff);
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
border-top-color: transparent;
|
||||
border-bottom-color: var(--border-secondary, #e5e7eb);
|
||||
border-bottom-color: var(--floating-border, #e5e7eb);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,31 @@ import type { OAuthModelAliasEntry } from '@/types';
|
||||
type StatusError = { status?: number };
|
||||
type AuthFileStatusResponse = { status: string; disabled: boolean };
|
||||
type AuthFileEntry = AuthFilesResponse['files'][number];
|
||||
type AuthFileBatchFailure = { name: string; error: string };
|
||||
type AuthFileBatchUploadResponse = {
|
||||
status?: string;
|
||||
uploaded?: number;
|
||||
files?: unknown;
|
||||
failed?: unknown;
|
||||
};
|
||||
type AuthFileBatchDeleteResponse = {
|
||||
status?: string;
|
||||
deleted?: number;
|
||||
files?: unknown;
|
||||
failed?: unknown;
|
||||
};
|
||||
type AuthFileBatchUploadResult = {
|
||||
status: string;
|
||||
uploaded: number;
|
||||
files: string[];
|
||||
failed: AuthFileBatchFailure[];
|
||||
};
|
||||
type AuthFileBatchDeleteResult = {
|
||||
status: string;
|
||||
deleted: number;
|
||||
files: string[];
|
||||
failed: AuthFileBatchFailure[];
|
||||
};
|
||||
|
||||
export const AUTH_FILE_INVALID_JSON_OBJECT_ERROR = 'AUTH_FILE_INVALID_JSON_OBJECT';
|
||||
|
||||
@@ -18,6 +43,129 @@ const getStatusCode = (err: unknown): number | undefined => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizeRequestedAuthFileNames = (names: string[]): string[] => {
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
|
||||
names.forEach((name) => {
|
||||
const trimmed = String(name ?? '').trim();
|
||||
if (!trimmed || seen.has(trimmed)) return;
|
||||
seen.add(trimmed);
|
||||
normalized.push(trimmed);
|
||||
});
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const normalizeBatchFileNames = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return normalizeRequestedAuthFileNames(value.map((item) => String(item ?? '')));
|
||||
};
|
||||
|
||||
const normalizeBatchFailures = (value: unknown): AuthFileBatchFailure[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
return value.reduce<AuthFileBatchFailure[]>((result, item) => {
|
||||
if (!item || typeof item !== 'object') return result;
|
||||
const entry = item as Record<string, unknown>;
|
||||
const name = String(entry.name ?? '').trim();
|
||||
const error =
|
||||
typeof entry.error === 'string'
|
||||
? entry.error.trim()
|
||||
: typeof entry.message === 'string'
|
||||
? entry.message.trim()
|
||||
: '';
|
||||
|
||||
if (!name && !error) return result;
|
||||
result.push({ name, error: error || 'Unknown error' });
|
||||
return result;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const deriveSuccessfulFileNames = (requestedNames: string[], failed: AuthFileBatchFailure[]): string[] => {
|
||||
const failedNames = new Set(
|
||||
failed
|
||||
.map((entry) => entry.name.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
if (failedNames.size === 0) {
|
||||
return [...requestedNames];
|
||||
}
|
||||
|
||||
return requestedNames.filter((name) => !failedNames.has(name));
|
||||
};
|
||||
|
||||
const normalizeBatchUploadResponse = (
|
||||
payload: AuthFileBatchUploadResponse | undefined,
|
||||
requestedNames: string[]
|
||||
): AuthFileBatchUploadResult => {
|
||||
const failed = normalizeBatchFailures(payload?.failed);
|
||||
const uploadedFilesFromPayload = normalizeBatchFileNames(payload?.files);
|
||||
const uploaded =
|
||||
typeof payload?.uploaded === 'number'
|
||||
? payload.uploaded
|
||||
: uploadedFilesFromPayload.length > 0
|
||||
? uploadedFilesFromPayload.length
|
||||
: requestedNames.length === 1 && failed.length === 0
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
let uploadedFiles = uploadedFilesFromPayload;
|
||||
if (uploadedFiles.length === 0 && uploaded > 0) {
|
||||
if (failed.length === 0 && uploaded === requestedNames.length) {
|
||||
uploadedFiles = [...requestedNames];
|
||||
} else {
|
||||
const derivedNames = deriveSuccessfulFileNames(requestedNames, failed);
|
||||
if (derivedNames.length === uploaded) {
|
||||
uploadedFiles = derivedNames;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: typeof payload?.status === 'string' ? payload.status : failed.length > 0 ? 'partial' : 'ok',
|
||||
uploaded,
|
||||
files: uploadedFiles,
|
||||
failed,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeBatchDeleteResponse = (
|
||||
payload: AuthFileBatchDeleteResponse | undefined,
|
||||
requestedNames: string[]
|
||||
): AuthFileBatchDeleteResult => {
|
||||
const failed = normalizeBatchFailures(payload?.failed);
|
||||
const deletedFilesFromPayload = normalizeBatchFileNames(payload?.files);
|
||||
const deleted =
|
||||
typeof payload?.deleted === 'number'
|
||||
? payload.deleted
|
||||
: deletedFilesFromPayload.length > 0
|
||||
? deletedFilesFromPayload.length
|
||||
: requestedNames.length === 1 && failed.length === 0
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
let deletedFiles = deletedFilesFromPayload;
|
||||
if (deletedFiles.length === 0 && deleted > 0) {
|
||||
if (failed.length === 0 && deleted === requestedNames.length) {
|
||||
deletedFiles = [...requestedNames];
|
||||
} else {
|
||||
const derivedNames = deriveSuccessfulFileNames(requestedNames, failed);
|
||||
if (derivedNames.length === deleted) {
|
||||
deletedFiles = derivedNames;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: typeof payload?.status === 'string' ? payload.status : failed.length > 0 ? 'partial' : 'ok',
|
||||
deleted,
|
||||
files: deletedFiles,
|
||||
failed,
|
||||
};
|
||||
};
|
||||
|
||||
const readTextField = (entry: AuthFileEntry, key: string): string => {
|
||||
const value = entry[key];
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
@@ -252,13 +400,35 @@ export const authFilesApi = {
|
||||
setStatus: (name: string, disabled: boolean) =>
|
||||
apiClient.patch<AuthFileStatusResponse>('/auth-files/status', { name, disabled }),
|
||||
|
||||
upload: (file: File) => {
|
||||
uploadFiles: async (files: File[]): Promise<AuthFileBatchUploadResult> => {
|
||||
const requestedNames = files.map((file) => file.name);
|
||||
if (requestedNames.length === 0) {
|
||||
return { status: 'ok', uploaded: 0, files: [], failed: [] };
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name);
|
||||
return apiClient.postForm('/auth-files', formData);
|
||||
files.forEach((file) => {
|
||||
formData.append('file', file, file.name);
|
||||
});
|
||||
const payload = await apiClient.postForm<AuthFileBatchUploadResponse>('/auth-files', formData);
|
||||
return normalizeBatchUploadResponse(payload, requestedNames);
|
||||
},
|
||||
|
||||
deleteFile: (name: string) => apiClient.delete(`/auth-files?name=${encodeURIComponent(name)}`),
|
||||
upload: (file: File) => authFilesApi.uploadFiles([file]),
|
||||
|
||||
deleteFiles: async (names: string[]): Promise<AuthFileBatchDeleteResult> => {
|
||||
const requestedNames = normalizeRequestedAuthFileNames(names);
|
||||
if (requestedNames.length === 0) {
|
||||
return { status: 'ok', deleted: 0, files: [], failed: [] };
|
||||
}
|
||||
|
||||
const payload = await apiClient.delete<AuthFileBatchDeleteResponse>('/auth-files', {
|
||||
data: { names: requestedNames },
|
||||
});
|
||||
return normalizeBatchDeleteResponse(payload, requestedNames);
|
||||
},
|
||||
|
||||
deleteFile: (name: string) => authFilesApi.deleteFiles([name]),
|
||||
|
||||
deleteAll: () => apiClient.delete('/auth-files', { params: { all: true } }),
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ export const modelsApi = {
|
||||
}
|
||||
|
||||
const resolvedHeaders = { ...headers };
|
||||
if (apiKey) {
|
||||
if (apiKey && !hasHeader(resolvedHeaders, 'authorization')) {
|
||||
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
@@ -116,8 +116,7 @@ export const modelsApi = {
|
||||
}
|
||||
|
||||
const resolvedHeaders = { ...headers };
|
||||
const hasAuthHeader = Boolean(resolvedHeaders.Authorization || resolvedHeaders.authorization);
|
||||
if (apiKey && !hasAuthHeader) {
|
||||
if (apiKey && !hasHeader(resolvedHeaders, 'authorization')) {
|
||||
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
@@ -149,8 +148,7 @@ export const modelsApi = {
|
||||
}
|
||||
|
||||
const resolvedHeaders = { ...headers };
|
||||
const hasAuthHeader = Boolean(resolvedHeaders.Authorization || resolvedHeaders.authorization);
|
||||
if (apiKey && !hasAuthHeader) {
|
||||
if (apiKey && !hasHeader(resolvedHeaders, 'authorization')) {
|
||||
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
/**
|
||||
* 安全存储服务
|
||||
* 本地存储混淆服务(可逆)
|
||||
* 基于原项目 src/utils/secure-storage.js
|
||||
*
|
||||
* IMPORTANT: 这不是安全边界,仅用于避免“肉眼直读”的轻度混淆。
|
||||
*/
|
||||
|
||||
import { encryptData, decryptData } from '@/utils/encryption';
|
||||
import { obfuscateData, deobfuscateData, isObfuscated } from '@/utils/encryption';
|
||||
|
||||
interface StorageOptions {
|
||||
/**
|
||||
* Whether to obfuscate the stored value. This was historically called `encrypt`,
|
||||
* but the implementation is reversible obfuscation, not cryptographic security.
|
||||
*/
|
||||
obfuscate?: boolean;
|
||||
encrypt?: boolean;
|
||||
}
|
||||
|
||||
class SecureStorageService {
|
||||
class ObfuscatedStorageService {
|
||||
/**
|
||||
* 存储数据
|
||||
*/
|
||||
setItem(key: string, value: unknown, options: StorageOptions = {}): void {
|
||||
const { encrypt = true } = options;
|
||||
const obfuscate = options.obfuscate ?? options.encrypt ?? true;
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
this.removeItem(key);
|
||||
@@ -22,7 +29,7 @@ class SecureStorageService {
|
||||
}
|
||||
|
||||
const stringValue = JSON.stringify(value);
|
||||
const storedValue = encrypt ? encryptData(stringValue) : stringValue;
|
||||
const storedValue = obfuscate ? obfuscateData(stringValue) : stringValue;
|
||||
|
||||
localStorage.setItem(key, storedValue);
|
||||
}
|
||||
@@ -31,20 +38,20 @@ class SecureStorageService {
|
||||
* 获取数据
|
||||
*/
|
||||
getItem<T = unknown>(key: string, options: StorageOptions = {}): T | null {
|
||||
const { encrypt = true } = options;
|
||||
const obfuscate = options.obfuscate ?? options.encrypt ?? true;
|
||||
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) return null;
|
||||
|
||||
try {
|
||||
const decrypted = encrypt ? decryptData(raw) : raw;
|
||||
const decrypted = obfuscate ? deobfuscateData(raw) : raw;
|
||||
return JSON.parse(decrypted) as T;
|
||||
} catch {
|
||||
// JSON解析失败,尝试兼容旧的纯字符串数据 (非JSON格式)
|
||||
try {
|
||||
// 如果是加密的,尝试解密后直接返回
|
||||
if (encrypt && raw.startsWith('enc::v1::')) {
|
||||
const decrypted = decryptData(raw);
|
||||
if (obfuscate && isObfuscated(raw)) {
|
||||
const decrypted = deobfuscateData(raw);
|
||||
// 解密后如果还不是JSON,返回原始字符串
|
||||
return decrypted as T;
|
||||
}
|
||||
@@ -108,4 +115,6 @@ class SecureStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
export const secureStorage = new SecureStorageService();
|
||||
export const obfuscatedStorage = new ObfuscatedStorageService();
|
||||
// Backward-compatible alias (historically named "secureStorage").
|
||||
export const secureStorage = obfuscatedStorage;
|
||||
|
||||
@@ -7,10 +7,11 @@ import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import type { AuthState, LoginCredentials, ConnectionStatus } from '@/types';
|
||||
import { STORAGE_KEY_AUTH } from '@/utils/constants';
|
||||
import { secureStorage } from '@/services/storage/secureStorage';
|
||||
import { obfuscatedStorage } from '@/services/storage/secureStorage';
|
||||
import { apiClient } from '@/services/api/client';
|
||||
import { useConfigStore } from './useConfigStore';
|
||||
import { useUsageStatsStore } from './useUsageStatsStore';
|
||||
import { useModelsStore } from './useModelsStore';
|
||||
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
|
||||
|
||||
interface AuthStoreState extends AuthState {
|
||||
@@ -46,13 +47,13 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
if (restoreSessionPromise) return restoreSessionPromise;
|
||||
|
||||
restoreSessionPromise = (async () => {
|
||||
secureStorage.migratePlaintextKeys(['apiBase', 'apiUrl', 'managementKey']);
|
||||
obfuscatedStorage.migratePlaintextKeys(['apiBase', 'apiUrl', 'managementKey']);
|
||||
|
||||
const wasLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
|
||||
const legacyBase =
|
||||
secureStorage.getItem<string>('apiBase') ||
|
||||
secureStorage.getItem<string>('apiUrl', { encrypt: true });
|
||||
const legacyKey = secureStorage.getItem<string>('managementKey');
|
||||
obfuscatedStorage.getItem<string>('apiBase') ||
|
||||
obfuscatedStorage.getItem<string>('apiUrl', { encrypt: true });
|
||||
const legacyKey = obfuscatedStorage.getItem<string>('managementKey');
|
||||
|
||||
const { apiBase, managementKey, rememberPassword } = get();
|
||||
const resolvedBase = normalizeApiBase(apiBase || legacyBase || detectApiBaseFromLocation());
|
||||
@@ -94,6 +95,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
|
||||
try {
|
||||
set({ connectionStatus: 'connecting' });
|
||||
useModelsStore.getState().clearCache();
|
||||
|
||||
// 配置 API 客户端
|
||||
apiClient.setConfig({
|
||||
@@ -138,6 +140,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
restoreSessionPromise = null;
|
||||
useConfigStore.getState().clearCache();
|
||||
useUsageStatsStore.getState().clearUsageStats();
|
||||
useModelsStore.getState().clearCache();
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
apiBase: '',
|
||||
@@ -197,14 +200,14 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
name: STORAGE_KEY_AUTH,
|
||||
storage: createJSONStorage(() => ({
|
||||
getItem: (name) => {
|
||||
const data = secureStorage.getItem<AuthStoreState>(name);
|
||||
const data = obfuscatedStorage.getItem<AuthStoreState>(name);
|
||||
return data ? JSON.stringify(data) : null;
|
||||
},
|
||||
setItem: (name, value) => {
|
||||
secureStorage.setItem(name, JSON.parse(value));
|
||||
obfuscatedStorage.setItem(name, JSON.parse(value));
|
||||
},
|
||||
removeItem: (name) => {
|
||||
secureStorage.removeItem(name);
|
||||
obfuscatedStorage.removeItem(name);
|
||||
}
|
||||
})),
|
||||
partialize: (state) => ({
|
||||
|
||||
@@ -267,7 +267,12 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
|
||||
// 同时清除完整配置缓存
|
||||
newCache.delete('__full__');
|
||||
|
||||
set({ cache: newCache });
|
||||
// Section-level invalidation usually follows an optimistic write path. Invalidate any in-flight
|
||||
// full fetch so stale responses can't overwrite newer local changes.
|
||||
configRequestToken += 1;
|
||||
inFlightConfigRequest = null;
|
||||
|
||||
set({ cache: newCache, loading: false, error: null });
|
||||
return;
|
||||
} else {
|
||||
newCache.clear();
|
||||
|
||||
@@ -11,6 +11,7 @@ interface ModelsCache {
|
||||
data: ModelInfo[];
|
||||
timestamp: number;
|
||||
apiBase: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
interface ModelsState {
|
||||
@@ -21,7 +22,7 @@ interface ModelsState {
|
||||
|
||||
fetchModels: (apiBase: string, apiKey?: string, forceRefresh?: boolean) => Promise<ModelInfo[]>;
|
||||
clearCache: () => void;
|
||||
isCacheValid: (apiBase: string) => boolean;
|
||||
isCacheValid: (apiBase: string, apiKey?: string) => boolean;
|
||||
}
|
||||
|
||||
export const useModelsStore = create<ModelsState>((set, get) => ({
|
||||
@@ -32,9 +33,10 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
|
||||
|
||||
fetchModels: async (apiBase, apiKey, forceRefresh = false) => {
|
||||
const { cache, isCacheValid } = get();
|
||||
const apiKeyScope = apiKey?.trim() || '';
|
||||
|
||||
// 检查缓存
|
||||
if (!forceRefresh && isCacheValid(apiBase) && cache) {
|
||||
if (!forceRefresh && isCacheValid(apiBase, apiKeyScope) && cache) {
|
||||
set({ models: cache.data, error: null });
|
||||
return cache.data;
|
||||
}
|
||||
@@ -42,13 +44,13 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const list = await modelsApi.fetchModels(apiBase, apiKey);
|
||||
const list = await modelsApi.fetchModels(apiBase, apiKeyScope || undefined);
|
||||
const now = Date.now();
|
||||
|
||||
set({
|
||||
models: list,
|
||||
loading: false,
|
||||
cache: { data: list, timestamp: now, apiBase }
|
||||
cache: { data: list, timestamp: now, apiBase, apiKey: apiKeyScope }
|
||||
});
|
||||
|
||||
return list;
|
||||
@@ -68,10 +70,12 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
|
||||
set({ cache: null, models: [] });
|
||||
},
|
||||
|
||||
isCacheValid: (apiBase) => {
|
||||
isCacheValid: (apiBase, apiKey) => {
|
||||
const { cache } = get();
|
||||
if (!cache) return false;
|
||||
if (cache.apiBase !== apiBase) return false;
|
||||
const apiKeyScope = apiKey?.trim() || '';
|
||||
if ((cache.apiKey || '') !== apiKeyScope) return false;
|
||||
return Date.now() - cache.timestamp < CACHE_EXPIRY_MS;
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -411,6 +411,7 @@ textarea {
|
||||
border-radius: $radius-lg;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: $shadow-lg;
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
@@ -523,6 +524,49 @@ textarea {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.modal-overlay {
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.modal {
|
||||
max-height: calc(100vh - #{$spacing-md * 2});
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
.modal {
|
||||
max-height: calc(100dvh - #{$spacing-md * 2});
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: $spacing-md;
|
||||
padding-right: 52px;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: $spacing-md;
|
||||
max-height: min(60vh, calc(100vh - 180px));
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
.modal-body {
|
||||
max-height: min(60dvh, calc(100dvh - 180px));
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: $spacing-md;
|
||||
flex-direction: column-reverse;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.request-log-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
--bg-hover: var(--bg-tertiary);
|
||||
--bg-quinary: #f6f4ee;
|
||||
--bg-error-light: rgba(198, 87, 70, 0.1);
|
||||
--floating-surface: #fffdf9;
|
||||
--floating-border: #d8d3ca;
|
||||
--floating-shadow: 0 12px 26px rgba(0, 0, 0, 0.14);
|
||||
|
||||
--text-primary: #2d2a26;
|
||||
--text-secondary: #6d6760;
|
||||
@@ -29,6 +32,7 @@
|
||||
--primary-contrast: #ffffff;
|
||||
|
||||
--success-color: #10b981;
|
||||
--quota-medium-color: #e0aa14;
|
||||
--warning-color: #c65746; // 错误/警告色
|
||||
--error-color: #c65746;
|
||||
--danger-color: var(--error-color);
|
||||
@@ -64,6 +68,9 @@
|
||||
--bg-hover: var(--bg-tertiary);
|
||||
--bg-quinary: #ffffff;
|
||||
--bg-error-light: rgba(198, 87, 70, 0.08);
|
||||
--floating-surface: #ffffff;
|
||||
--floating-border: #d9d9d9;
|
||||
--floating-shadow: 0 12px 26px rgba(0, 0, 0, 0.12);
|
||||
|
||||
--text-primary: #2d2a26;
|
||||
--text-secondary: #6d6760;
|
||||
@@ -82,6 +89,7 @@
|
||||
--primary-contrast: #ffffff;
|
||||
|
||||
--success-color: #10b981;
|
||||
--quota-medium-color: #e0aa14;
|
||||
--warning-color: #c65746;
|
||||
--error-color: #c65746;
|
||||
--danger-color: var(--error-color);
|
||||
@@ -118,6 +126,9 @@
|
||||
--bg-hover: #2e2a26;
|
||||
--bg-quinary: #191714;
|
||||
--bg-error-light: rgba(198, 87, 70, 0.18);
|
||||
--floating-surface: #2a2723;
|
||||
--floating-border: #4a443d;
|
||||
--floating-shadow: 0 14px 30px rgba(0, 0, 0, 0.4);
|
||||
|
||||
--text-primary: #f6f4f1;
|
||||
--text-secondary: #c9c3bb;
|
||||
@@ -136,6 +147,7 @@
|
||||
--primary-contrast: #ffffff;
|
||||
|
||||
--success-color: #10b981;
|
||||
--quota-medium-color: #ffd862;
|
||||
--warning-color: #c65746;
|
||||
--error-color: #c65746;
|
||||
--danger-color: var(--error-color);
|
||||
|
||||
+14
-7
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* 加密工具函数
|
||||
* 本地存储混淆工具函数(可逆)
|
||||
* 从原项目 src/utils/secure-storage.js 迁移
|
||||
*
|
||||
* IMPORTANT: 这不是安全边界。浏览器端长期持久化的密钥仍应视为可被读取。
|
||||
*/
|
||||
|
||||
const ENC_PREFIX = 'enc::v1::';
|
||||
@@ -26,7 +28,7 @@ function getKeyBytes(): Uint8Array {
|
||||
const ua = navigator.userAgent;
|
||||
cachedKeyBytes = encodeText(`${SECRET_SALT}|${host}|${ua}`);
|
||||
} catch (error) {
|
||||
console.warn('Encryption fallback to simple key:', error);
|
||||
console.warn('Obfuscation fallback to simple key:', error);
|
||||
cachedKeyBytes = encodeText(SECRET_SALT);
|
||||
}
|
||||
|
||||
@@ -61,7 +63,7 @@ function fromBase64(base64: string): Uint8Array {
|
||||
/**
|
||||
* 加密数据
|
||||
*/
|
||||
export function encryptData(value: string): string {
|
||||
export function obfuscateData(value: string): string {
|
||||
if (!value) return value;
|
||||
|
||||
try {
|
||||
@@ -69,7 +71,7 @@ export function encryptData(value: string): string {
|
||||
const encrypted = xorBytes(encodeText(value), keyBytes);
|
||||
return `${ENC_PREFIX}${toBase64(encrypted)}`;
|
||||
} catch (error) {
|
||||
console.warn('Encryption failed, fallback to plaintext:', error);
|
||||
console.warn('Obfuscation failed, fallback to plaintext:', error);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -77,7 +79,7 @@ export function encryptData(value: string): string {
|
||||
/**
|
||||
* 解密数据
|
||||
*/
|
||||
export function decryptData(payload: string): string {
|
||||
export function deobfuscateData(payload: string): string {
|
||||
if (!payload || !payload.startsWith(ENC_PREFIX)) {
|
||||
return payload;
|
||||
}
|
||||
@@ -88,7 +90,7 @@ export function decryptData(payload: string): string {
|
||||
const decrypted = xorBytes(encrypted, getKeyBytes());
|
||||
return decodeText(decrypted);
|
||||
} catch (error) {
|
||||
console.warn('Decryption failed, return as-is:', error);
|
||||
console.warn('Deobfuscation failed, return as-is:', error);
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@@ -96,6 +98,11 @@ export function decryptData(payload: string): string {
|
||||
/**
|
||||
* 检查是否已加密
|
||||
*/
|
||||
export function isEncrypted(value: string): boolean {
|
||||
export function isObfuscated(value: string): boolean {
|
||||
return value?.startsWith(ENC_PREFIX) || false;
|
||||
}
|
||||
|
||||
// Backward-compatible aliases (this module was historically named "encryption").
|
||||
export const encryptData = obfuscateData;
|
||||
export const decryptData = deobfuscateData;
|
||||
export const isEncrypted = isObfuscated;
|
||||
|
||||
@@ -31,6 +31,12 @@ export function buildHeaderObject(input?: HeaderEntry[] | Record<string, string
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function hasHeader(headers: Record<string, unknown> | null | undefined, name: string): boolean {
|
||||
if (!headers) return false;
|
||||
const target = name.toLowerCase();
|
||||
return Object.keys(headers).some((key) => key.toLowerCase() === target);
|
||||
}
|
||||
|
||||
export function headersToEntries(headers?: Record<string, string | undefined | null>): HeaderEntry[] {
|
||||
if (!headers || typeof headers !== 'object') return [];
|
||||
return Object.entries(headers)
|
||||
|
||||
Reference in New Issue
Block a user