Compare commits

...

19 Commits

Author SHA1 Message Date
Supra4E8C
8ca6d31a26 feat(oauth): add vertex json login via vertex/import 2025-12-27 08:02:46 +08:00
Supra4E8C
66c6073bbc feat(usage): add usage stats export/import actions 2025-12-27 00:49:41 +08:00
Supra4E8C
2dd3f233d3 feat(logs): add long-press request log download and hints 2025-12-27 00:30:18 +08:00
Supra4E8C
7a65e03ad3 fix(layout): prevent footer gap on non-scroll pages 2025-12-26 23:53:13 +08:00
Supra4E8C
589a5bad4c fix(oauth): use resolvedTheme for provider icons 2025-12-26 18:54:16 +08:00
Supra4E8C
bcaa0c8545 Merge branch 'main' of https://github.com/router-for-me/Cli-Proxy-API-Management-Center 2025-12-26 18:42:47 +08:00
Supra4E8C
312a06a8b8 fix(logs): clarify error request logs list behavior 2025-12-26 18:42:41 +08:00
Supra4E8C
24861dabd2 Merge pull request #29 from XYenon/feat/auto-theme-mode
feat: add auto theme mode (follow system preference)
2025-12-26 18:04:40 +08:00
Supra4E8C
ea1bdc3ac1 fix(logs): make error log panel scrollable 2025-12-26 17:52:23 +08:00
Supra4E8C
46701b40ad fix(layout): restore scroll and set panel heights 2025-12-26 17:37:49 +08:00
XYenon
c9fc22bae5 fix: use resolvedTheme instead of theme for dark mode detection
When theme is set to 'auto', checking theme === 'dark' returns false even
when the system preference is dark mode. This caused charts and custom
styles to use light mode colors in a dark UI.

Fixed by using resolvedTheme which correctly resolves to 'light' or 'dark'
based on system preference when in auto mode.

Fixes the issue reported in PR #29 review.
2025-12-26 00:19:04 +08:00
Supra4E8C
ff9bd8a33b fix(ai-providers): allow empty Claude base URL 2025-12-25 23:55:31 +08:00
Supra4E8C
d0c376fc31 fix(layout): restore full-height panels on mobile/HiDPI 2025-12-25 23:33:52 +08:00
Supra4E8C
d09db34c34 Merge pull request #28 from notdp/main
feat(oauth): add provider icons to oauth login cards
2025-12-25 20:57:51 +08:00
dp
9dd37245bd feat(icons): convert png to svg and add icons to ai providers page 2025-12-25 20:48:12 +08:00
Supra4E8C
834ba43231 fix: ConfigPage.module.scss LogPage.moudle.scss 2025-12-25 01:09:00 +08:00
XYenon
961cc802b2 fix: address PR review feedback
- Use unique ID prefix for clipPath to avoid duplicate ID issues
- Add cleanup function to initializeTheme to prevent memory leak
- Change tooltip to show action description instead of current theme name
2025-12-24 00:18:44 +08:00
XYenon
5f7df33469 feat: add auto theme mode (follow system preference)
- Add 'auto' to Theme type
- Implement cycleTheme (light -> dark -> auto)
- Add autoTheme icon (sun with half-filled center)
- Listen to system theme changes in auto mode

Also includes some Prettier formatting fixes.
2025-12-24 00:02:59 +08:00
dp
39847fa56d feat(oauth): add provider icons to oauth login cards 2025-12-23 14:05:35 +08:00
33 changed files with 958 additions and 140 deletions

View File

