Compare commits

..

12 Commits

Author SHA1 Message Date
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
Supra4E8C
684502c8b6 fix: layout.scss 2025-12-24 23:39:40 +08:00
hkfires
0aee78c072 fix(logs): improve request id and status code parsing 2025-12-24 22:46:08 +08:00
hkfires
8780ea7ec5 fix(logs): wrap long log messages 2025-12-24 15:38:11 +08:00
hkfires
40fe33aeae fix(config): optimize layout for full height 2025-12-24 12:45:30 +08:00
hkfires
2a94be08fa fix(logs): optimize layout for full height 2025-12-24 12:38:57 +08:00
hkfires
0758cfe08a feat(logs): implement tabbed view for logs and error files 2025-12-24 11:45:14 +08:00
dp
39847fa56d feat(oauth): add provider icons to oauth login cards 2025-12-23 14:05:35 +08:00
21 changed files with 502 additions and 270 deletions

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

@@ -3,11 +3,12 @@ import type { PropsWithChildren, ReactNode } from 'react';
interface CardProps {
title?: ReactNode;
extra?: ReactNode;
className?: string;
}
export function Card({ title, extra, children }: PropsWithChildren<CardProps>) {
export function Card({ title, extra, children, className }: PropsWithChildren<CardProps>) {
return (
<div className="card">
<div className={className ? `card ${className}` : 'card'}>
{(title || extra) && (
<div className="card-header">
<div className="title">{title}</div>

View File

@@ -608,6 +608,8 @@
"auto_refresh_disabled": "Auto refresh disabled",
"load_more_hint": "Scroll up to load more",
"hidden_lines": "Hidden: {{count}} lines",
"loaded_lines": "Loaded: {{count}} lines",
"filtered_lines": "Filtered: {{count}} lines",
"hide_management_logs": "Hide {{prefix}} logs",
"search_placeholder": "Search logs by content or keyword",
"search_empty_title": "No matching logs found",

View File

@@ -608,6 +608,8 @@
"auto_refresh_disabled": "自动刷新已关闭",
"load_more_hint": "向上滚动加载更多",
"hidden_lines": "已隐藏 {{count}} 行",
"loaded_lines": "已载入 {{count}} 行",
"filtered_lines": "已过滤 {{count}} 行",
"hide_management_logs": "屏蔽 {{prefix}} 日志",
"search_placeholder": "搜索日志内容或关键字",
"search_empty_title": "未找到匹配的日志",

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

@@ -2,6 +2,11 @@
.container {
width: 100%;
height: 100%;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.pageTitle {
@@ -21,6 +26,8 @@
display: flex;
flex-direction: column;
gap: $spacing-lg;
flex: 1;
min-height: 0;
}
.searchInputWrapper {
@@ -127,7 +134,8 @@
.editorWrapper {
width: 100%;
height: 500px;
flex: 1;
min-height: 200px;
border: 1px solid var(--border-color);
border-radius: $radius-lg;
overflow: hidden;
@@ -159,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 {
@@ -206,6 +217,14 @@
}
}
.configCard {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.actions {
display: flex;
gap: $spacing-sm;

View File

@@ -224,7 +224,7 @@ export function ConfigPage() {
<h1 className={styles.pageTitle}>{t('config_management.title')}</h1>
<p className={styles.description}>{t('config_management.description')}</p>
<Card>
<Card className={styles.configCard}>
<div className={styles.content}>
{/* Editor */}
{error && <div className="error-box">{error}</div>}

View File

@@ -2,19 +2,65 @@
.container {
width: 100%;
height: 100%;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 $spacing-xl 0;
margin: 0 0 $spacing-lg 0;
}
.tabBar {
display: flex;
gap: $spacing-xs;
margin-bottom: $spacing-lg;
border-bottom: 1px solid var(--border-color);
}
.tabItem {
@include button-reset;
padding: 12px 20px;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
background: transparent;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
cursor: pointer;
transition:
color 0.15s ease,
border-color 0.15s ease;
&:hover {
color: var(--text-primary);
}
}
.tabActive {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
}
.content {
display: flex;
flex-direction: column;
gap: $spacing-lg;
flex: 1;
min-height: 0;
}
.logCard {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.toolbar {
@@ -22,9 +68,12 @@
align-items: center;
gap: $spacing-sm;
flex-wrap: wrap;
margin-left: auto;
@include mobile {
align-items: flex-start;
margin-left: 0;
width: 100%;
}
}
@@ -112,9 +161,13 @@
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
max-height: 620px;
flex: 1;
min-height: 200px;
overflow: auto;
position: relative;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
overscroll-behavior: contain;
}
.loadMoreBanner {
@@ -137,6 +190,12 @@
white-space: nowrap;
}
.loadMoreStats {
display: flex;
align-items: center;
gap: $spacing-md;
}
.logList {
display: flex;
flex-direction: column;
@@ -310,5 +369,5 @@
.message {
color: var(--text-secondary);
white-space: nowrap;
word-break: break-word;
}

View File

@@ -49,7 +49,7 @@ const LOG_SOURCE_REGEX = /^\[([^\]]+)\]/;
const LOG_LATENCY_REGEX = /\b(\d+(?:\.\d+)?)(?:\s*)(µs|us|ms|s)\b/i;
const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;
const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i;
const LOG_REQUEST_ID_REGEX = /^([a-f0-9]{8}|--------|---------)$/i;
const LOG_REQUEST_ID_REGEX = /^([a-f0-9]{8}|--------)$/i;
const LOG_TIME_OF_DAY_REGEX = /^\d{1,2}:\d{2}:\d{2}(?:\.\d{1,3})?$/;
const GIN_TIMESTAMP_SEGMENT_REGEX =
/^\[GIN\]\s+(\d{4})\/(\d{2})\/(\d{2})\s*-\s*(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s*$/;
@@ -156,6 +156,16 @@ const parseLogLine = (raw: string): ParsedLogLine => {
remaining = remaining.slice(tsMatch[0].length).trim();
}
let requestId: string | undefined;
const requestIdMatch = remaining.match(/^\[([a-f0-9]{8}|--------)\]\s*/i);
if (requestIdMatch) {
const id = requestIdMatch[1];
if (!/^-+$/.test(id)) {
requestId = id;
}
remaining = remaining.slice(requestIdMatch[0].length).trim();
}
let level: LogLevel | undefined;
const lvlMatch = remaining.match(LOG_LEVEL_REGEX);
if (lvlMatch) {
@@ -175,7 +185,6 @@ const parseLogLine = (raw: string): ParsedLogLine => {
let ip: string | undefined;
let method: HttpMethod | undefined;
let path: string | undefined;
let requestId: string | undefined;
let message = remaining;
if (remaining.includes('|')) {
@@ -216,9 +225,9 @@ const parseLogLine = (raw: string): ParsedLogLine => {
}
// status code
const statusIndex = segments.findIndex((segment) => /^\d{3}\b/.test(segment));
const statusIndex = segments.findIndex((segment) => /^\d{3}$/.test(segment));
if (statusIndex >= 0) {
const match = segments[statusIndex].match(/^(\d{3})\b/);
const match = segments[statusIndex].match(/^(\d{3})$/);
if (match) {
const code = Number.parseInt(match[1], 10);
if (code >= 100 && code <= 599) {
@@ -346,11 +355,14 @@ const copyToClipboard = async (text: string) => {
}
};
type TabType = 'logs' | 'errors';
export function LogsPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const [activeTab, setActiveTab] = useState<TabType>('logs');
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -626,261 +638,278 @@ export function LogsPage() {
return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
<div className={styles.tabBar}>
<button
type="button"
className={`${styles.tabItem} ${activeTab === 'logs' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('logs')}
>
{t('logs.log_content')}
</button>
<button
type="button"
className={`${styles.tabItem} ${activeTab === 'errors' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('errors')}
>
{t('logs.error_logs_modal_title')}
</button>
</div>
<div className={styles.content}>
<Card
title={t('logs.log_content')}
extra={
<div className={styles.toolbar}>
<Button
variant="secondary"
size="sm"
onClick={() => loadLogs(false)}
disabled={disableControls || loading}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconRefreshCw size={16} />
{t('logs.refresh_button')}
</span>
</Button>
{activeTab === 'logs' && (
<Card className={styles.logCard}>
{error && <div className="error-box">{error}</div>}
<div className={styles.filters}>
<div className={styles.searchWrapper}>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('logs.search_placeholder')}
className={styles.searchInput}
rightElement={
searchQuery ? (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearchQuery('')}
title="Clear"
aria-label="Clear"
>
<IconX size={16} />
</button>
) : (
<IconSearch size={16} className={styles.searchIcon} />
)
}
/>
</div>
<ToggleSwitch
checked={autoRefresh}
onChange={(value) => setAutoRefresh(value)}
disabled={disableControls}
checked={hideManagementLogs}
onChange={setHideManagementLogs}
label={
<span className={styles.switchLabel}>
<IconTimer size={16} />
{t('logs.auto_refresh')}
<IconEyeOff size={16} />
{t('logs.hide_management_logs', { prefix: MANAGEMENT_API_PREFIX })}
</span>
}
/>
<Button
variant="secondary"
size="sm"
onClick={downloadLogs}
disabled={logState.buffer.length === 0}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconDownload size={16} />
{t('logs.download_button')}
</span>
</Button>
<Button
variant="danger"
size="sm"
onClick={clearLogs}
disabled={disableControls}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconTrash2 size={16} />
{t('logs.clear_button')}
</span>
</Button>
</div>
}
>
{error && <div className="error-box">{error}</div>}
<div className={styles.filters}>
<div className={styles.searchWrapper}>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('logs.search_placeholder')}
className={styles.searchInput}
rightElement={
searchQuery ? (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearchQuery('')}
title="Clear"
aria-label="Clear"
>
<IconX size={16} />
</button>
) : (
<IconSearch size={16} className={styles.searchIcon} />
)
}
/>
</div>
<ToggleSwitch
checked={hideManagementLogs}
onChange={setHideManagementLogs}
label={
<span className={styles.switchLabel}>
<IconEyeOff size={16} />
{t('logs.hide_management_logs', { prefix: MANAGEMENT_API_PREFIX })}
</span>
}
/>
<div className={styles.filterStats}>
<span>
{parsedVisibleLines.length} {t('logs.lines')}
</span>
{removedCount > 0 && (
<span className={styles.removedCount}>
{t('logs.removed')} {removedCount}
</span>
)}
</div>
</div>
{loading ? (
<div className="hint">{t('logs.loading')}</div>
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (
<div ref={logViewerRef} className={styles.logPanel} onScroll={handleLogScroll}>
{canLoadMore && (
<div className={styles.loadMoreBanner}>
<span>{t('logs.load_more_hint')}</span>
<span className={styles.loadMoreCount}>
{t('logs.hidden_lines', { count: logState.visibleFrom })}
<div className={styles.toolbar}>
<Button
variant="secondary"
size="sm"
onClick={() => loadLogs(false)}
disabled={disableControls || loading}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconRefreshCw size={16} />
{t('logs.refresh_button')}
</span>
</div>
)}
<div className={styles.logList}>
{parsedVisibleLines.map((line, index) => {
const rowClassNames = [styles.logRow];
if (line.level === 'warn') rowClassNames.push(styles.rowWarn);
if (line.level === 'error' || line.level === 'fatal')
rowClassNames.push(styles.rowError);
return (
<div
key={`${logState.visibleFrom + index}-${line.raw}`}
className={rowClassNames.join(' ')}
onDoubleClick={() => {
void copyLogLine(line.raw);
}}
title={t('logs.double_click_copy_hint', {
defaultValue: 'Double-click to copy',
})}
>
<div className={styles.timestamp}>{line.timestamp || ''}</div>
<div className={styles.rowMain}>
{line.level && (
<span
className={[
styles.badge,
line.level === 'info' ? styles.levelInfo : '',
line.level === 'warn' ? styles.levelWarn : '',
line.level === 'error' || line.level === 'fatal'
? styles.levelError
: '',
line.level === 'debug' ? styles.levelDebug : '',
line.level === 'trace' ? styles.levelTrace : '',
]
.filter(Boolean)
.join(' ')}
>
{line.level.toUpperCase()}
</span>
)}
{line.source && (
<span className={styles.source} title={line.source}>
{line.source}
</span>
)}
{typeof line.statusCode === 'number' && (
<span
className={[
styles.badge,
styles.statusBadge,
line.statusCode >= 200 && line.statusCode < 300
? styles.statusSuccess
: line.statusCode >= 300 && line.statusCode < 400
? styles.statusInfo
: line.statusCode >= 400 && line.statusCode < 500
? styles.statusWarn
: styles.statusError,
].join(' ')}
>
{line.statusCode}
</span>
)}
{line.latency && <span className={styles.pill}>{line.latency}</span>}
{line.ip && <span className={styles.pill}>{line.ip}</span>}
{line.method && (
<span className={[styles.badge, styles.methodBadge].join(' ')}>
{line.method}
</span>
)}
{line.requestId && (
<span
className={[styles.badge, styles.requestIdBadge].join(' ')}
title={line.requestId}
>
{line.requestId}
</span>
)}
{line.path && (
<span className={styles.path} title={line.path}>
{line.path}
</span>
)}
{line.message && <span className={styles.message}>{line.message}</span>}
</div>
</div>
);
})}
</Button>
<ToggleSwitch
checked={autoRefresh}
onChange={(value) => setAutoRefresh(value)}
disabled={disableControls}
label={
<span className={styles.switchLabel}>
<IconTimer size={16} />
{t('logs.auto_refresh')}
</span>
}
/>
<Button
variant="secondary"
size="sm"
onClick={downloadLogs}
disabled={logState.buffer.length === 0}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconDownload size={16} />
{t('logs.download_button')}
</span>
</Button>
<Button
variant="danger"
size="sm"
onClick={clearLogs}
disabled={disableControls}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconTrash2 size={16} />
{t('logs.clear_button')}
</span>
</Button>
</div>
</div>
) : logState.buffer.length > 0 ? (
<EmptyState
title={t('logs.search_empty_title')}
description={t('logs.search_empty_desc')}
/>
) : (
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
)}
</Card>
<Card
title={t('logs.error_logs_modal_title')}
extra={
<Button variant="secondary" size="sm" onClick={loadErrorLogs} loading={loadingErrors}>
{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) : ''}
{loading ? (
<div className="hint">{t('logs.loading')}</div>
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (
<div ref={logViewerRef} className={styles.logPanel} onScroll={handleLogScroll}>
{canLoadMore && (
<div className={styles.loadMoreBanner}>
<span>{t('logs.load_more_hint')}</span>
<div className={styles.loadMoreStats}>
<span>
{t('logs.loaded_lines', { count: parsedVisibleLines.length })}
</span>
{removedCount > 0 && (
<span className={styles.loadMoreCount}>
{t('logs.filtered_lines', { count: removedCount })}
</span>
)}
<span className={styles.loadMoreCount}>
{t('logs.hidden_lines', { count: logState.visibleFrom })}
</span>
</div>
</div>
<div className="item-actions">
<Button
variant="secondary"
size="sm"
onClick={() => downloadErrorLog(item.name)}
>
{t('logs.error_logs_download')}
</Button>
</div>
)}
<div className={styles.logList}>
{parsedVisibleLines.map((line, index) => {
const rowClassNames = [styles.logRow];
if (line.level === 'warn') rowClassNames.push(styles.rowWarn);
if (line.level === 'error' || line.level === 'fatal')
rowClassNames.push(styles.rowError);
return (
<div
key={`${logState.visibleFrom + index}-${line.raw}`}
className={rowClassNames.join(' ')}
onDoubleClick={() => {
void copyLogLine(line.raw);
}}
title={t('logs.double_click_copy_hint', {
defaultValue: 'Double-click to copy',
})}
>
<div className={styles.timestamp}>{line.timestamp || ''}</div>
<div className={styles.rowMain}>
{line.level && (
<span
className={[
styles.badge,
line.level === 'info' ? styles.levelInfo : '',
line.level === 'warn' ? styles.levelWarn : '',
line.level === 'error' || line.level === 'fatal'
? styles.levelError
: '',
line.level === 'debug' ? styles.levelDebug : '',
line.level === 'trace' ? styles.levelTrace : '',
]
.filter(Boolean)
.join(' ')}
>
{line.level.toUpperCase()}
</span>
)}
{line.source && (
<span className={styles.source} title={line.source}>
{line.source}
</span>
)}
{line.requestId && (
<span
className={[styles.badge, styles.requestIdBadge].join(' ')}
title={line.requestId}
>
{line.requestId}
</span>
)}
{typeof line.statusCode === 'number' && (
<span
className={[
styles.badge,
styles.statusBadge,
line.statusCode >= 200 && line.statusCode < 300
? styles.statusSuccess
: line.statusCode >= 300 && line.statusCode < 400
? styles.statusInfo
: line.statusCode >= 400 && line.statusCode < 500
? styles.statusWarn
: styles.statusError,
].join(' ')}
>
{line.statusCode}
</span>
)}
{line.latency && <span className={styles.pill}>{line.latency}</span>}
{line.ip && <span className={styles.pill}>{line.ip}</span>}
{line.method && (
<span className={[styles.badge, styles.methodBadge].join(' ')}>
{line.method}
</span>
)}
{line.path && (
<span className={styles.path} title={line.path}>
{line.path}
</span>
)}
{line.message && <span className={styles.message}>{line.message}</span>}
</div>
</div>
);
})}
</div>
))}
</div>
)}
</Card>
</div>
) : logState.buffer.length > 0 ? (
<EmptyState
title={t('logs.search_empty_title')}
description={t('logs.search_empty_desc')}
/>
) : (
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
)}
</Card>
)}
{activeTab === 'errors' && (
<Card
extra={
<Button variant="secondary" size="sm" onClick={loadErrorLogs} loading={loadingErrors}>
{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>
</div>
<div className="item-actions">
<Button
variant="secondary"
size="sm"
onClick={() => downloadErrorLog(item.name)}
>
{t('logs.error_logs_download')}
</Button>
</div>
</div>
))}
</div>
)}
</Card>
)}
</div>
</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;

View File

@@ -3,9 +3,16 @@ 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 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';
interface ProviderState {
url?: string;
@@ -29,20 +36,25 @@ 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' }
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 { theme } = useThemeStore();
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false });
const timers = useRef<Record<string, number>>({});
@@ -215,7 +227,12 @@ export function OAuthPage() {
return (
<div key={provider.id}>
<Card
title={t(provider.titleKey)}
title={
<span className={styles.cardTitle}>
<img src={getIcon(provider.icon, theme)} alt="" className={styles.cardTitleIcon} />
{t(provider.titleKey)}
</span>
}
extra={
<Button onClick={() => startAuth(provider.id)} loading={state.polling}>
{t('common.login')}
@@ -309,7 +326,12 @@ export function OAuthPage() {
{/* 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

@@ -336,6 +336,9 @@
display: flex;
flex-direction: column;
gap: $spacing-lg;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
@media (max-width: $breakpoint-mobile) {
padding: $spacing-md;

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

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