From 3e325d4d90923eabf42d0e861f9913ea3a3b219b Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:54:16 +0800 Subject: [PATCH 1/2] fix(upstreams): undo layout squeezed only to avoid a scrollbar #319 and #320 made every step of the upstream dialog fit 1100x720 without a scrollbar, including the steps with many settings. That rule was meant for steps with few options and a lot of empty space. Steps with many settings may scroll: the dialog keeps its fixed height and its body scrolls. Options crammed into columns, descriptions dropped and results moved into hovers only to save a few rows go back to their natural form. The approved redesign stays (service tiles and search, auth rows, Advanced settings collapsed unless set, Check on Next when creating, sign-in cards, account summary rows, the import host note). - Edit dialog: Check connection is back in the Connection section body, with the full result and the balance below it, not in the footer with the details in a hover. - Advanced settings, expanded: headers full width, then Forward client identity with its description, then the short fields two per row (proxy unavailable, concurrency limit with its description, and the balance source for Custom as a normal labelled field). - Connection: the base URL is full width again, protocol and region share the next row; AWS access keys and OAuth go back to two columns with the session token and access token on their own rows; the English "Auto-detect (not detected, forwarded as is)" that was shortened for the narrow protocol column is back. Spacing back to the form's gap-4. - Descriptions restored, as worded before: base URL, headers (${NAME}), session token, OAuth access token, and the egress note on the ChatGPT account page; also the Z.ai key-storage notice and the full device code warning, which had been cut to fit one line. - ChatGPT account page: name and egress in two columns with the concurrency limit below, usage limits above reset credits, gap-5. - Import link: the dialog no longer grows by the note's height; the note takes its space from the body, and long host names wrap instead of being cut off. - Billing: the price sheet rows keep their normal padding, and the import link's new-sheet row wraps its detail and its error instead of truncating them. Co-Authored-By: Claude Opus 5.5 --- src/upstreams/AccountPanel.i18n.tsx | 7 +- src/upstreams/AccountPanel.tsx | 9 +- src/upstreams/BillingSection.tsx | 20 +- src/upstreams/ChatgptAccountSection.i18n.ts | 2 + src/upstreams/ChatgptAccountSection.tsx | 21 +- src/upstreams/ConnectionSection.i18n.ts | 10 +- src/upstreams/ConnectionSection.tsx | 430 ++++++++------------ src/upstreams/ManualModelInput.tsx | 4 +- src/upstreams/ModelsSection.tsx | 7 +- src/upstreams/UpstreamDialog.tsx | 69 +--- 10 files changed, 251 insertions(+), 328 deletions(-) diff --git a/src/upstreams/AccountPanel.i18n.tsx b/src/upstreams/AccountPanel.i18n.tsx index f386b220..cdff5a01 100644 --- a/src/upstreams/AccountPanel.i18n.tsx +++ b/src/upstreams/AccountPanel.i18n.tsx @@ -18,6 +18,7 @@ export const accountPanelText = messages( noticeTheirPage: "授权页由对方提供,页面上显示的是他们自己的客户端名称。", noticeKey: "登录会在账号中创建一把名为 thinkwatch 的 API 密钥(已存在则直接使用),额度按该密钥计入套餐。", + noticeKeyStorage: (where: string) => `得到的密钥保存在${where}的配置文件中,与其他上游一同管理。`, here: "本机", server: (name: string) => ` ${name} `, understood: "已阅读上述说明", @@ -45,7 +46,7 @@ export const accountPanelText = messages( open: "打开", deviceWaiting: (min: number) => `等待输入 · ${min} 分钟内有效`, newCode: "重新获取", - deviceWarning: "只输入这里显示的设备码", + deviceWarning: "只输入这里显示的设备码;他人提供的设备码请勿输入。", doneTitle: "已登录", reloginDone: (name: ReactNode) => <>已重新登录,上游 {name} 的凭据已更新。, signedIn: "已登录", @@ -75,6 +76,8 @@ export const accountPanelText = messages( noticeTheirPage: "The authorization page is theirs, and it names their own client.", noticeKey: "Signing in creates an API key named thinkwatch on the account, or uses the existing one. Quota is counted against that key.", + noticeKeyStorage: (where: string) => + `The key obtained by signing in is stored in the config file ${where} and managed together with the other upstreams.`, here: "on this computer", server: (name: string) => `on ${name}`, understood: "Acknowledge the notes above", @@ -103,7 +106,7 @@ export const accountPanelText = messages( open: "Open", deviceWaiting: (min: number) => `Waiting for the code · valid for ${min} min`, newCode: "Get a new code", - deviceWarning: "Enter only the device code shown here", + deviceWarning: "Enter only the device code shown here; never enter a device code provided by someone else.", doneTitle: "Signed in", reloginDone: (name: ReactNode) => <>Signed in again. The credential of the upstream {name} has been replaced., signedIn: "Signed in", diff --git a/src/upstreams/AccountPanel.tsx b/src/upstreams/AccountPanel.tsx index d3525222..2aaaa1f4 100644 --- a/src/upstreams/AccountPanel.tsx +++ b/src/upstreams/AccountPanel.tsx @@ -159,6 +159,7 @@ function Methods({ <>
  • {t.noticeTheirPage}
  • {t.noticeKey}
  • +
  • {t.noticeKeyStorage(where)}
  • )} @@ -273,8 +274,8 @@ function Way({ } /** - * 设备码登录:验证网址和设备码并排(网址能复制、能打开,码大字、能复制),下面是有效期、 - * 重新获取和那一句提醒。在这台电脑上时还能改回浏览器登录 + * 设备码登录:验证网址和设备码并排(网址能复制、能打开,码大字、能复制),下面是有效期和 + * 重新获取(在这台电脑上时还能改回浏览器登录),最后一句提醒 */ function DeviceCode({ login, params }: { login: AccountLogin; params: LoginParams }) { const t = useText(accountPanelText); @@ -317,9 +318,9 @@ function DeviceCode({ login, params }: { login: AccountLogin; params: LoginParam )} - {/* 拿着别人给的码去输,等于把自己的账号授权给对方 */} - {t.deviceWarning} + {/* 拿着别人给的码去输,等于把自己的账号授权给对方 */} +

    {t.deviceWarning}

    ); } diff --git a/src/upstreams/BillingSection.tsx b/src/upstreams/BillingSection.tsx index 418389ab..013e86e7 100644 --- a/src/upstreams/BillingSection.tsx +++ b/src/upstreams/BillingSection.tsx @@ -65,7 +65,7 @@ export function BillingSection({ ] : []; - // 一屏放下:价格表在自己的框里滚,框按剩下的高度收 + // 价格表在自己的框里滚,框按剩下的高度收;再矮就让对话框中间那一块去滚 return (
    @@ -110,13 +110,15 @@ export function BillingSection({
    {perToken && linkSheet && ( -
    - - {t.linkSheetDetail(linkSheet.multiplier, Object.keys(linkSheet.models).length)} - - {sheetProblem && {sheetProblem}} -
    - @@ -124,7 +126,7 @@ export function BillingSection({ )} {perToken && sheet && ( -
    +
    {t.usedBy}
    diff --git a/src/upstreams/ChatgptAccountSection.i18n.ts b/src/upstreams/ChatgptAccountSection.i18n.ts index 3c068068..98b635c4 100644 --- a/src/upstreams/ChatgptAccountSection.i18n.ts +++ b/src/upstreams/ChatgptAccountSection.i18n.ts @@ -16,6 +16,7 @@ export const chatgptAccountText = messages( }, name: "名称", egress: "出站方式", + egressDesc: "登录与后续请求都经此发出", direct: "直连", systemProxy: "系统代理", quota: "订阅额度", @@ -56,6 +57,7 @@ export const chatgptAccountText = messages( }, name: "Name", egress: "Outbound connection", + egressDesc: "Used for sign-in and all later requests", direct: "Direct", systemProxy: "System proxy", quota: "Usage limits", diff --git a/src/upstreams/ChatgptAccountSection.tsx b/src/upstreams/ChatgptAccountSection.tsx index 8f11ae5b..5acb1047 100644 --- a/src/upstreams/ChatgptAccountSection.tsx +++ b/src/upstreams/ChatgptAccountSection.tsx @@ -116,10 +116,9 @@ export function ChatgptAccountSection({ const list = credits?.credits ?? []; const available = list.filter((c) => c.status === AVAILABLE); - // 一屏放下:名称、出站方式、并发上限一行,额度和重置卡左右两栏 return ( -
    -
    +
    +
    set({ name: e.target.value })} /> - + {/* 账号一样限制同时进行的请求 */} - +
    {!broken && ( -
    -
    -
    + <> +
    +

    {t.quota}

    -
    -
    +
    +

    {t.credits} {credits && ` · ${t.available(available.length)}`} @@ -233,7 +232,7 @@ export function ChatgptAccountSection({ )}

    -
    + )} diff --git a/src/upstreams/ConnectionSection.i18n.ts b/src/upstreams/ConnectionSection.i18n.ts index afa00215..831d92a9 100644 --- a/src/upstreams/ConnectionSection.i18n.ts +++ b/src/upstreams/ConnectionSection.i18n.ts @@ -14,6 +14,7 @@ export const connectionSectionText = messages( namePlaceholder: "例如 relay-hk", namePlaceholderGateway: "例如 corp-gateway", baseUrl: "接口地址", + baseUrlDesc: "服务商提供的 Base URL,不含 /chat/completions、/messages 等接口路径。", auth: "认证方式", site: "站点", siteZai: "Z.ai(国际)", @@ -22,6 +23,7 @@ export const connectionSectionText = messages( /** 收起时那一行列出的几项之间 */ listSep: "、", headers: "请求头", + headersHint: "随每个请求发送。值中的 ${变量名} 读取系统环境变量。", authUnknown: "鉴权头(按接口协议)", renewedToken: "‹自动换发的 Access Token›", signedPerRequest: "‹每个请求单独签名›", @@ -31,6 +33,7 @@ export const connectionSectionText = messages( accessKeyId: "访问密钥 ID", secretAccessKey: "私有访问密钥", sessionToken: "会话令牌(可选)", + sessionTokenDesc: "临时凭证才有。", /** 术语表:两种语言都写 AWS profile(「配置文件」是 config.yaml) */ awsProfile: "AWS profile", profileDesc: "读取本机 AWS 凭证文件(~/.aws/credentials、~/.aws/config)中的这个 profile,只支持写有访问密钥的 profile。", @@ -56,6 +59,7 @@ export const connectionSectionText = messages( clientId: "Client ID(可选)", clientSecret: "Client Secret(可选)", accessToken: "Access Token(检测用,可选)", + accessTokenDesc: "仅用于保存前检测连接。", failed: "连接失败", authenticated: "认证通过", responded: (ms: string) => `响应 ${ms}`, @@ -68,12 +72,13 @@ export const connectionSectionText = messages( { auto: "Auto-detect", autoDetected: (protocol: string) => `Auto-detect (${protocol})`, - autoUndetected: "Auto-detect (pass-through)", + autoUndetected: "Auto-detect (not detected, forwarded as is)", protocol: "Protocol", name: "Name", namePlaceholder: "e.g. relay-hk", namePlaceholderGateway: "e.g. corp-gateway", baseUrl: "Base URL", + baseUrlDesc: "The provider's base URL, without an endpoint path such as /chat/completions or /messages.", auth: "Authentication", site: "Site", siteZai: "Z.ai (international)", @@ -81,6 +86,7 @@ export const connectionSectionText = messages( advanced: "Advanced settings", listSep: ", ", headers: "Headers", + headersHint: "Sent with every request. ${NAME} in a value reads a system environment variable.", authUnknown: "Auth header (per protocol)", renewedToken: "‹renewed access token›", signedPerRequest: "‹signed per request›", @@ -90,6 +96,7 @@ export const connectionSectionText = messages( accessKeyId: "Access key ID", secretAccessKey: "Secret access key", sessionToken: "Session token (optional)", + sessionTokenDesc: "Only temporary credentials have one.", awsProfile: "AWS profile", profileDesc: "Reads this profile from the AWS credential files on this computer (~/.aws/credentials, ~/.aws/config). Only profiles that hold access keys are supported.", @@ -115,6 +122,7 @@ export const connectionSectionText = messages( clientId: "Client ID (optional)", clientSecret: "Client secret (optional)", accessToken: "Access token (for checking, optional)", + accessTokenDesc: "Only used to check the connection before saving.", failed: "Connection failed", authenticated: "Authenticated", responded: (ms: string) => `responded in ${ms}`, diff --git a/src/upstreams/ConnectionSection.tsx b/src/upstreams/ConnectionSection.tsx index 2f3af8d5..9f8c2c0e 100644 --- a/src/upstreams/ConnectionSection.tsx +++ b/src/upstreams/ConnectionSection.tsx @@ -2,13 +2,11 @@ import { useState, type ReactNode } from "react"; import { ChevronRightIcon, CircleAlertIcon, CircleCheckIcon, PlugIcon } from "lucide-react"; import { Button, DISCLOSURE } from "@/ui/button"; import { Input } from "@/ui/input"; -import { Label } from "@/ui/label"; import { NativeSelect, NativeSelectOption } from "@/ui/native-select"; import { SecretInput } from "@/ui/secret-input"; import { Segmented } from "@/ui/segmented"; import { StatusLabel } from "@/ui/status-dot"; import { Switch } from "@/ui/switch"; -import { Tip } from "@/ui/tip"; import { cn } from "@/lib/utils"; import { ms } from "@/format"; import { useText } from "@/i18n"; @@ -131,15 +129,17 @@ export interface ZaiAccount { } /** - * 连接:名称和出站代理一行,地址和协议一行(协议只在有得选时出现),凭据,最后是收起的 - * 「高级设置」(请求头、转发客户端身份、代理不可用时、并发上限)。新建和编辑是同一张表单; - * 新建时第一步选的服务类型已经定了的几项(地址、协议、要不要密钥)不再问,见 `presets.ts`。 + * 连接:名称和出站代理一行,地址(整行,下面一句说明),协议和区域一行(只在有得选时出现), + * 凭据,最后是收起的「高级设置」(请求头、转发客户端身份、代理不可用时、并发上限)。新建和 + * 编辑是同一张表单;新建时第一步选的服务类型已经定了的几项(地址、协议、要不要密钥)不再问, + * 见 `presets.ts`。 * * 认证方式那一行和 Z.ai 的站点在这一节上面(`AuthField`、`SiteField`),新建时登录账号 * 这一节整个让位给登录那一块;编辑 Z.ai / BigModel 上游时账号登录只占密钥那一栏的位置。 * - * **「检测连接」只在编辑时是一个按钮**,在对话框底部左边(`CheckButton`):新建时点「下一步」 - * 就检测,通过了结果在「模型」一步顶上,没通过时对话框底部说原因(见 `UpstreamDialog`)。 + * **「检测连接」只在编辑时有**(`check`),在这一节最后,结果整句写在它下面(`CheckButton`)。 + * 新建时点「下一步」就检测,通过了结果在「模型」一步顶上,没通过时对话框底部说原因(见 + * `UpstreamDialog`)。 */ export function ConnectionSection({ form, @@ -149,6 +149,7 @@ export function ConnectionSection({ preview, onNewProxy, account, + check, }: { form: UpstreamForm; set: (patch: Partial) => void; @@ -159,6 +160,8 @@ export function ConnectionSection({ onNewProxy: () => void; /** 编辑 Z.ai / BigModel 上游、它在能登录的那一边时有;认证方式是账号登录时显示 */ account?: ZaiAccount; + /** 「检测连接」:编辑时有,新建时点「下一步」就检测 */ + check?: { testing: boolean; test: ProviderTestResult | null; onTest: () => void }; }) { const t = useText(connectionSectionText); // 连着远程 core 时 `${变量名}` 取的是服务器上 core 进程的环境 @@ -184,16 +187,25 @@ export function ConnectionSection({ // 不签名,非标准地址也就用不着区域 const showRegion = bedrock && (region !== null || mode !== "key"); - /* - 地址、协议、区域排成一行:地址占两份,协议、区域各占一份。地址由这一格定了的(Z.ai - 按站点)不显示,剩下的各占一半 - */ - const addr: { key: string; node: ReactNode }[] = []; - if (rules.url) { - addr.push({ - key: "url", - node: ( - + return ( +
    + {/* 名称和出站代理在每种认证方式下都是这一行:换认证方式时它们不挪地方 */} +
    + + set({ name: e.target.value })} + /> + + set({ proxy })} onNewProxy={onNewProxy} /> +
    + + {/* 地址由这一格定了的(Z.ai 按站点)不显示 */} + {rules.url && ( + - ), - }); - } - // 协议只在真有得选时给选:只说一种接口的服务,选了那一格就定了 - if (rules.protocols) { - addr.push({ - key: "protocol", - node: ( - - - - ), - }); - } - if (showRegion) { - addr.push({ - key: "region", - node: ( - - (region !== null ? set({ baseUrl: bedrockUrl(r) }) : set({ awsRegion: r }))} - /> - - ), - }); - } - const addrColumns = rules.url - ? addr.map((c) => (c.key === "url" ? "minmax(0,2fr)" : "minmax(0,1fr)")).join(" ") - : "repeat(2,minmax(0,1fr))"; - - return ( -
    - {/* 名称和出站代理在每种认证方式下都是这一行:换认证方式时它们不挪地方 */} -
    - - set({ name: e.target.value })} - /> - - set({ proxy })} onNewProxy={onNewProxy} /> -
    + )} - {addr.length > 0 && ( -
    - {addr.map((c) => ( -
    - {c.node} -
    - ))} + {/* 协议只在真有得选时给选:只说一种接口的服务,选了那一格就定了 */} + {(rules.protocols || showRegion) && ( +
    + {rules.protocols && ( + + + + )} + {showRegion && ( + + (region !== null ? set({ baseUrl: bedrockUrl(r) }) : set({ awsRegion: r }))} + /> + + )}
    )} @@ -298,8 +274,8 @@ export function ConnectionSection({ )} - {/* `${变量名}` 在值的占位里说过;连着远程 core 时读的是服务器上的环境,另说一句 */} - + {/* 连着远程 core 时 `${变量名}` 读的是服务器上的环境 */} + + + {check && }
    ); } @@ -362,22 +340,44 @@ function AdvancedSettings({ {!open && {inside.join(t.listSep)}} {/* - 展开时左右两栏:左边请求头(自定义的余额来源接在下面,和请求头一样是这个服务自己的事), - 右边转发客户端身份、代理不可用时和并发上限 —— 上下排开放不进一屏 + 展开时一项接一项:请求头整行,转发客户端身份和它的说明,最后是几项短的(代理不可用时、 + 并发上限,自定义还有余额来源)两两并排 */} {open && ( -
    -
    - {children} - {/* 标签和下拉在一行:一屏放得下展开的这一节 */} +
    + {children} + {/* 只有中转站和自定义的服务会只接受特定客户端;各家官方的接口不看这个 */} + {rules.clientIdentity && ( +
    + +

    {t.clientIdentityDesc}

    +
    + )} +
    + + set({ onProxyFail: e.target.value === "direct" ? "direct" : "fail" })} + > + {t.failWithError} + {t.fallBackDirect} + + + {rules.balance && ( -
    - + set({ balance: e.target.value as BalanceSetting })} > @@ -387,38 +387,8 @@ function AdvancedSettings({ ))} -
    - )} -
    -
    - {/* 只有中转站和自定义的服务会只接受特定客户端;各家官方的接口不看这个 */} - {rules.clientIdentity && ( -
    - -

    {t.clientIdentityDesc}

    -
    - )} -
    - - set({ onProxyFail: e.target.value === "direct" ? "direct" : "fail" })} - > - {t.failWithError} - {t.fallBackDirect} - - -
    + )}
    )} @@ -481,12 +451,9 @@ export function ProxyField({ export function ConcurrencyField({ form, set, - bare = false, }: { form: UpstreamForm; set: (patch: Partial) => void; - /** 不写那句说明(窄的一格里)。写错时照样说 */ - bare?: boolean; }) { const t = useText(connectionSectionText); const bad = concurrencyOf(form) === undefined; @@ -494,7 +461,7 @@ export function ConcurrencyField({ {t.badConcurrency} : bare ? undefined : t.concurrencyDesc} + desc={bad ? {t.badConcurrency} : t.concurrencyDesc} > ENV_REF.test(v.trim()); return ( -
    -
    - - set({ awsKeyId: e.target.value })} - /> - - - set({ awsSecret: e.target.value })} - /> - - - set({ awsToken: e.target.value })} - /> - -
    - {remote && {rt.keyHint}} +
    + + set({ awsKeyId: e.target.value })} + /> + + + set({ awsSecret: e.target.value })} + /> + + + set({ awsToken: e.target.value })} + /> +
    ); } -/** - * OAuth 凭据,两行:Token 端点和 Refresh Token;Client ID、Client Secret,和检测用的 - * Access Token(凭据没改时检测用网关现有的 token,不要它) - */ +/** OAuth 凭据。检测用的 Access Token 只在凭据新填或改过时要:没改时检测用网关现有的 token */ function OAuth({ form, set, @@ -674,63 +640,58 @@ function OAuth({ set: (patch: Partial) => void; }) { const t = useText(connectionSectionText); - const access = !oauthKept(form); return ( -
    -
    - - set({ oauthEndpoint: e.target.value })} - /> - - +
    + + set({ oauthEndpoint: e.target.value })} + /> + + + set({ oauthRefresh: e.target.value })} + /> + + + set({ oauthClientId: e.target.value })} + /> + + + set({ oauthClientSecret: e.target.value })} + /> + + {!oauthKept(form) && ( + set({ oauthRefresh: e.target.value })} - /> - -
    -
    - - set({ oauthClientId: e.target.value })} + value={form.oauthAccess} + onChange={(e) => set({ oauthAccess: e.target.value })} /> - - set({ oauthClientSecret: e.target.value })} - /> - - {access && ( - - set({ oauthAccess: e.target.value })} - /> - - )} -
    + )}
    ); } /** - * 行菜单「检测连接」对话框里的结果:「连接正常 · 认证通过 · 响应 312ms · 经由 hk-socks · - * 发现 6 个模型」。 + * 检测连接的结果(行菜单的「检测连接」对话框,编辑时「连接」一节最后那一块):「连接正常 · + * 认证通过 · 响应 312ms · 经由 hk-socks · 发现 6 个模型」。 * * 检测时一并读了余额的,下面再一行:从哪儿读的,和上游表那一格写的那几样(`balanceBrief`)。 * 读取失败时这一行是琥珀色的原因。 @@ -778,9 +739,8 @@ export function TestLine({ result }: { result: ProviderTestResult }) { } /** - * 编辑时对话框底部左边的「检测连接」,结果接在右边一行:「✓ 连接正常 · 认证通过 · 响应 - * 312ms · 发现 6 个模型」,没通过是「连接失败」和 core 给的原因。一行放不下的截断,全文 - * (和检测时读到的余额)在悬停里 + * 编辑时「连接」一节最后的「检测连接」:按钮旁一句它做什么(检测中是转圈),检测完结果整句 + * 写在下面(`TestLine`:认证、响应时间、代理、模型数,读到余额的再一行) */ export function CheckButton({ testing, @@ -792,58 +752,30 @@ export function CheckButton({ onTest: () => void; }) { const t = useText(connectionSectionText); - const lines = test ? testLines(test, t) : []; return ( -
    - - - - {testing ? ( - - {t.checking} - - ) : ( - test && ( - - {lines.map((l, i) => ( - {l} - ))} - - } - > -
    - {test.ok ? ( - - ) : ( - - )} - {test.ok ? t.ok : t.failed} - {lines.slice(1).join(" · ")} -
    -
    - ) + {testing ? ( + + {t.checking} + + ) : ( + {t.checkNote} + )} +
    + {test && !testing && ( +
    + +
    )}
    ); } -/** 检测结果写成几行:结论、细节(或原因)、余额 */ -function testLines(r: ProviderTestResult, t: (typeof connectionSectionText)["zh"]): string[] { - if (!r.ok) return [t.failed, ...(r.error ? [coreText(r.error)] : [])]; - const parts = [t.authenticated, t.responded(ms(r.latency_ms)), r.via ? t.via(r.via) : null, describeModelList(r.models)]; - const balance = balanceBrief(r.balance, Date.now()); - return [ - t.ok, - parts.filter(Boolean).join(" · "), - ...(balance ? [`${balance.source} · ${balance.text}`] : []), - ]; -} - /** * 新建时「模型」一步顶上的一行:点「下一步」检测通过的结果。「连接正常 · 响应 312ms · 发现 * 24 个模型」,经由代理时带上代理;检测时读到了余额的,右边写余额(和上游表那一格同一套写法) diff --git a/src/upstreams/ManualModelInput.tsx b/src/upstreams/ManualModelInput.tsx index 3d3621e9..2ee79fd1 100644 --- a/src/upstreams/ManualModelInput.tsx +++ b/src/upstreams/ManualModelInput.tsx @@ -86,8 +86,8 @@ export function ManualModelList({ inputRowClassName?: string; inputRef?: Ref; /** - * 放在一屏排满的一节里(上游对话框的「模型」):框按剩下的高度收,`children` 里那一层滚动的 - * 跟着收,输入行和提示不收 + * 放在按剩下的高度排的一节里(上游对话框的「模型」):框按剩下的高度收,`children` 里那一层 + * 滚动的跟着收,输入行和提示不收 */ fill?: boolean; }) { diff --git a/src/upstreams/ModelsSection.tsx b/src/upstreams/ModelsSection.tsx index ca6af27d..8e137357 100644 --- a/src/upstreams/ModelsSection.tsx +++ b/src/upstreams/ModelsSection.tsx @@ -81,10 +81,11 @@ export function modelCount(hasList: boolean, listed: number, added: number): str } /** - * 对话框的「模型」一节,**一屏放下**:顶上一行(新建时是检测结果或登录的账号,编辑时是清单从哪儿 - * 来、多少个),一条工具条(启用范围、已启用几个、筛选、刷新),下面是上游列出的和手动添加的 + * 对话框的「模型」一节:顶上一行(新建时是检测结果或登录的账号,编辑时是清单从哪儿来、 + * 多少个),一条工具条(启用范围、已启用几个、筛选、刷新),下面是上游列出的和手动添加的 * 合成的一张表,最后一行手动添加模型(名单式输入,和模型弹窗的「添加模型…」共用,见 - * `ManualModelInput`)。**只有那张表在自己的框里滚**,框按剩下的高度收放。 + * `ManualModelInput`)。**那张表在自己的框里滚**,框按剩下的高度收放;剩下的太矮时框留着 + * 几行高,由对话框中间那一块去滚。 * * 手动添加的那几行标「手动」、行尾「移除」。加和移除都只改表单,和别的设置一起保存。 * 指定了启用范围时,新加的一并勾上(`withManualAdded`)。 diff --git a/src/upstreams/UpstreamDialog.tsx b/src/upstreams/UpstreamDialog.tsx index 96c8c72f..37b4bd62 100644 --- a/src/upstreams/UpstreamDialog.tsx +++ b/src/upstreams/UpstreamDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState, type Ref } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { LinkIcon } from "lucide-react"; import { Input } from "@/ui/input"; import { Banner } from "@/ui/banner"; @@ -34,7 +34,6 @@ import { BillingSection } from "./BillingSection"; import { ChatgptAccountSection } from "./ChatgptAccountSection"; import { AuthField, - CheckButton, CheckSummary, ConnectionSection, ProxyField, @@ -127,12 +126,15 @@ interface SignedIn { * 随意切换**,「保存」一次提交所有分节的改动 —— core 那边是一次写入、一个 * 配置版本。取消不写入任何东西。 * - * **一屏放下**:默认窗口(1100×720)里每一步都不出整页的滚动条,只有长列表(模型表、价格表) - * 在自己的框里滚。窗口再小时中间那一块可以滚。 + * **高度固定,内容高了中间那一块滚**。选项少、空白多的步(选服务类型、只要一把密钥的连接、 + * 登录)收拾紧凑,在默认窗口(1100×720)里一屏放下;设置多的步(展开的高级设置、OAuth、账号 + * 的额度与重置卡)可以滚,滚动条不算毛病。**不为了省掉滚动条**把选项挤成几栏、删掉有用的 + * 说明、或者把结果藏进悬停里。长列表(模型表、价格表)在自己的框里滚,框按剩下的高度收放。 * * **新建时点「下一步」就检测连接**:按钮转圈,通过了进「模型」,结果写在那一步顶上;没通过就 * 留在这一步,底部一条红色横幅说原因,给「重试」和「仍然继续」—— 有的上游不接受检测却能用, - * 有的没有模型列表。编辑时没有「下一步」,「检测连接」照旧是一个按钮。 + * 有的没有模型列表。编辑时没有「下一步」,「检测连接」是「连接」一节最后的一个按钮,结果 + * (连同读到的余额)整句写在它下面。 * * **登录账号(ChatGPT、Z.ai / BigModel)在第二步里完成**:登录成功时 core 已经把上游 * 写进配置,直接进「模型」,顶上写登的是哪个账号;之后的「模型」「计费」两步改的就是这个 @@ -244,21 +246,6 @@ export function UpstreamDialog({ const [visited, setVisited] = useState>( () => new Set
    (drafted ? ["service", "connection"] : ["service"]), ); - /** - * 导入的对话框顶上那一句占的高度(连同它和步骤条之间的间距)。**加在对话框的高度上,不从 - * 中间那一块里扣**:没有它时放得下的每一步,有它时照样放得下 - */ - // 对话框画在 portal 里,挂上得晚一步:等那一句真的挂上了再量 - const [noteEl, setNoteEl] = useState(null); - const [noteHeight, setNoteHeight] = useState(0); - useLayoutEffect(() => { - if (!noteEl) return; - const measure = () => setNoteHeight(noteEl.offsetHeight + NOTE_GAP); - measure(); - const ro = new ResizeObserver(measure); - ro.observe(noteEl); - return () => ro.disconnect(); - }, [noteEl]); /** 第一步的搜索框。放在这里:它画在步骤条右边,不在那一节里 */ const [query, setQuery] = useState(""); @@ -677,13 +664,12 @@ export function UpstreamDialog({ return ( !open && onClose()}> {/* - 固定高度:切换分节时对话框不跳动;默认窗口里每一步都放得下,只有长列表在自己的框里 - 滚,窗口更小时中间那一块滚。点到对话框外面不关 —— 填了一半的表单不该因为一次误点 - 丢掉;Esc、×、取消照常。登录进行中关掉就是放弃这一次登录(`useAccountLogin` 卸载时取消) + 固定高度:切换分节时对话框不跳动,内容高了中间那一块滚。点到对话框外面不关 —— 填了 + 一半的表单不该因为一次误点丢掉;Esc、×、取消照常。登录进行中关掉就是放弃这一次登录 + (`useAccountLogin` 卸载时取消) */} 0 ? { height: `calc(min(88vh, 760px) + ${noteHeight}px)` } : undefined} onInteractOutside={(e) => e.preventDefault()} // 导入链接打开的不是点出来的:焦点放在对话框上,不落到第一个按钮上画一圈焦点框 onOpenAutoFocus={imported ? focusSelf : undefined} @@ -754,9 +740,9 @@ export function UpstreamDialog({ )}
    + {/* 占的是中间那一块的高度:放不下时那一块滚 */} {imported && ( @@ -781,7 +767,7 @@ export function UpstreamDialog({ /> )} {section === "connection" && ( -
    +
    setNested({ kind: "proxy" })} account={zaiAccount} + // 新建时点「下一步」就检测,没有这个按钮 + check={editing ? { testing, test, onTest: () => void runTest() } : undefined} /> )}
    @@ -899,13 +887,7 @@ export function UpstreamDialog({ {editing ? ( <> - {/* 「检测连接」只在连接这一节,结果接在它右边 */} - {section === "connection" ? ( - void runTest()} /> - ) : ( - - )} - {blocking && {blocking}} + {blocking && {blocking}} @@ -1027,26 +1009,19 @@ export function UpstreamDialog({ ); } -/** 那一句和步骤条之间的间距:对话框各块之间是 16px,它往上提了 8px(`-mt-2`) */ -const NOTE_GAP = 8; - /** * 导入的对话框每一步顶上那一句:来自导入链接,请求和密钥发往哪台主机(OAuth 的 Token 端点在 * 别的主机上时两台都写)。主机名是 ASCII,IDN 已转成 punycode。只陈述,不是警告。 * - * `plain`:地址是内网的明文 `http://`,下面再一行说不加密 + * `plain`:地址是内网的明文 `http://`,下面再一行说不加密。主机名长的折行,不截断 */ -function ImportNote({ hosts, plain, ref }: { hosts: string[]; plain: boolean; ref?: Ref }) { +function ImportNote({ hosts, plain }: { hosts: string[]; plain: boolean }) { const t = useText(upstreamDialogText); return ( -
    -

    - - +

    +

    + + {t.importFrom} {hosts.length > 0 && ( <> @@ -1178,7 +1153,7 @@ function AccountStep({ existing.base_url.replace(/\/+$/, "") === ZAI_ENDPOINTS[family].replace(/\/+$/, ""); const taken = existing != null && !replaces; return ( -

    +
    Date: Sun, 11 Oct 2026 21:57:24 +0800 Subject: [PATCH 2/2] Base URL and protocol share a row again The user approved that row in the design; a step with few fields does not need the extra line, and the base URL hint still sits under the address. The undetected label stays short for the narrower column. Co-Authored-By: Claude Opus 5.5 --- src/upstreams/ConnectionSection.i18n.ts | 2 +- src/upstreams/ConnectionSection.tsx | 64 +++++++++++++++---------- 2 files changed, 39 insertions(+), 27 deletions(-) diff --git a/src/upstreams/ConnectionSection.i18n.ts b/src/upstreams/ConnectionSection.i18n.ts index 831d92a9..d23d2ca2 100644 --- a/src/upstreams/ConnectionSection.i18n.ts +++ b/src/upstreams/ConnectionSection.i18n.ts @@ -72,7 +72,7 @@ export const connectionSectionText = messages( { auto: "Auto-detect", autoDetected: (protocol: string) => `Auto-detect (${protocol})`, - autoUndetected: "Auto-detect (not detected, forwarded as is)", + autoUndetected: "Auto-detect (pass-through)", protocol: "Protocol", name: "Name", namePlaceholder: "e.g. relay-hk", diff --git a/src/upstreams/ConnectionSection.tsx b/src/upstreams/ConnectionSection.tsx index 9f8c2c0e..800023c8 100644 --- a/src/upstreams/ConnectionSection.tsx +++ b/src/upstreams/ConnectionSection.tsx @@ -129,7 +129,7 @@ export interface ZaiAccount { } /** - * 连接:名称和出站代理一行,地址(整行,下面一句说明),协议和区域一行(只在有得选时出现), + * 连接:名称和出站代理一行,地址和协议、区域一行(协议、区域只在有得选时出现,地址下面一句说明), * 凭据,最后是收起的「高级设置」(请求头、转发客户端身份、代理不可用时、并发上限)。新建和 * 编辑是同一张表单;新建时第一步选的服务类型已经定了的几项(地址、协议、要不要密钥)不再问, * 见 `presets.ts`。 @@ -203,31 +203,43 @@ export function ConnectionSection({ set({ proxy })} onNewProxy={onNewProxy} />
    - {/* 地址由这一格定了的(Z.ai 按站点)不显示 */} - {rules.url && ( - - - set({ - baseUrl: e.target.value, - // 新建时名称跟着地址猜,直到用户自己填了 - name: - !editing && (form.name === "" || form.name === freeName(nameFromUrl(form.baseUrl), taken)) - ? freeName(nameFromUrl(e.target.value), taken) - : form.name, - }) - } - /> - - )} - - {/* 协议只在真有得选时给选:只说一种接口的服务,选了那一格就定了 */} - {(rules.protocols || showRegion) && ( -
    + {/* + 地址、协议、区域排成一行:地址占两份,协议、区域各占一份(协议只在真有得选时给选,只说 + 一种接口的服务选了那一格就定了)。地址由这一格定了的(Z.ai 按站点)不显示,剩下的各占一半 + */} + {(rules.url || rules.protocols || showRegion) && ( +
    + {rules.url && ( + + + set({ + baseUrl: e.target.value, + // 新建时名称跟着地址猜,直到用户自己填了 + name: + !editing && (form.name === "" || form.name === freeName(nameFromUrl(form.baseUrl), taken)) + ? freeName(nameFromUrl(e.target.value), taken) + : form.name, + }) + } + /> + + )} {rules.protocols && (