@@ -31,10 +31,11 @@ function App() {
const [authReady, setAuthReady] = useState(false);
useEffect(() => {
initializeTheme();
const cleanupTheme = initializeTheme();
void restoreSession().finally(() => {
setAuthReady(true);
});
return cleanupTheme;
}, [initializeTheme, restoreSession]);
useEffect(() => {

6
src/assets/icons/amp.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg width="400" height="400" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.9197 13.61L17.3816 26.566L14.242 27.4049L11.2645 16.2643L0.119926 13.2906L0.957817 10.15L13.9197 13.61Z" fill="#F34E3F"/>
<path d="M13.7391 16.0892L4.88169 24.9056L2.58872 22.6019L11.4461 13.7865L13.7391 16.0892Z" fill="#F34E3F"/>
<path d="M18.9386 8.58315L22.4005 21.5392L19.2609 22.3781L16.2833 11.2374L5.13879 8.26381L5.97668 5.12318L18.9386 8.58315Z" fill="#F34E3F"/>
<path d="M23.9803 3.55632L27.4422 16.5124L24.3025 17.3512L21.325 6.21062L10.1805 3.23698L11.0183 0.0963593L23.9803 3.55632Z" fill="#F34E3F"/>
</svg>

After

Width:  |  Height:  |  Size: 632 B

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 0.6.4 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="64" height="59">
<path d="M0,0 L8,0 L14,4 L19,14 L27,40 L32,50 L36,54 L35,59 L30,59 L22,52 L11,35 L6,33 L-1,34 L-6,39 L-14,52 L-22,59 L-28,59 L-27,53 L-22,47 L-17,34 L-10,12 L-5,3 Z " fill="#3789F9" transform="translate(28,0)"/>
<path d="M0,0 L8,0 L14,4 L19,14 L25,35 L21,34 L16,29 L11,26 L7,20 L7,18 L2,16 L-3,15 L-8,18 L-12,19 L-9,9 L-4,2 Z " fill="#6D80D8" transform="translate(28,0)"/>
<path d="M0,0 L8,0 L14,4 L19,14 L20,19 L13,15 L10,12 L3,10 L-1,8 L-7,7 L-4,2 Z " fill="#D78240" transform="translate(28,0)"/>
<path d="M0,0 L5,1 L10,4 L12,9 L1,8 L-5,13 L-10,21 L-13,26 L-16,26 L-9,5 L-4,2 Z M6,7 Z " fill="#3294CC" transform="translate(25,14)"/>
<path d="M0,0 L5,2 L10,10 L12,18 L5,14 L1,10 L0,4 L-3,3 L0,2 Z " fill="#E45C49" transform="translate(36,1)"/>
<path d="M0,0 L9,1 L12,3 L12,5 L7,6 L4,8 L-1,11 L-5,12 L-2,2 Z " fill="#90AE64" transform="translate(21,7)"/>
<path d="M0,0 L5,1 L5,4 L-2,7 L-7,11 L-11,10 L-9,5 L-4,2 Z " fill="#53A89A" transform="translate(25,14)"/>
<path d="M0,0 L5,0 L16,9 L17,13 L12,12 L8,9 L8,7 L4,5 L0,2 Z " fill="#B5677D" transform="translate(33,11)"/>
<path d="M0,0 L6,0 L14,6 L19,11 L23,12 L22,15 L15,12 L10,8 L10,6 L4,5 Z " fill="#778998" transform="translate(27,12)"/>
<path d="M0,0 L4,2 L-11,17 L-12,14 L-5,4 Z " fill="#3390DF" transform="translate(26,21)"/>
<path d="M0,0 L2,1 L-4,5 L-9,9 L-13,13 L-14,10 L-13,7 L-6,4 L-3,1 Z " fill="#3FA1B7" transform="translate(27,18)"/>
<path d="M0,0 L4,0 L9,5 L13,6 L12,9 L5,6 L0,2 Z " fill="#8277BB" transform="translate(37,18)"/>
<path d="M0,0 L5,1 L7,6 L-2,5 Z M1,4 Z " fill="#4989CF" transform="translate(30,17)"/>
<path d="M0,0 L5,1 L2,3 L-3,6 L-7,7 L-6,3 Z " fill="#71B774" transform="translate(23,12)"/>
<path d="M0,0 L7,1 L9,7 L5,6 L0,1 Z " fill="#6687E9" transform="translate(44,28)"/>
<path d="M0,0 L7,0 L5,1 L5,3 L8,4 L4,5 L-2,4 Z " fill="#C7AF38" transform="translate(23,3)"/>
<path d="M0,0 L8,0 L8,3 L4,4 L-4,3 Z " fill="#EF842A" transform="translate(28,0)"/>
<path d="M0,0 L7,4 L7,6 L10,6 L11,10 L4,6 L0,2 Z " fill="#CD5D67" transform="translate(37,9)"/>
<path d="M0,0 L5,2 L9,8 L8,11 L2,3 L0,2 Z " fill="#F35241" transform="translate(36,1)"/>
<path d="M0,0 L8,2 L9,6 L4,5 L0,2 Z " fill="#A667A2" transform="translate(41,18)"/>
<path d="M0,0 L9,1 L8,3 L-2,3 Z " fill="#A4B34C" transform="translate(21,7)"/>
<path d="M0,0 L2,0 L7,5 L8,7 L3,6 L0,2 Z " fill="#617FCF" transform="translate(35,18)"/>
<path d="M0,0 L5,2 L8,7 L4,5 L0,2 Z " fill="#9D7784" transform="translate(33,11)"/>
<path d="M0,0 L6,2 L6,4 L0,3 Z " fill="#BC7F59" transform="translate(31,7)"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="32" height="32" viewBox="0 0 32 32"><defs><filter id="master_svg0_278_51503" filterUnits="objectBoundingBox" color-interpolation-filters="sRGB" x="0" y="0" width="1" height="1"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur in="BackgroundImageFix" stdDeviation="1.3333334922790527"/><feComposite in2="SourceAlpha" operator="in" result="effect1_foregroundBlur"/><feBlend mode="normal" in="SourceGraphic" in2="effect1_foregroundBlur" result="shape"/></filter><linearGradient x1="0.07353696972131729" y1="0.12899449467658997" x2="0.9907095821060244" y2="0.9383787344260006" id="master_svg1_93_40276"><stop offset="0%" stop-color="#5C5CFF" stop-opacity="1"/><stop offset="100%" stop-color="#AE5CFF" stop-opacity="1"/></linearGradient></defs><g><g filter="url(#master_svg0_278_51503)"><rect x="0" y="0" width="32" height="32" rx="16" fill="#F0F2F5" fill-opacity="0"/></g><g><g><path d="M31.843111328125,14.751C31.315411328125,7.18121,25.497411328125,1.04691,17.966011328125,0.119698C10.434711328125,-0.807512,3.302541328125,3.73244,0.954596328125,10.9482C0.345662328125,12.8248,1.732821328125,14.751,3.705641328125,14.751C4.950051328125,14.7517,6.055631328125,13.9569,6.451401328125,12.7772C7.497331328125,9.65101,10.504411328125,3.91401,18.482011328125,3.91401Q29.445911328125,3.91401,31.843111328125,14.751ZM9.127681328125,17.3314L9.127681328125,13.0862Q9.127681328125,13.0022,9.144081328125,12.9198Q9.160481328125,12.8373,9.192641328125,12.7597Q9.224801328125,12.682,9.271501328125,12.6122Q9.318191328125,12.5423,9.377621328125,12.4828Q9.437051328125,12.4234,9.506931328125,12.3767Q9.576811328125,12.33,9.654461328125,12.2979Q9.732111328125,12.2657,9.814541328125,12.2493Q9.896971328125,12.2329,9.981021328125,12.2329L11.049211328125,12.2329Q11.133211328125,12.2329,11.215711328125,12.2493Q11.298111328125,12.2657,11.375811328125,12.2979Q11.453411328125,12.33,11.523311328125,12.3767Q11.593211328125,12.4234,11.652611328125,12.4828Q11.712011328125,12.5423,11.758711328125,12.6122Q11.805411328125,12.682,11.837611328125,12.7597Q11.869711328125,12.8373,11.886111328125,12.9198Q11.902511328125,13.0022,11.902511328125,13.0862L11.902511328125,17.3314Q11.902511328125,17.4154,11.886111328125,17.4978Q11.869711328125,17.5803,11.837611328125,17.6579Q11.805411328125,17.7356,11.758711328125,17.8055Q11.712011328125,17.8753,11.652611328125,17.9348Q11.593211328125,17.9942,11.523311328125,18.0409Q11.453411328125,18.0876,11.375811328125,18.1197Q11.298111328125,18.1519,11.215711328125,18.1683Q11.133211328125,18.1847,11.049211328125,18.1847L9.981021328125,18.1847Q9.896971328125,18.1847,9.814541328125,18.1683Q9.732111328125,18.1519,9.654461328125,18.1197Q9.576811328125,18.0876,9.506931328125,18.0409Q9.437051328125,17.9942,9.377621328125,17.9348Q9.318191328125,17.8753,9.271501328125,17.8055Q9.224801328125,17.7356,9.192641328125,17.6579Q9.160481328125,17.5803,9.144081328125,17.4978Q9.127681328125,17.4154,9.127681328125,17.3314ZM17.273611328125,17.3295C17.272611328125,17.8015,17.654911328125,18.1847,18.126911328125,18.1847L19.408411328125,18.1847C19.879011328125,18.1847,20.260711328125,17.8038,20.261811328125,17.3332L20.266411328125,15.2107L20.266411328125,15.2069L20.261811328125,13.0844C20.260711328125,12.6138,19.879011328125,12.2329,19.408411328125,12.2329L18.126911328125,12.2329C17.654911328125,12.2329,17.272611328125,12.6161,17.273611328125,13.0881L17.278211328125,15.2069L17.278211328125,15.2107L17.273611328125,17.3295ZM13.574711328125,28.0523C21.552211328125,28.0523,24.559311328125,22.3153,25.605811328125,19.1897C26.001411328125,18.0098,27.107111328125,17.215,28.351511328125,17.2158C30.323811328125,17.2158,31.711511328125,19.1416,31.102611328125,21.0181C30.552411328125,22.7189,29.716211328125,24.3134,28.629811328125,25.733L30.137611328125,30.2235L24.775211328125,29.3432C14.645911328125,36.0484,1.048779328125,29.3346,0.214111328125,17.2158Q2.611231328125,28.0523,13.574711328125,28.0523Z" fill-rule="evenodd" fill="url(#master_svg1_93_40276)" fill-opacity="1"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -0,0 +1 @@
<svg fill="#ffffff" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg fill="#000000" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z" fill="url(#lobe-icons-qwen-fill)" fill-rule="nonzero"></path><defs><linearGradient id="lobe-icons-qwen-fill" x1="0%" x2="100%" y1="0%" y2="0%"><stop offset="0%" stop-color="#6336E7" stop-opacity=".84"></stop><stop offset="100%" stop-color="#6F69F7" stop-opacity=".84"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24px" height="24px"><path d="M20,13.89A.77.77,0,0,0,19,13.73l-7,5.14v.22a.72.72,0,1,1,0,1.43v0a.74.74,0,0,0,.45-.15l7.41-5.47A.76.76,0,0,0,20,13.89Z" style="fill:#669df6"/><path d="M12,20.52a.72.72,0,0,1,0-1.43h0v-.22L5,13.73a.76.76,0,0,0-1,.16.74.74,0,0,0,.16,1l7.41,5.47a.73.73,0,0,0,.44.15v0Z" style="fill:#aecbfa"/><path d="M12,18.34a1.47,1.47,0,1,0,1.47,1.47A1.47,1.47,0,0,0,12,18.34Zm0,2.18a.72.72,0,1,1,.72-.71A.71.71,0,0,1,12,20.52Z" style="fill:#4285f4"/><path d="M6,6.11a.76.76,0,0,1-.75-.75V3.48a.76.76,0,1,1,1.51,0V5.36A.76.76,0,0,1,6,6.11Z" style="fill:#aecbfa"/><circle cx="5.98" cy="12" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="9.79" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="7.57" r="0.76" style="fill:#aecbfa"/><path d="M18,8.31a.76.76,0,0,1-.75-.76V5.67a.75.75,0,1,1,1.5,0V7.55A.75.75,0,0,1,18,8.31Z" style="fill:#4285f4"/><circle cx="18.02" cy="12.01" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="9.76" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="3.48" r="0.76" style="fill:#4285f4"/><path d="M12,15a.76.76,0,0,1-.75-.75V12.34a.76.76,0,0,1,1.51,0v1.89A.76.76,0,0,1,12,15Z" style="fill:#669df6"/><circle cx="12" cy="16.45" r="0.76" style="fill:#669df6"/><circle cx="12" cy="10.14" r="0.76" style="fill:#669df6"/><circle cx="12" cy="7.92" r="0.76" style="fill:#669df6"/><path d="M15,10.54a.76.76,0,0,1-.75-.75V7.91a.76.76,0,1,1,1.51,0V9.79A.76.76,0,0,1,15,10.54Z" style="fill:#4285f4"/><circle cx="15.01" cy="5.69" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="14.19" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="11.97" r="0.76" style="fill:#4285f4"/><circle cx="8.99" cy="14.19" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="7.92" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="5.69" r="0.76" style="fill:#aecbfa"/><path d="M9,12.73A.76.76,0,0,1,8.24,12V10.1a.75.75,0,1,1,1.5,0V12A.75.75,0,0,1,9,12.73Z" style="fill:#aecbfa"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1,4 +1,12 @@
import { ReactNode, SVGProps, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import {
ReactNode,
SVGProps,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
@@ -14,10 +22,16 @@ import {
IconScrollText,
IconSettings,
IconShield,
IconSlidersHorizontal
IconSlidersHorizontal,
} from '@/components/ui/icons';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import { useAuthStore, useConfigStore, useLanguageStore, useNotificationStore, useThemeStore } from '@/stores';
import {
useAuthStore,
useConfigStore,
useLanguageStore,
useNotificationStore,
useThemeStore,
} from '@/stores';
import { configApi, versionApi } from '@/services/api';
const sidebarIcons: Record<string, ReactNode> = {
@@ -30,7 +44,7 @@ const sidebarIcons: Record<string, ReactNode> = {
usage: <IconChartLine size={18} />,
config: <IconSettings size={18} />,
logs: <IconScrollText size={18} />,
system: <IconInfo size={18} />
system: <IconInfo size={18} />,
};
// Header action icons - smaller size for header buttons
@@ -44,7 +58,7 @@ const headerIconProps: SVGProps<SVGSVGElement> = {
strokeLinecap: 'round',
strokeLinejoin: 'round',
'aria-hidden': 'true',
focusable: 'false'
focusable: 'false',
};
const headerIcons = {
@@ -97,19 +111,38 @@ const headerIcons = {
<path d="m19.07 4.93-1.41 1.41" />
</svg>
),
moon: (
<svg {...headerIconProps}>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />
</svg>
),
logout: (
<svg {...headerIconProps}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
</svg>
)
};
moon: (
<svg {...headerIconProps}>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />
</svg>
),
autoTheme: (
<svg {...headerIconProps}>
<defs>
<clipPath id="mainLayoutAutoThemeSunLeftHalf">
<rect x="0" y="0" width="12" height="24" />
</clipPath>
</defs>
<circle cx="12" cy="12" r="4" />
<circle cx="12" cy="12" r="4" clipPath="url(#mainLayoutAutoThemeSunLeftHalf)" fill="currentColor" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="M4.93 4.93l1.41 1.41" />
<path d="M17.66 17.66l1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="M6.34 17.66l-1.41 1.41" />
<path d="M19.07 4.93l-1.41 1.41" />
</svg>
),
logout: (
<svg {...headerIconProps}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
</svg>
),
};
const parseVersionSegments = (version?: string | null) => {
if (!version) return null;
@@ -153,7 +186,7 @@ export function MainLayout() {
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const theme = useThemeStore((state) => state.theme);
const toggleTheme = useThemeStore((state) => state.toggleTheme);
const cycleTheme = useThemeStore((state) => state.cycleTheme);
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -187,7 +220,9 @@ export function MainLayout() {
updateHeaderHeight();
const resizeObserver =
typeof ResizeObserver !== 'undefined' && headerRef.current ? new ResizeObserver(updateHeaderHeight) : null;
typeof ResizeObserver !== 'undefined' && headerRef.current
? new ResizeObserver(updateHeaderHeight)
: null;
if (resizeObserver && headerRef.current) {
resizeObserver.observe(headerRef.current);
}
@@ -320,8 +355,10 @@ export function MainLayout() {
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
...(config?.loggingToFile ? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }] : []),
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system }
...(config?.loggingToFile
? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
: []),
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system },
];
const handleRefreshAll = async () => {
@@ -370,7 +407,11 @@ export function MainLayout() {
<button
className="sidebar-toggle-header"
onClick={() => setSidebarCollapsed((prev) => !prev)}
title={sidebarCollapsed ? t('sidebar.expand', { defaultValue: '展开' }) : t('sidebar.collapse', { defaultValue: '收起' })}
title={
sidebarCollapsed
? t('sidebar.expand', { defaultValue: '展开' })
: t('sidebar.collapse', { defaultValue: '收起' })
}
>
{sidebarCollapsed ? headerIcons.chevronRight : headerIcons.chevronLeft}
</button>
@@ -400,20 +441,40 @@ export function MainLayout() {
</div>
<div className="header-actions">
<Button className="mobile-menu-btn" variant="ghost" size="sm" onClick={() => setSidebarOpen((prev) => !prev)}>
<Button
className="mobile-menu-btn"
variant="ghost"
size="sm"
onClick={() => setSidebarOpen((prev) => !prev)}
>
{headerIcons.menu}
</Button>
<Button variant="ghost" size="sm" onClick={handleRefreshAll} title={t('header.refresh_all')}>
<Button
variant="ghost"
size="sm"
onClick={handleRefreshAll}
title={t('header.refresh_all')}
>
{headerIcons.refresh}
</Button>
<Button variant="ghost" size="sm" onClick={handleVersionCheck} loading={checkingVersion} title={t('system_info.version_check_button')}>
<Button
variant="ghost"
size="sm"
onClick={handleVersionCheck}
loading={checkingVersion}
title={t('system_info.version_check_button')}
>
{headerIcons.update}
</Button>
<Button variant="ghost" size="sm" onClick={toggleLanguage} title={t('language.switch')}>
{headerIcons.language}
</Button>
<Button variant="ghost" size="sm" onClick={toggleTheme} title={t('theme.switch')}>
{theme === 'dark' ? headerIcons.sun : headerIcons.moon}
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
{theme === 'auto'
? headerIcons.autoTheme
: theme === 'dark'
? headerIcons.moon
: headerIcons.sun}
</Button>
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
{headerIcons.logout}
@@ -423,7 +484,9 @@ export function MainLayout() {
</header>
<div className="main-body">
<aside className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}>
<aside
className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}
>
<div className="nav-section">
{navItems.map((item) => (
<NavLink
@@ -454,7 +517,9 @@ export function MainLayout() {
</span>
<span>
{t('footer.build_date')}:{' '}
{serverBuildDate ? new Date(serverBuildDate).toLocaleString(i18n.language) : t('system_info.version_unknown')}
{serverBuildDate
? new Date(serverBuildDate).toLocaleString(i18n.language)
: t('system_info.version_unknown')}
</span>
</footer>
</div>

View File

@@ -358,7 +358,7 @@
"models_excluded_hint": "This model is excluded by OAuth"
},
"vertex_import": {
"title": "Vertex AI Credential Import",
"title": "Vertex JSON Login",
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.",
"location_label": "Region (optional)",
"location_placeholder": "us-central1",
@@ -534,6 +534,11 @@
"by_hour": "By Hour",
"by_day": "By Day",
"refresh": "Refresh",
"export": "Export",
"import": "Import",
"export_success": "Usage export downloaded",
"import_success": "Import complete: added {{added}}, skipped {{skipped}}, total {{total}}, failed {{failed}}",
"import_invalid": "Invalid usage export file",
"chart_line_label_1": "Line 1",
"chart_line_label_2": "Line 2",
"chart_line_label_3": "Line 3",
@@ -589,12 +594,18 @@
"error_log_button": "Select Error Log",
"error_logs_modal_title": "Error Request Logs",
"error_logs_description": "Pick an error request log file to download (only generated when request logging is off).",
"error_logs_request_log_enabled": "Request logging is enabled, so this list will always be empty. Disable request logging and refresh to view error logs.",
"error_logs_empty": "No error request log files found",
"error_logs_load_error": "Failed to load error log list",
"error_logs_size": "Size",
"error_logs_modified": "Last modified",
"error_logs_download": "Download",
"error_log_download_success": "Error log downloaded successfully",
"request_log_download_title": "Download Request Log",
"request_log_download_confirm": "Download request log for ID {{id}}?",
"request_log_download_success": "Request log downloaded successfully",
"action_hint": "Double-click a log line to copy the raw text. Long-press a line with a request ID to download the request log.",
"action_hint_disabled": "Double-click a log line to copy the raw text. Enable request logging to long-press a line with a request ID and download the request log.",
"empty_title": "No Logs Available",
"empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here",
"log_content": "Log Content",

View File

@@ -358,7 +358,7 @@
"models_excluded_hint": "此模型已被 OAuth 排除"
},
"vertex_import": {
"title": "Vertex AI 凭证导入",
"title": "Vertex JSON 登录",
"description": "上传 Google 服务账号 JSON使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
"location_label": "目标区域 (可选)",
"location_placeholder": "us-central1",
@@ -534,6 +534,11 @@
"by_hour": "按小时",
"by_day": "按天",
"refresh": "刷新",
"export": "导出数据",
"import": "导入数据",
"export_success": "使用统计已导出",
"import_success": "导入完成:新增 {{added}},跳过 {{skipped}},总请求 {{total}},失败 {{failed}}",
"import_invalid": "导入文件格式不正确",
"chart_line_label_1": "曲线 1",
"chart_line_label_2": "曲线 2",
"chart_line_label_3": "曲线 3",
@@ -589,12 +594,18 @@
"error_log_button": "选择错误日志",
"error_logs_modal_title": "错误请求日志",
"error_logs_description": "请选择要下载的错误请求日志文件(仅在关闭请求日志时生成)。",
"error_logs_request_log_enabled": "当前已开启请求日志,按接口约定错误请求日志列表会始终为空。关闭请求日志后再刷新即可查看。",
"error_logs_empty": "暂无错误请求日志文件",
"error_logs_load_error": "加载错误日志列表失败",
"error_logs_size": "大小",
"error_logs_modified": "最后修改",
"error_logs_download": "下载",
"error_log_download_success": "错误日志下载成功",
"request_log_download_title": "下载报文",
"request_log_download_confirm": "是否要下载id为{{id}}的报文?",
"request_log_download_success": "报文下载成功",
"action_hint": "双击日志行可复制原文,长按带有请求 ID 的日志可下载报文。",
"action_hint_disabled": "双击日志行可复制原文,启用请求日志后可长按带请求 ID 的日志下载报文。",
"empty_title": "暂无日志记录",
"empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里",
"log_content": "日志内容",

View File

@@ -5,6 +5,17 @@
width: 100%;
}
.cardTitle {
display: flex;
align-items: center;
gap: $spacing-sm;
}
.cardTitleIcon {
width: 24px;
height: 24px;
}
.pageTitle {
font-size: 28px;
font-weight: 700;

View File

@@ -9,8 +9,13 @@ import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { ModelInputList, modelsToEntries, entriesToModels } from '@/components/ui/ModelInputList';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconCheck, IconX } from '@/components/ui/icons';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
import { ampcodeApi, modelsApi, providersApi, usageApi } from '@/services/api';
import iconGemini from '@/assets/icons/gemini.svg';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
import iconClaude from '@/assets/icons/claude.svg';
import iconAmp from '@/assets/icons/amp.svg';
import type {
GeminiKeyConfig,
ProviderKeyConfig,
@@ -181,6 +186,7 @@ const buildAmpcodeFormState = (ampcode?: AmpcodeConfig | null): AmpcodeFormState
export function AiProvidersPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const { theme } = useThemeStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const config = useConfigStore((state) => state.config);
@@ -897,9 +903,10 @@ export function AiProvidersPage() {
};
const saveProvider = async (type: 'codex' | 'claude') => {
const baseUrl = (providerForm.baseUrl ?? '').trim();
if (!baseUrl) {
showNotification(t('codex_base_url_required'), 'error');
const trimmedBaseUrl = (providerForm.baseUrl ?? '').trim();
const baseUrl = trimmedBaseUrl || undefined;
if (type === 'codex' && !baseUrl) {
showNotification(t('notification.codex_base_url_required'), 'error');
return;
}
@@ -1158,7 +1165,12 @@ export function AiProvidersPage() {
{error && <div className="error-box">{error}</div>}
<Card
title={t('ai_providers.gemini_title')}
title={
<span className={styles.cardTitle}>
<img src={iconGemini} alt="" className={styles.cardTitleIcon} />
{t('ai_providers.gemini_title')}
</span>
}
extra={
<Button
size="sm"
@@ -1264,7 +1276,12 @@ export function AiProvidersPage() {
</Card>
<Card
title={t('ai_providers.codex_title')}
title={
<span className={styles.cardTitle}>
<img src={theme === 'dark' ? iconOpenaiDark : iconOpenaiLight} alt="" className={styles.cardTitleIcon} />
{t('ai_providers.codex_title')}
</span>
}
extra={
<Button
size="sm"
@@ -1375,7 +1392,12 @@ export function AiProvidersPage() {
</Card>
<Card
title={t('ai_providers.claude_title')}
title={
<span className={styles.cardTitle}>
<img src={iconClaude} alt="" className={styles.cardTitleIcon} />
{t('ai_providers.claude_title')}
</span>
}
extra={
<Button
size="sm"
@@ -1502,7 +1524,12 @@ export function AiProvidersPage() {
</Card>
<Card
title={t('ai_providers.ampcode_title')}
title={
<span className={styles.cardTitle}>
<img src={iconAmp} alt="" className={styles.cardTitleIcon} />
{t('ai_providers.ampcode_title')}
</span>
}
extra={
<Button
size="sm"
@@ -1575,7 +1602,12 @@ export function AiProvidersPage() {
</Card>
<Card
title={t('ai_providers.openai_title')}
title={
<span className={styles.cardTitle}>
<img src={theme === 'dark' ? iconOpenaiDark : iconOpenaiLight} alt="" className={styles.cardTitleIcon} />
{t('ai_providers.openai_title')}
</span>
}
extra={
<Button
size="sm"

View File

@@ -17,6 +17,7 @@ import styles from './AuthFilesPage.module.scss';
type ThemeColors = { bg: string; text: string; border?: string };
type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
type ResolvedTheme = 'light' | 'dark';
// 标签类型颜色配置(对齐重构前 styles.css 的 file-type-badge 颜色)
const TYPE_COLORS: Record<string, TypeColorSet> = {
@@ -129,7 +130,7 @@ export function AuthFilesPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const theme = useThemeStore((state) => state.theme);
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [files, setFiles] = useState<AuthFileItem[]>([]);
const [loading, setLoading] = useState(true);
@@ -488,7 +489,7 @@ export function AuthFilesPage() {
// 获取类型颜色
const getTypeColor = (type: string): ThemeColors => {
const set = TYPE_COLORS[type] || TYPE_COLORS.unknown;
return theme === 'dark' && set.dark ? set.dark : set.light;
return resolvedTheme === 'dark' && set.dark ? set.dark : set.light;
};
// OAuth 排除相关方法
@@ -547,7 +548,7 @@ export function AuthFilesPage() {
{existingTypes.map((type) => {
const isActive = filter === type;
const color = type === 'all' ? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' } : getTypeColor(type);
const activeTextColor = theme === 'dark' ? '#111827' : '#fff';
const activeTextColor = resolvedTheme === 'dark' ? '#111827' : '#fff';
return (
<button
key={type}

View File

@@ -3,6 +3,7 @@
.container {
width: 100%;
height: 100%;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
@@ -133,8 +134,8 @@
.editorWrapper {
width: 100%;
flex: 1;
min-height: 200px;
flex: 0 0 auto;
height: 480px;
border: 1px solid var(--border-color);
border-radius: $radius-lg;
overflow: hidden;
@@ -166,6 +167,9 @@
.cm-scroller {
overflow: auto;
padding-top: calc(var(--floating-controls-height, 0px) + #{$spacing-md});
-webkit-overflow-scrolling: touch;
touch-action: pan-x pan-y;
overscroll-behavior: contain;
}
.cm-gutters {
@@ -234,3 +238,26 @@
}
}
}
@media (max-height: 820px) {
.pageTitle {
font-size: 24px;
margin-bottom: $spacing-sm;
}
.description {
margin-bottom: $spacing-lg;
}
.content {
gap: $spacing-md;
}
.configCard {
padding: $spacing-md;
}
.editorWrapper {
height: 360px;
}
}

View File

@@ -16,7 +16,7 @@ export function ConfigPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const theme = useThemeStore((state) => state.theme);
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
@@ -289,7 +289,7 @@ export function ConfigPage() {
value={content}
onChange={handleChange}
extensions={extensions}
theme={theme === 'dark' ? 'dark' : 'light'}
theme={resolvedTheme}
editable={!disableControls && !loading}
placeholder={t('config_management.editor_placeholder')}
height="100%"

View File

@@ -242,7 +242,11 @@ export function DashboardPage() {
</div>
<div className={styles.connectionInfo}>
<span className={styles.serverUrl}>{apiBase || '-'}</span>
{serverVersion && <span className={styles.serverVersion}>v{serverVersion}</span>}
{serverVersion && (
<span className={styles.serverVersion}>
v{serverVersion.trim().replace(/^[vV]+/, '')}
</span>
)}
{serverBuildDate && (
<span className={styles.buildDate}>
{new Date(serverBuildDate).toLocaleDateString(i18n.language)}

View File

@@ -3,6 +3,7 @@
.container {
width: 100%;
height: 100%;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
@@ -160,10 +161,20 @@
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
flex: 1;
min-height: 200px;
flex: 0 0 auto;
height: 480px;
overflow: auto;
position: relative;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
overscroll-behavior: contain;
}
.errorPanel {
height: 480px;
overflow: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.loadMoreBanner {
@@ -367,3 +378,34 @@
color: var(--text-secondary);
word-break: break-word;
}
@media (max-height: 820px) {
.pageTitle {
font-size: 24px;
margin-bottom: $spacing-md;
}
.tabBar {
margin-bottom: $spacing-md;
}
.tabItem {
padding: 10px 16px;
}
.content {
gap: $spacing-md;
}
.logCard {
padding: $spacing-md;
}
.logPanel {
height: 360px;
}
.errorPanel {
height: 360px;
}
}

View File

@@ -1,9 +1,11 @@
import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { PointerEvent as ReactPointerEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import {
IconDownload,
@@ -14,7 +16,7 @@ import {
IconTrash2,
IconX,
} from '@/components/ui/icons';
import { useNotificationStore, useAuthStore } from '@/stores';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { logsApi } from '@/services/api/logs';
import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
import { formatUnixTimestamp } from '@/utils/format';
@@ -38,6 +40,8 @@ const INITIAL_DISPLAY_LINES = 100;
const LOAD_MORE_LINES = 200;
const MAX_BUFFER_LINES = 10000;
const LOAD_MORE_THRESHOLD_PX = 72;
const LONG_PRESS_MS = 650;
const LONG_PRESS_MOVE_THRESHOLD = 10;
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const;
type HttpMethod = (typeof HTTP_METHODS)[number];
@@ -361,6 +365,7 @@ export function LogsPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const requestLogEnabled = useConfigStore((state) => state.config?.requestLog ?? false);
const [activeTab, setActiveTab] = useState<TabType>('logs');
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
@@ -369,13 +374,22 @@ export function LogsPage() {
const [autoRefresh, setAutoRefresh] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const deferredSearchQuery = useDeferredValue(searchQuery);
const [hideManagementLogs, setHideManagementLogs] = useState(false);
const [hideManagementLogs, setHideManagementLogs] = useState(true);
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
const [loadingErrors, setLoadingErrors] = useState(false);
const [errorLogsError, setErrorLogsError] = useState('');
const [requestLogId, setRequestLogId] = useState<string | null>(null);
const [requestLogDownloading, setRequestLogDownloading] = useState(false);
const logViewerRef = useRef<HTMLDivElement | null>(null);
const pendingScrollToBottomRef = useRef(false);
const pendingPrependScrollRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
const longPressRef = useRef<{
timer: number | null;
startX: number;
startY: number;
fired: boolean;
} | null>(null);
// 保存最新时间戳用于增量获取
const latestTimestampRef = useRef<number>(0);
@@ -488,14 +502,18 @@ export function LogsPage() {
}
setLoadingErrors(true);
setErrorLogsError('');
try {
const res = await logsApi.fetchErrorLogs();
// API 返回 { files: [...] }
setErrorLogs(Array.isArray(res.files) ? res.files : []);
} catch (err: unknown) {
console.error('Failed to load error logs:', err);
// 静默失败,不影响主日志显示
setErrorLogs([]);
const message = getErrorMessage(err);
setErrorLogsError(
message ? `${t('logs.error_logs_load_error')}: ${message}` : t('logs.error_logs_load_error')
);
} finally {
setLoadingErrors(false);
}
@@ -525,11 +543,17 @@ export function LogsPage() {
if (connectionStatus === 'connected') {
latestTimestampRef.current = 0;
loadLogs(false);
loadErrorLogs();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectionStatus]);
useEffect(() => {
if (activeTab !== 'errors') return;
if (connectionStatus !== 'connected') return;
void loadErrorLogs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, connectionStatus, requestLogEnabled]);
useEffect(() => {
if (!autoRefresh || connectionStatus !== 'connected') {
return;
@@ -635,6 +659,85 @@ export function LogsPage() {
}
};
const clearLongPressTimer = () => {
if (longPressRef.current?.timer) {
window.clearTimeout(longPressRef.current.timer);
longPressRef.current.timer = null;
}
};
const startLongPress = (event: ReactPointerEvent<HTMLDivElement>, id?: string) => {
if (!requestLogEnabled) return;
if (!id) return;
if (requestLogId) return;
clearLongPressTimer();
longPressRef.current = {
timer: window.setTimeout(() => {
setRequestLogId(id);
if (longPressRef.current) {
longPressRef.current.fired = true;
longPressRef.current.timer = null;
}
}, LONG_PRESS_MS),
startX: event.clientX,
startY: event.clientY,
fired: false,
};
};
const cancelLongPress = () => {
clearLongPressTimer();
longPressRef.current = null;
};
const handleLongPressMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const current = longPressRef.current;
if (!current || current.timer === null || current.fired) return;
const deltaX = Math.abs(event.clientX - current.startX);
const deltaY = Math.abs(event.clientY - current.startY);
if (deltaX > LONG_PRESS_MOVE_THRESHOLD || deltaY > LONG_PRESS_MOVE_THRESHOLD) {
cancelLongPress();
}
};
const closeRequestLogModal = () => {
if (requestLogDownloading) return;
setRequestLogId(null);
};
const downloadRequestLog = async (id: string) => {
setRequestLogDownloading(true);
try {
const response = await logsApi.downloadRequestLogById(id);
const blob = new Blob([response.data], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `request-${id}.log`;
a.click();
window.URL.revokeObjectURL(url);
showNotification(t('logs.request_log_download_success'), 'success');
setRequestLogId(null);
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
'error'
);
} finally {
setRequestLogDownloading(false);
}
};
useEffect(() => {
return () => {
if (longPressRef.current?.timer) {
window.clearTimeout(longPressRef.current.timer);
longPressRef.current.timer = null;
}
};
}, []);
return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
@@ -748,6 +851,10 @@ export function LogsPage() {
</div>
</div>
<div className="hint">
{requestLogEnabled ? t('logs.action_hint') : t('logs.action_hint_disabled')}
</div>
{loading ? (
<div className="hint">{t('logs.loading')}</div>
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (
@@ -783,6 +890,11 @@ export function LogsPage() {
onDoubleClick={() => {
void copyLogLine(line.raw);
}}
onPointerDown={(event) => startLongPress(event, line.requestId)}
onPointerUp={cancelLongPress}
onPointerLeave={cancelLongPress}
onPointerCancel={cancelLongPress}
onPointerMove={handleLongPressMove}
title={t('logs.double_click_copy_hint', {
defaultValue: 'Double-click to copy',
})}
@@ -877,40 +989,89 @@ export function LogsPage() {
{activeTab === 'errors' && (
<Card
extra={
<Button variant="secondary" size="sm" onClick={loadErrorLogs} loading={loadingErrors}>
<Button
variant="secondary"
size="sm"
onClick={loadErrorLogs}
loading={loadingErrors}
disabled={disableControls}
>
{t('common.refresh')}
</Button>
}
>
{errorLogs.length === 0 ? (
<div className="hint">{t('logs.error_logs_empty')}</div>
) : (
<div className="item-list">
{errorLogs.map((item) => (
<div key={item.name} className="item-row">
<div className="item-meta">
<div className="item-title">{item.name}</div>
<div className="item-subtitle">
{item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''}{' '}
{item.modified ? formatUnixTimestamp(item.modified) : ''}
<div className="stack">
<div className="hint">{t('logs.error_logs_description')}</div>
{requestLogEnabled && (
<div>
<div className="status-badge warning">{t('logs.error_logs_request_log_enabled')}</div>
</div>
)}
{errorLogsError && <div className="error-box">{errorLogsError}</div>}
<div className={styles.errorPanel}>
{loadingErrors ? (
<div className="hint">{t('common.loading')}</div>
) : errorLogs.length === 0 ? (
<div className="hint">{t('logs.error_logs_empty')}</div>
) : (
<div className="item-list">
{errorLogs.map((item) => (
<div key={item.name} className="item-row">
<div className="item-meta">
<div className="item-title">{item.name}</div>
<div className="item-subtitle">
{item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''}{' '}
{item.modified ? formatUnixTimestamp(item.modified) : ''}
</div>
</div>
<div className="item-actions">
<Button
variant="secondary"
size="sm"
onClick={() => downloadErrorLog(item.name)}
disabled={disableControls}
>
{t('logs.error_logs_download')}
</Button>
</div>
</div>
</div>
<div className="item-actions">
<Button
variant="secondary"
size="sm"
onClick={() => downloadErrorLog(item.name)}
>
{t('logs.error_logs_download')}
</Button>
</div>
))}
</div>
))}
)}
</div>
)}
</div>
</Card>
)}
</div>
<Modal
open={Boolean(requestLogId)}
onClose={closeRequestLogModal}
title={t('logs.request_log_download_title')}
footer={
<>
<Button variant="secondary" onClick={closeRequestLogModal} disabled={requestLogDownloading}>
{t('common.cancel')}
</Button>
<Button
onClick={() => {
if (requestLogId) {
void downloadRequestLog(requestLogId);
}
}}
loading={requestLogDownloading}
disabled={!requestLogId}
>
{t('common.confirm')}
</Button>
</>
}
>
{requestLogId ? t('logs.request_log_download_confirm', { id: requestLogId }) : null}
</Modal>
</div>
);
}

View File

@@ -4,6 +4,17 @@
width: 100%;
}
.cardTitle {
display: flex;
align-items: center;
gap: $spacing-sm;
}
.cardTitleIcon {
width: 24px;
height: 24px;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
@@ -103,3 +114,25 @@
gap: $spacing-sm;
margin-top: $spacing-sm;
}
.filePicker {
display: flex;
align-items: center;
gap: $spacing-sm;
flex-wrap: wrap;
}
.fileName {
flex: 1;
min-width: 220px;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
}
.fileNamePlaceholder {
color: var(--text-secondary);
}

View File

@@ -1,11 +1,20 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { useNotificationStore } from '@/stores';
import { useNotificationStore, useThemeStore } from '@/stores';
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
import { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
import styles from './OAuthPage.module.scss';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
import iconClaude from '@/assets/icons/claude.svg';
import iconAntigravity from '@/assets/icons/antigravity.svg';
import iconGemini from '@/assets/icons/gemini.svg';
import iconQwen from '@/assets/icons/qwen.svg';
import iconIflow from '@/assets/icons/iflow.svg';
import iconVertex from '@/assets/icons/vertex.svg';
interface ProviderState {
url?: string;
@@ -29,23 +38,50 @@ interface IFlowCookieState {
errorType?: 'error' | 'warning';
}
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string }[] = [
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label' },
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label' },
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label' },
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label' },
{ id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label' },
{ id: 'iflow', titleKey: 'auth_login.iflow_oauth_title', hintKey: 'auth_login.iflow_oauth_hint', urlLabelKey: 'auth_login.iflow_oauth_url_label' }
interface VertexImportResult {
projectId?: string;
email?: string;
location?: string;
authFile?: string;
}
interface VertexImportState {
file?: File;
fileName: string;
location: string;
loading: boolean;
error?: string;
result?: VertexImportResult;
}
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconOpenaiLight, dark: iconOpenaiDark } },
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity },
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini },
{ id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label', icon: iconQwen },
{ id: 'iflow', titleKey: 'auth_login.iflow_oauth_title', hintKey: 'auth_login.iflow_oauth_hint', urlLabelKey: 'auth_login.iflow_oauth_url_label', icon: iconIflow }
];
const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli', 'iflow'];
const getIcon = (icon: string | { light: string; dark: string }, theme: 'light' | 'dark') => {
return typeof icon === 'string' ? icon : icon[theme];
};
export function OAuthPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false });
const [vertexState, setVertexState] = useState<VertexImportState>({
fileName: '',
location: '',
loading: false
});
const timers = useRef<Record<string, number>>({});
const vertexFileInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
return () => {
@@ -204,6 +240,64 @@ export function OAuthPage() {
}
};
const handleVertexFilePick = () => {
vertexFileInputRef.current?.click();
};
const handleVertexFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.name.endsWith('.json')) {
showNotification(t('vertex_import.file_required'), 'warning');
event.target.value = '';
return;
}
setVertexState((prev) => ({
...prev,
file,
fileName: file.name,
error: undefined,
result: undefined
}));
event.target.value = '';
};
const handleVertexImport = async () => {
if (!vertexState.file) {
const message = t('vertex_import.file_required');
setVertexState((prev) => ({ ...prev, error: message }));
showNotification(message, 'warning');
return;
}
const location = vertexState.location.trim();
setVertexState((prev) => ({ ...prev, loading: true, error: undefined, result: undefined }));
try {
const res: VertexImportResponse = await vertexApi.importCredential(
vertexState.file,
location || undefined
);
const result: VertexImportResult = {
projectId: res.project_id,
email: res.email,
location: res.location,
authFile: res['auth-file'] ?? res.auth_file
};
setVertexState((prev) => ({ ...prev, loading: false, result }));
showNotification(t('vertex_import.success'), 'success');
} catch (err: any) {
const message = err?.message || '';
setVertexState((prev) => ({
...prev,
loading: false,
error: message || t('notification.upload_failed')
}));
const notification = message
? `${t('notification.upload_failed')}: ${message}`
: t('notification.upload_failed');
showNotification(notification, 'error');
}
};
return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('nav.oauth', { defaultValue: 'OAuth' })}</h1>
@@ -215,7 +309,16 @@ export function OAuthPage() {
return (
<div key={provider.id}>
<Card
title={t(provider.titleKey)}
title={
<span className={styles.cardTitle}>
<img
src={getIcon(provider.icon, resolvedTheme)}
alt=""
className={styles.cardTitleIcon}
/>
{t(provider.titleKey)}
</span>
}
extra={
<Button onClick={() => startAuth(provider.id)} loading={state.polling}>
{t('common.login')}
@@ -307,9 +410,102 @@ export function OAuthPage() {
);
})}
{/* Vertex JSON 登录 */}
<Card
title={
<span className={styles.cardTitle}>
<img src={iconVertex} alt="" className={styles.cardTitleIcon} />
{t('vertex_import.title')}
</span>
}
extra={
<Button onClick={handleVertexImport} loading={vertexState.loading}>
{t('vertex_import.import_button')}
</Button>
}
>
<div className="hint">{t('vertex_import.description')}</div>
<Input
label={t('vertex_import.location_label')}
hint={t('vertex_import.location_hint')}
value={vertexState.location}
onChange={(e) =>
setVertexState((prev) => ({
...prev,
location: e.target.value
}))
}
placeholder={t('vertex_import.location_placeholder')}
/>
<div className="form-group">
<label>{t('vertex_import.file_label')}</label>
<div className={styles.filePicker}>
<Button variant="secondary" size="sm" onClick={handleVertexFilePick}>
{t('vertex_import.choose_file')}
</Button>
<div
className={`${styles.fileName} ${
vertexState.fileName ? '' : styles.fileNamePlaceholder
}`.trim()}
>
{vertexState.fileName || t('vertex_import.file_placeholder')}
</div>
</div>
<div className="hint">{t('vertex_import.file_hint')}</div>
<input
ref={vertexFileInputRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={handleVertexFileChange}
/>
</div>
{vertexState.error && (
<div className="status-badge error" style={{ marginTop: 8 }}>
{vertexState.error}
</div>
)}
{vertexState.result && (
<div className="connection-box" style={{ marginTop: 12 }}>
<div className="label">{t('vertex_import.result_title')}</div>
<div className="key-value-list">
{vertexState.result.projectId && (
<div className="key-value-item">
<span className="key">{t('vertex_import.result_project')}</span>
<span className="value">{vertexState.result.projectId}</span>
</div>
)}
{vertexState.result.email && (
<div className="key-value-item">
<span className="key">{t('vertex_import.result_email')}</span>
<span className="value">{vertexState.result.email}</span>
</div>
)}
{vertexState.result.location && (
<div className="key-value-item">
<span className="key">{t('vertex_import.result_location')}</span>
<span className="value">{vertexState.result.location}</span>
</div>
)}
{vertexState.result.authFile && (
<div className="key-value-item">
<span className="key">{t('vertex_import.result_file')}</span>
<span className="value">{vertexState.result.authFile}</span>
</div>
)}
</div>
</div>
)}
</Card>
{/* iFlow Cookie 登录 */}
<Card
title={t('auth_login.iflow_cookie_title')}
title={
<span className={styles.cardTitle}>
<img src={iconIflow} alt="" className={styles.cardTitleIcon} />
{t('auth_login.iflow_cookie_title')}
</span>
}
extra={
<Button onClick={submitIflowCookie} loading={iflowCookie.loading}>
{t('auth_login.iflow_cookie_button')}

View File

@@ -18,6 +18,13 @@
gap: 10px;
}
.headerActions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.pageTitle {
font-size: 28px;
font-weight: 700;

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useMemo, type CSSProperties } from 'react';
import { useEffect, useState, useCallback, useMemo, useRef, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import {
Chart as ChartJS,
@@ -19,7 +19,7 @@ import { Input } from '@/components/ui/Input';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useThemeStore } from '@/stores';
import { useNotificationStore, useThemeStore } from '@/stores';
import { usageApi } from '@/services/api/usage';
import {
formatTokensInMillions,
@@ -63,14 +63,18 @@ interface UsagePayload {
export function UsagePage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const isMobile = useMediaQuery('(max-width: 768px)');
const theme = useThemeStore((state) => state.theme);
const isDark = theme === 'dark';
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const isDark = resolvedTheme === 'dark';
const [usage, setUsage] = useState<UsagePayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const importInputRef = useRef<HTMLInputElement | null>(null);
// Model price form state
const [selectedModel, setSelectedModel] = useState('');
@@ -107,6 +111,77 @@ export function UsagePage() {
setModelPrices(loadModelPrices());
}, [loadUsage]);
const handleExport = async () => {
setExporting(true);
try {
const data = await usageApi.exportUsage();
const exportedAt =
typeof data?.exported_at === 'string' ? new Date(data.exported_at) : new Date();
const safeTimestamp = Number.isNaN(exportedAt.getTime())
? new Date().toISOString()
: exportedAt.toISOString();
const filename = `usage-export-${safeTimestamp.replace(/[:.]/g, '-')}.json`;
const blob = new Blob([JSON.stringify(data ?? {}, null, 2)], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
window.URL.revokeObjectURL(url);
showNotification(t('usage_stats.export_success'), 'success');
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
showNotification(
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
'error'
);
} finally {
setExporting(false);
}
};
const handleImportClick = () => {
importInputRef.current?.click();
};
const handleImportChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
setImporting(true);
try {
const text = await file.text();
let payload: unknown;
try {
payload = JSON.parse(text);
} catch {
showNotification(t('usage_stats.import_invalid'), 'error');
return;
}
const result = await usageApi.importUsage(payload);
showNotification(
t('usage_stats.import_success', {
added: result?.added ?? 0,
skipped: result?.skipped ?? 0,
total: result?.total_requests ?? 0,
failed: result?.failed_requests ?? 0
}),
'success'
);
await loadUsage();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
showNotification(
`${t('notification.upload_failed')}${message ? `: ${message}` : ''}`,
'error'
);
} finally {
setImporting(false);
}
};
// Calculate derived data
const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 };
const rateStats = usage
@@ -527,14 +602,41 @@ export function UsagePage() {
)}
<div className={styles.header}>
<h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1>
<Button
variant="secondary"
size="sm"
onClick={loadUsage}
disabled={loading}
>
{loading ? t('common.loading') : t('usage_stats.refresh')}
</Button>
<div className={styles.headerActions}>
<Button
variant="secondary"
size="sm"
onClick={handleExport}
loading={exporting}
disabled={loading || importing}
>
{t('usage_stats.export')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleImportClick}
loading={importing}
disabled={loading || exporting}
>
{t('usage_stats.import')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={loadUsage}
disabled={loading || exporting || importing}
>
{loading ? t('common.loading') : t('usage_stats.refresh')}
</Button>
<input
ref={importInputRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={handleImportChange}
/>
</div>
</div>
{error && <div className={styles.errorBox}>{error}</div>}

View File

@@ -11,3 +11,4 @@ export * from './logs';
export * from './version';
export * from './models';
export * from './transformers';
export * from './vertex';

View File

@@ -39,4 +39,10 @@ export const logsApi = {
responseType: 'blob',
timeout: LOGS_TIMEOUT_MS
}),
downloadRequestLogById: (id: string) =>
apiClient.getRaw(`/request-log-by-id/${encodeURIComponent(id)}`, {
responseType: 'blob',
timeout: LOGS_TIMEOUT_MS
}),
};

View File

@@ -7,12 +7,38 @@ import { computeKeyStats, KeyStats } from '@/utils/usage';
const USAGE_TIMEOUT_MS = 60 * 1000;
export interface UsageExportPayload {
version?: number;
exported_at?: string;
usage?: Record<string, unknown>;
[key: string]: unknown;
}
export interface UsageImportResponse {
added?: number;
skipped?: number;
total_requests?: number;
failed_requests?: number;
[key: string]: unknown;
}
export const usageApi = {
/**
* 获取使用统计原始数据
*/
getUsage: () => apiClient.get('/usage', { timeout: USAGE_TIMEOUT_MS }),
/**
* 导出使用统计快照
*/
exportUsage: () => apiClient.get<UsageExportPayload>('/usage/export', { timeout: USAGE_TIMEOUT_MS }),
/**
* 导入使用统计快照
*/
importUsage: (payload: unknown) =>
apiClient.post<UsageImportResponse>('/usage/import', payload, { timeout: USAGE_TIMEOUT_MS }),
/**
* 计算密钥成功/失败统计,必要时会先获取 usage 数据
*/

View File

@@ -0,0 +1,25 @@
/**
* Vertex credential import API
*/
import { apiClient } from './client';
export interface VertexImportResponse {
status: 'ok';
project_id?: string;
email?: string;
location?: string;
'auth-file'?: string;
auth_file?: string;
}
export const vertexApi = {
importCredential: (file: File, location?: string) => {
const formData = new FormData();
formData.append('file', file);
if (location) {
formData.append('location', location);
}
return apiClient.postForm<VertexImportResponse>('/vertex/import', formData);
}
};

View File

@@ -8,63 +8,79 @@ import { persist } from 'zustand/middleware';
import type { Theme } from '@/types';
import { STORAGE_KEY_THEME } from '@/utils/constants';
type ResolvedTheme = 'light' | 'dark';
interface ThemeState {
theme: Theme;
resolvedTheme: ResolvedTheme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
initializeTheme: () => void;
cycleTheme: () => void;
initializeTheme: () => () => void;
}
const getSystemTheme = (): ResolvedTheme => {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
};
const applyTheme = (resolved: ResolvedTheme) => {
if (resolved === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
};
export const useThemeStore = create<ThemeState>()(
persist(
(set, get) => ({
theme: 'light',
theme: 'auto',
resolvedTheme: 'light',
setTheme: (theme) => {
// 应用主题到 DOM
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
set({ theme });
const resolved: ResolvedTheme = theme === 'auto' ? getSystemTheme() : theme;
applyTheme(resolved);
set({ theme, resolvedTheme: resolved });
},
toggleTheme: () => {
cycleTheme: () => {
const { theme, setTheme } = get();
const newTheme: Theme = theme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
const order: Theme[] = ['light', 'dark', 'auto'];
const currentIndex = order.indexOf(theme);
const nextTheme = order[(currentIndex + 1) % order.length];
setTheme(nextTheme);
},
initializeTheme: () => {
const { theme, setTheme } = get();
// 检查系统偏好
if (
!localStorage.getItem(STORAGE_KEY_THEME) &&
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
setTheme('dark');
return;
}
// 应用已保存的主题
setTheme(theme);
// 监听系统主题变化(仅在用户未手动设置时
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem(STORAGE_KEY_THEME)) {
setTheme(e.matches ? 'dark' : 'light');
}
});
// 监听系统主题变化(仅在 auto 模式下生效
if (!window.matchMedia) {
return () => {};
}
}
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const listener = () => {
const { theme: currentTheme } = get();
if (currentTheme === 'auto') {
const resolved = getSystemTheme();
applyTheme(resolved);
set({ resolvedTheme: resolved });
}
};
mediaQuery.addEventListener('change', listener);
return () => mediaQuery.removeEventListener('change', listener);
},
}),
{
name: STORAGE_KEY_THEME
name: STORAGE_KEY_THEME,
}
)
);

View File

@@ -331,13 +331,11 @@
}
.main-content {
flex: 1;
flex: 1 0 auto;
padding: $spacing-lg;
display: flex;
flex-direction: column;
gap: $spacing-lg;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
@media (max-width: $breakpoint-mobile) {

View File

@@ -2,7 +2,7 @@
* 通用类型定义
*/
export type Theme = 'light' | 'dark';
export type Theme = 'light' | 'dark' | 'auto';
export type Language = 'zh-CN' | 'en';

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />