merge old config

This commit is contained in:
leookun
2026-08-26 15:46:11 +08:00
parent 34f97334dc
commit e1937233ec
70 changed files with 4515 additions and 3764 deletions
@@ -1,15 +0,0 @@
.form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.fullWidth {
width: 100%;
grid-column: 1 / -1;
}
@media (max-width: 720px) {
.form { grid-template-columns: 1fr; }
.fullWidth { grid-column: auto; }
}
@@ -1,30 +0,0 @@
import type { ProviderInput, ProviderType } from "../api";
import { FormField, SecretTextInput, TextInput } from "./ui/FormControls";
import { JsonEditor } from "./ui/JsonEditor";
import { Select } from "./ui/Select";
import { claudeIcon, openAiIcon } from "./ui/icons";
import styles from "./ProviderEditor.module.scss";
export function ProviderEditor({ value, headersText, extraText, editing, onChange, onHeadersChange, onExtraChange }: {
value: ProviderInput;
headersText: string;
extraText: string;
editing: boolean;
onChange: (value: ProviderInput) => void;
onHeadersChange: (value: string) => void;
onExtraChange: (value: string) => void;
}) {
const patch = (next: Partial<ProviderInput>) => onChange({ ...value, ...next });
return <div className={styles.form}>
<FormField className={styles.fullWidth} label={t("名称")}><TextInput placeholder={t("例如:OpenAI")} value={value.name} onChange={(event) => patch({ name: event.target.value })} /></FormField>
<FormField label={t("协议")} hint={t("选择上游服务使用的请求协议。")}><Select ariaLabel={t("协议")} value={value.provider_type} options={[
{ value: "openai-responses", label: "OpenAI Responses", icon: openAiIcon },
{ value: "openai-chat", label: "OpenAI Chat", icon: openAiIcon },
{ value: "anthropic", label: "Anthropic", icon: claudeIcon },
]} onChange={(provider_type) => patch({ provider_type: provider_type as ProviderType })} /></FormField>
<FormField label="Base URL" hint={t("模型服务的 API 根地址;修改后会同步更新该上游模型的路由身份。")}><TextInput placeholder="https://api.example.com/v1" value={value.base_url} onChange={(event) => patch({ base_url: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label="API Key" hint={editing ? t("留空表示保留当前 API Key。") : t("访问模型服务所需的密钥。")}><SecretTextInput autoComplete="off" placeholder={editing ? t("留空以保留当前密钥") : "sk-xxxxxx"} value={value.api_key ?? ""} onChange={(event) => patch({ api_key: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label={t("自定义 Headers JSON")} hint={t("值必须是字符串;编辑时 null 表示保留对应敏感 Header 的原值。")}><JsonEditor ariaLabel={t("自定义 Headers JSON")} value={headersText} onChange={onHeadersChange} /></FormField>
<FormField className={styles.fullWidth} label={t("额外参数 JSON")} hint={t("合并到该上游所有模型的请求体。")}><JsonEditor ariaLabel={t("额外参数 JSON")} value={extraText} onChange={onExtraChange} /></FormField>
</div>;
}
@@ -1,14 +0,0 @@
@use "../styles/typography" as type;
.badge {
padding: 3px 7px;
color: var(--vscode-descriptionForeground);
background: var(--vscode-input-background);
border-radius: 99px;
font-size: type.$font-size-xs;
}
.actions {
display: flex;
gap: 2px;
}
@@ -1,31 +0,0 @@
import type { Provider } from "../api";
import controls from "./ui/Controls.module.scss";
import { DataTable, type DataTableColumn } from "./ui/DataTable";
import { Icon } from "./ui/Icon";
import { TooltipTrigger } from "./ui/TooltipTrigger";
import { editIcon, trashIcon } from "./ui/icons";
import styles from "./ProviderTable.module.scss";
const json = (value: unknown) => JSON.stringify(value);
export function ProviderTable({ providers, onEdit, onDelete }: {
providers: Provider[];
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
}) {
const columns: DataTableColumn<Provider>[] = [
{ key: "name", header: t("名称"), render: (provider) => provider.name, title: (provider) => provider.name },
{ key: "type", header: t("协议"), render: (provider) => provider.provider_type },
{ key: "url", header: "Base URL", render: (provider) => provider.base_url, title: (provider) => provider.base_url },
{ key: "key", header: "API Key", render: (provider) => <span className={styles.badge}>{provider.has_api_key ? t("已配置") : t("未配置")}</span> },
{ key: "headers", header: "Headers JSON", render: (provider) => json(provider.custom_headers), title: (provider) => json(provider.custom_headers) },
{ key: "extra", header: t("额外参数 JSON"), render: (provider) => json(provider.extra_params), title: (provider) => json(provider.extra_params) },
{ key: "created", header: t("创建时间"), render: (provider) => new Date(provider.created_at_ms).toLocaleString() },
{ key: "updated", header: t("更新时间"), render: (provider) => new Date(provider.updated_at_ms).toLocaleString() },
{ key: "actions", header: t("操作"), sticky: "right", render: (provider) => <div className={styles.actions}>
<TooltipTrigger label={t("编辑上游")}><button className={controls.iconButton} aria-label={t("编辑上游")} onClick={() => onEdit(provider)}><Icon icon={editIcon} size="1.1em" /></button></TooltipTrigger>
<TooltipTrigger label={t("删除上游")}><button className={`${controls.iconButton} ${controls.danger}`} aria-label={t("删除上游")} onClick={() => onDelete(provider)}><Icon icon={trashIcon} size="1.1em" /></button></TooltipTrigger>
</div> },
];
return <DataTable rows={providers} columns={columns} rowKey={(provider) => provider.provider_id} minWidth={1400} />;
}
@@ -28,12 +28,15 @@ export function CursorModelProvider({ children }: { children: ReactNode }) {
return <ModelsReady.Provider value={models.length > 0}>{children}</ModelsReady.Provider>;
}
export function CursorModelGate({ onAdd, children }: { onAdd: () => void; children: ReactNode }) {
export function CursorModelGate({ busy, previewingImport, onAdd, onImport, children }: { busy: boolean; previewingImport: boolean; onAdd: () => void; onImport: () => void; children: ReactNode }) {
const ready = useContext(ModelsReady);
if (ready) return children;
return <div className={styles.gate}>
<strong>{t("还没有可供 Cursor 使用的模型")}</strong>
<span>{t("Cursor 接管已生效;添加上游及其模型配置后即可使用 BYOK 模型。")}</span>
<button className={controls.primary} onClick={onAdd}>{t("添加模型")}</button>
<span>{t("Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。")}</span>
<div className={styles.gateActions}>
<button className={controls.primary} disabled={busy} onClick={onAdd}>{t("添加模型")}</button>
<button className={controls.secondary} disabled={busy} onClick={onImport}>{previewingImport ? t("读取中…") : t("导入旧版配置")}</button>
</div>
</div>;
}
@@ -0,0 +1,114 @@
import { useEffect, useRef } from "react";
import Sortable from "sortablejs";
import type { Model } from "../../api";
import { Button } from "../ui/Button";
import { Card } from "../ui/Card";
import { Icon } from "../ui/Icon";
import { claudeIcon, dragIcon, openAiIcon } from "../ui/icons";
import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult";
import styles from "./CursorSettings.module.scss";
export function CursorModelCards({
models,
disabled,
testingModelHashes,
testResults,
onTest,
onEdit,
onDuplicate,
onDelete,
onReorder,
}: {
models: Model[];
disabled: boolean;
testingModelHashes: Set<string>;
testResults: Map<string, CursorModelTestState>;
onTest: (model: Model) => void;
onEdit: (model: Model) => void;
onDuplicate: (model: Model) => void;
onDelete: (model: Model) => void;
onReorder: (modelHashes: string[]) => void;
}) {
const grid = useRef<HTMLDivElement>(null);
const sortable = useRef<Sortable | null>(null);
const currentModels = useRef(models);
const reorder = useRef(onReorder);
currentModels.current = models;
reorder.current = onReorder;
useEffect(() => {
if (!grid.current) return;
sortable.current = Sortable.create(grid.current, {
animation: 160,
dataIdAttr: "data-model-hash",
draggable: `.${styles.modelCard}`,
handle: `.${styles.sortHandle}`,
ghostClass: styles.sortGhost,
chosenClass: styles.sortChosen,
dragClass: styles.sortDragging,
forceFallback: true,
fallbackOnBody: true,
fallbackTolerance: 3,
onEnd: (event) => {
const oldIndex = event.oldDraggableIndex ?? event.oldIndex;
const newIndex = event.newDraggableIndex ?? event.newIndex;
if (typeof oldIndex !== "number"
|| typeof newIndex !== "number"
|| oldIndex === newIndex) {
sortable.current?.sort(currentModels.current.map((model) => model.model_hash), false);
return;
}
const reordered = currentModels.current.slice();
const [moved] = reordered.splice(oldIndex, 1);
if (!moved || newIndex < 0 || newIndex > reordered.length) {
sortable.current?.sort(currentModels.current.map((model) => model.model_hash), false);
return;
}
reordered.splice(newIndex, 0, moved);
reorder.current(reordered.map((model) => model.model_hash));
},
});
return () => {
sortable.current?.destroy();
sortable.current = null;
};
}, []);
useEffect(() => {
sortable.current?.option("disabled", disabled);
sortable.current?.sort(models.map((model) => model.model_hash), false);
}, [disabled, models]);
return <div ref={grid} className={styles.modelGrid}>
{models.map((model) => {
const result = testResults.get(model.model_hash);
const testing = testingModelHashes.has(model.model_hash);
return <Card className={styles.modelCard} data-model-hash={model.model_hash} key={model.model_hash}>
<button type="button" className={styles.sortHandle} disabled={disabled} aria-label={t("拖动排序")} title={t("拖动排序")} onClick={(event) => event.stopPropagation()}>
<Icon icon={dragIcon} size="1.25em" />
</button>
<div className={styles.modelCardContent}>
<div className={styles.modelCardTop}>
<div className={styles.modelCardName}>
<strong>{model.display_name}</strong>
<span>{model.model_id}</span>
</div>
<span className={styles.modelTypeBadge}>
<Icon icon={model.type === "anthropic" ? claudeIcon : openAiIcon} />
{model.type === "anthropic" ? "Anthropic" : "OpenAI"}
</span>
</div>
<div className={styles.modelCardTest}>
<CursorModelTestResult state={result} testing={testing} />
</div>
<div className={styles.modelCardActions}>
<Button size="small" disabled={disabled} onClick={() => onTest(model)}>{testing ? t("测试中…") : t("测试")}</Button>
<Button size="small" disabled={disabled} onClick={() => onEdit(model)}>{t("编辑")}</Button>
<Button size="small" disabled={disabled} onClick={() => onDuplicate(model)}>{t("复制")}</Button>
<Button size="small" className={styles.deleteButton} disabled={disabled} onClick={() => onDelete(model)}>{t("删除")}</Button>
</div>
</div>
</Card>;
})}
</div>;
}
@@ -1,101 +1,150 @@
import type { ModelInput, Provider, ProviderInput, ProviderType } from "../../api";
import { FormField, SecretTextInput, TextInput } from "../ui/FormControls";
import type { ModelInput, ModelType } from "../../api";
import { defaultCustomHeadersText } from "../../utils/modelDefaults";
import { Button } from "../ui/Button";
import { Checkbox } from "../ui/Checkbox";
import { FormField, SecretTextInput, TextInput } from "../ui/FormControls";
import { JsonEditor } from "../ui/JsonEditor";
import { Combobox, MultiCombobox, Select } from "../ui/Select";
import { Combobox, Select } from "../ui/Select";
import { Switch } from "../ui/Switch";
import controls from "../ui/Controls.module.scss";
import { TooltipTrigger } from "../ui/TooltipTrigger";
import { claudeIcon, openAiIcon } from "../ui/icons";
import { defaultCustomHeaders, defaultCustomHeadersText } from "../../utils/providerDefaults";
import styles from "./CursorSettings.module.scss";
export type CursorModelDraft = {
providerMode: string;
provider: ProviderInput;
model: ModelInput;
modelIds: string[];
headersText: string;
extraText: string;
customRequestUrl: boolean;
openAIExtraParamsText: string;
customHeadersText: string;
anthropicExtraParamsText: string;
};
export const emptyCursorModelDraft = (): CursorModelDraft => ({
providerMode: "new",
provider: { name: "", provider_type: "openai-responses", base_url: "", api_key: "", custom_headers: { ...defaultCustomHeaders }, extra_params: {} },
model: { model_id: "", display_name: "", endpoint_type: "openai-responses", request_url: "", enabled: true, sort_order: 0, context_window_tokens: null, max_output_tokens: null, reasoning_enabled: true, reasoning_effort: null, supports_image_generation: false },
modelIds: [],
headersText: defaultCustomHeadersText,
extraText: "{}",
customRequestUrl: false,
model: {
sort_order: 0,
display_name: "",
type: "openai",
base_url: "",
use_full_url: false,
api_key: "",
tooltip_data: t("备注"),
model_id: "",
reasoning_effort: null,
openai_endpoint: "/v1/responses",
openai_extra_params_enabled: false,
openai_extra_params: {},
custom_headers_enabled: false,
custom_headers: {},
anthropic_extra_params_enabled: false,
anthropic_extra_params: {},
context_window_tokens: null,
max_completion_tokens: null,
anthropic_max_tokens: null,
anthropic_thinking_effort: "xhigh",
thinking_budget_tokens: null,
},
openAIExtraParamsText: "{}",
customHeadersText: defaultCustomHeadersText,
anthropicExtraParamsText: "{}",
});
export function CursorModelEditor({ draft, providers, editing, modelOptions, discovering, onChange, onDiscover }: {
export function CursorModelEditor({ draft, modelOptions, discovering, onChange, onDiscover }: {
draft: CursorModelDraft;
providers: Provider[];
editing: boolean;
modelOptions: string[];
discovering: boolean;
onChange: (draft: CursorModelDraft) => void;
onDiscover: () => void;
}) {
const setProvider = (patch: Partial<ProviderInput>) => onChange({ ...draft, provider: { ...draft.provider, ...patch } });
const setModel = (patch: Partial<ModelInput>) => onChange({ ...draft, model: { ...draft.model, ...patch } });
const canDiscover = draft.providerMode !== "new"
|| Boolean(draft.provider.base_url.trim() && draft.provider.api_key?.trim());
const selectProvider = (providerMode: string) => {
const endpointType = providerMode === "new"
? draft.provider.provider_type
: providers.find((provider) => String(provider.provider_id) === providerMode)?.provider_type;
onChange({ ...draft, providerMode, model: endpointType ? { ...draft.model, endpoint_type: endpointType } : draft.model });
};
const setEndpointType = (endpoint_type: ProviderType) => onChange({
...draft,
provider: draft.providerMode === "new" ? { ...draft.provider, provider_type: endpoint_type } : draft.provider,
model: { ...draft.model, endpoint_type },
const setType = (type: ModelType) => setModel({
type,
openai_endpoint: type === "openai" ? draft.model.openai_endpoint || "/v1/responses" : "",
anthropic_thinking_effort: type === "anthropic" ? draft.model.anthropic_thinking_effort || "xhigh" : null,
});
const setModelIds = (modelIds: string[]) => onChange({
...draft,
modelIds,
model: {
...draft.model,
model_id: modelIds[0] ?? "",
display_name: modelIds.length === 1 && draft.modelIds.length !== 1 ? modelIds[0] : draft.model.display_name,
},
});
return <div className={styles.editor}>
{!editing && <FormField label={t("上游")} hint={t("选择已有上游,或创建一个新的上游。")}><Select ariaLabel={t("选择上游")} value={draft.providerMode} options={[
{ value: "new", label: t("新建上游") },
...providers.map((provider) => ({ value: String(provider.provider_id), label: provider.name })),
]} onChange={selectProvider} /></FormField>}
const numberValue = (value: string) => value === "" ? null : Math.trunc(Number(value));
const canDiscover = Boolean(draft.model.base_url.trim() && draft.model.api_key.trim());
const requestUrlPlaceholder = draft.model.use_full_url
? draft.model.type === "anthropic"
? "https://api.anthropic.com/v1/messages"
: draft.model.openai_endpoint === "/v1/chat/completions"
? "https://api.openai.com/v1/chat/completions"
: "https://api.openai.com/v1/responses"
: draft.model.type === "anthropic"
? "https://api.anthropic.com"
: "https://api.openai.com";
return <div className={styles.editor}>
<div className={styles.grid}>
{!editing && draft.providerMode === "new" && <>
<FormField label="Base URL" hint={t("模型服务的 API 根地址,例如 https://api.openai.com/v1。")}><TextInput placeholder="例如:https://api.openai.com/v1" value={draft.provider.base_url} onChange={(event) => setProvider({ base_url: event.target.value })} /></FormField>
<FormField label="API Key" hint={t("访问模型服务所需的密钥。")}><SecretTextInput placeholder="例如:sk-xxxxxx" autoComplete="off" value={draft.provider.api_key ?? ""} onChange={(event) => setProvider({ api_key: event.target.value })} /></FormField>
</>}
<FormField label={t("端点类型")} hint={t("默认继承上游,可为当前模型单独修改。")}><Select ariaLabel={t("端点类型")} value={draft.model.endpoint_type} options={[
{ value: "openai-responses", label: "OpenAI Responses", icon: openAiIcon }, { value: "openai-chat", label: "OpenAI Chat", icon: openAiIcon }, { value: "anthropic", label: "Anthropic", icon: claudeIcon },
]} onChange={(endpointType) => setEndpointType(endpointType as ProviderType)} /></FormField>
{(editing || draft.modelIds.length <= 1) && <FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给上游的模型名称。")}><TextInput placeholder="例如:GPT-4.1" value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>}
<FormField label={t("模型名称")} hint={editing ? t("可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。") : t("支持选择或输入多个模型;批量添加时显示名称默认使用对应模型名称。")}>{editing
? <Combobox value={draft.model.model_id} options={modelOptions} placeholder="例如:gpt-4.1" append={<button type="button" className={controls.secondary} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} />
: <MultiCombobox value={draft.modelIds} options={modelOptions} placeholder="例如:gpt-4.1" append={<button type="button" className={controls.secondary} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</button>} onChange={setModelIds} />
}</FormField>
<FormField label={t("自定义上下文")} hint={t("输入 token 数后,将作为额外选项添加到 Cursor 模型的 Context 列表;只有在 Cursor 中选中该选项时才会生效。")}>
<TextInput type="number" min={1} step={1} aria-label={t("自定义上下文 tokens")} placeholder={t("例如:272000")} value={draft.model.context_window_tokens ?? ""} onChange={(event) => setModel({ context_window_tokens: event.target.value === "" ? null : Math.trunc(Number(event.target.value)) })} />
</FormField>
<div className={styles.fullWidth}><Checkbox label={t("自定义请求完整地址")} checked={draft.customRequestUrl} onChange={(customRequestUrl) => onChange({ ...draft, customRequestUrl, model: { ...draft.model, request_url: customRequestUrl ? draft.model.request_url : "" } })} /></div>
{draft.customRequestUrl && <FormField className={styles.fullWidth} label={t("请求完整地址")} hint={t("支持完整 HTTP(S) 地址或以 / 开头、与上游地址组合的相对路径。")}><TextInput placeholder="例如:https://api.example.com/v1/chat/completions" value={draft.model.request_url} onChange={(event) => setModel({ request_url: event.target.value })} /></FormField>}
{!editing && draft.providerMode === "new" && <>
<FormField className={styles.fullWidth} label={t("额外参数 JSON")} hint={t("合并到该上游所有模型的请求体。")}><JsonEditor ariaLabel={t("额外参数 JSON")} value={draft.extraText} onChange={(extraText) => onChange({ ...draft, extraText })} /></FormField>
<FormField className={styles.fullWidth} label={t("自定义 Headers JSON")} hint={t("附加到该上游所有请求的自定义请求头,值必须是字符串。")}><JsonEditor ariaLabel={t("自定义 Headers JSON")} value={draft.headersText} onChange={(headersText) => onChange({ ...draft, headersText })} /></FormField>
</>}
<div className={`${styles.switches} ${styles.fullWidth}`}>
<label><TooltipTrigger label={t("模型是否允许被 Cursor 选择使用。")}><span>{t("启用模型")}</span></TooltipTrigger><Switch label={t("启用模型")} checked={draft.model.enabled} onChange={(enabled) => setModel({ enabled })} /></label>
<label><TooltipTrigger label={t("是否声明模型支持推理能力。")}><span>{t("启用推理")}</span></TooltipTrigger><Switch label={t("启用推理")} checked={draft.model.reasoning_enabled} onChange={(reasoning_enabled) => setModel({ reasoning_enabled })} /></label>
<label><TooltipTrigger label={t("是否声明模型支持图片生成。")}><span>{t("图片生成")}</span></TooltipTrigger><Switch label={t("图片生成")} checked={draft.model.supports_image_generation} onChange={(supports_image_generation) => setModel({ supports_image_generation })} /></label>
<FormField label={t("模型类型")}><Select ariaLabel={t("模型类型")} value={draft.model.type} options={[
{ value: "openai", label: "OpenAI", icon: openAiIcon },
{ value: "anthropic", label: "Anthropic", icon: claudeIcon },
]} onChange={(value) => setType(value as ModelType)} /></FormField>
{draft.model.type === "openai" && <FormField label={t("请求协议")} hint={t("只决定请求与响应的格式,不会改变请求地址。")}> <Select ariaLabel={t("请求协议")} value={draft.model.openai_endpoint} options={[
{ value: "/v1/responses", label: "Responses API" },
{ value: "/v1/chat/completions", label: "Chat Completions API" },
]} onChange={(openai_endpoint) => setModel({ openai_endpoint })} /></FormField>}
<div className={styles.urlField}>
<FormField label={draft.model.use_full_url ? t("完整请求 URL") : t("服务器地址")} hint={draft.model.use_full_url ? t("系统会原样使用此地址,不追加或修改请求路径。") : t("系统会根据请求协议自动追加标准端点路径。")}> <TextInput placeholder={requestUrlPlaceholder} value={draft.model.base_url} onChange={(event) => setModel({ base_url: event.target.value })} /></FormField>
<Checkbox checked={draft.model.use_full_url} label={t("使用完整请求地址")} onChange={(use_full_url) => setModel({ use_full_url })} />
</div>
<FormField label="API Key" hint={t("访问模型服务所需的密钥。")}> <SecretTextInput placeholder="sk-xxxxxx" autoComplete="off" value={draft.model.api_key} onChange={(event) => setModel({ api_key: event.target.value })} /></FormField>
<FormField label={t("模型名称")} hint={t("可以直接输入模型标识,也可以读取接口返回的模型列表。")}> <Combobox value={draft.model.model_id} options={modelOptions} placeholder="gpt-5" append={<Button className={styles.discoverButton} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</Button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /></FormField>
<FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给模型服务的模型名称。")}> <TextInput value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label={t("备注")} hint={t("显示在 Cursor 模型说明中。")}> <TextInput value={draft.model.tooltip_data} onChange={(event) => setModel({ tooltip_data: event.target.value })} /></FormField>
<FormField label={t("上下文窗口 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} value={draft.model.context_window_tokens ?? ""} onChange={(event) => setModel({ context_window_tokens: numberValue(event.target.value) })} /></FormField>
{draft.model.type === "openai" ? <>
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} value={draft.model.max_completion_tokens ?? ""} onChange={(event) => setModel({ max_completion_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("推理强度")}> <Select ariaLabel={t("推理强度")} value={draft.model.reasoning_effort ?? ""} options={effortOptions(true)} onChange={(value) => setModel({ reasoning_effort: value || null })} /></FormField>
</> : <>
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} value={draft.model.anthropic_max_tokens ?? ""} onChange={(event) => setModel({ anthropic_max_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("思考强度")}> <Select ariaLabel={t("思考强度")} value={draft.model.anthropic_thinking_effort ?? "xhigh"} options={effortOptions(false)} onChange={(anthropic_thinking_effort) => setModel({ anthropic_thinking_effort })} /></FormField>
<FormField label={t("思考预算 Token")} hint={t("留空时使用 adaptive thinking。")}> <TextInput type="number" min={1} step={1} value={draft.model.thinking_budget_tokens ?? ""} onChange={(event) => setModel({ thinking_budget_tokens: numberValue(event.target.value) })} /></FormField>
</>}
<ToggleJsonField
label={t("自定义 Headers")}
enabled={draft.model.custom_headers_enabled}
text={draft.customHeadersText}
onEnabledChange={(custom_headers_enabled) => setModel({ custom_headers_enabled })}
onTextChange={(customHeadersText) => onChange({ ...draft, customHeadersText })}
/>
{draft.model.type === "openai" ? <ToggleJsonField
label={t("OpenAI 额外参数")}
enabled={draft.model.openai_extra_params_enabled}
text={draft.openAIExtraParamsText}
onEnabledChange={(openai_extra_params_enabled) => setModel({ openai_extra_params_enabled })}
onTextChange={(openAIExtraParamsText) => onChange({ ...draft, openAIExtraParamsText })}
/> : <ToggleJsonField
label={t("Anthropic 额外参数")}
enabled={draft.model.anthropic_extra_params_enabled}
text={draft.anthropicExtraParamsText}
onEnabledChange={(anthropic_extra_params_enabled) => setModel({ anthropic_extra_params_enabled })}
onTextChange={(anthropicExtraParamsText) => onChange({ ...draft, anthropicExtraParamsText })}
/>}
</div>
</div>;
}
function ToggleJsonField({ label, enabled, text, onEnabledChange, onTextChange }: {
label: string;
enabled: boolean;
text: string;
onEnabledChange: (enabled: boolean) => void;
onTextChange: (text: string) => void;
}) {
return <div className={`${styles.fullWidth} ${styles.jsonOption}`}>
<label><span>{label}</span><Switch label={label} checked={enabled} onChange={onEnabledChange} /></label>
{enabled && <JsonEditor ariaLabel={label} value={text} onChange={onTextChange} />}
</div>;
}
function effortOptions(optional: boolean) {
return [
...(optional ? [{ value: "", label: t("不设置") }] : []),
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "xhigh", label: "Extra High" },
{ value: "max", label: "Max" },
];
}
@@ -0,0 +1,64 @@
@use "../../styles/typography" as type;
.root {
min-width: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 7px 9px;
border: 1px solid transparent;
border-radius: 6px;
font-size: type.$font-size-xs;
}
.idle {
color: var(--vscode-descriptionForeground);
background: color-mix(in srgb, var(--vscode-foreground) 6%, transparent);
border-color: color-mix(in srgb, var(--vscode-foreground) 12%, transparent);
}
.testing {
color: var(--vscode-textLink-foreground);
background: color-mix(in srgb, var(--vscode-textLink-foreground) 10%, transparent);
border-color: color-mix(in srgb, var(--vscode-textLink-foreground) 24%, transparent);
}
.success {
color: var(--vscode-testing-iconPassed, #73c991);
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 11%, transparent);
border-color: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 28%, transparent);
}
.error {
color: var(--vscode-errorForeground, #f48771);
background: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 10%, transparent);
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 28%, transparent);
}
.summary {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.details {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
color: currentColor;
background: transparent;
border: 0;
font: inherit;
cursor: help;
white-space: nowrap;
&:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 2px;
}
}
@@ -2,13 +2,16 @@ import type { ModelConnectivityResult } from "../../api";
import { Icon } from "../ui/Icon";
import { TooltipTrigger } from "../ui/TooltipTrigger";
import { informationOutlineIcon } from "../ui/icons";
import styles from "./CursorSettings.module.scss";
import styles from "./CursorModelTestResult.module.scss";
export type CursorModelTestState =
| { status: "success"; result: ModelConnectivityResult }
| { status: "error"; error: string };
export function CursorModelTestResult({ state }: { state: CursorModelTestState }) {
export function CursorModelTestResult({ state, testing = false }: { state?: CursorModelTestState; testing?: boolean }) {
if (testing) return <div className={`${styles.root} ${styles.testing}`}><span className={styles.summary}>{t("测试中…")}</span></div>;
if (!state) return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("未测试")}</span></div>;
const success = state.status === "success";
const summary = success
? t("速度:{speed} tokens/s", { speed: formatSpeed(state.result.tokens_per_second) })
@@ -24,9 +27,9 @@ export function CursorModelTestResult({ state }: { state: CursorModelTestState }
})
: t("测试失败:{error}", { error: state.error });
return <div className={`${styles.testResult} ${success ? styles.testSuccess : styles.testError}`}>
<span className={styles.testResultText}>{summary}</span>
<TooltipTrigger label={detail}><span className={styles.testResultHint} tabIndex={0}><Icon icon={informationOutlineIcon} size="1.1em" /></span></TooltipTrigger>
return <div className={`${styles.root} ${success ? styles.success : styles.error}`}>
<span className={styles.summary}>{summary}</span>
<TooltipTrigger label={detail}><button type="button" className={styles.details}>{t("查看详情")}<Icon icon={informationOutlineIcon} size="1.1em" /></button></TooltipTrigger>
</div>;
}
@@ -5,6 +5,12 @@
gap: 16px;
}
.gateActions {
display: flex;
align-items: center;
gap: 8px;
}
.gate {
min-height: 250px;
display: flex;
@@ -29,103 +35,123 @@
}
}
.groups {
display: flex;
flex-direction: column;
.modelGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 12px;
}
.providerTitle {
.modelCard {
position: relative;
padding: 16px;
}
.modelCardContent {
height: 150px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 12px;
}
.modelCardTop {
min-width: 0;
display: flex;
flex-direction: row;
align-items: center;
gap: 3px;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.modelCardName {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
strong,
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.models {
display: flex;
flex-direction: column;
}
.modelRow {
min-height: 54px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
padding: 8px 14px;
// border-top: 1px solid var(--vscode-sideBar-border);
}
.modelName {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--vscode-foreground);
font-size: type.$font-size-base;
}
small {
span {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.badge {
padding: 2px 7px;
color: var(--vscode-badge-foreground);
background: var(--vscode-badge-background);
.modelTypeBadge {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 7px;
color: var(--vscode-foreground);
border: 1px solid var(--vscode-sideBar-border);
border-radius: 999px;
font-size: type.$font-size-2xs;
}
.rowActions {
display: flex;
gap: 2px;
}
.testResult {
min-width: 0;
max-width: 220px;
.modelCardTest {
min-height: 31px;
display: flex;
align-items: center;
gap: 5px;
font-size: type.$font-size-xs;
}
.testSuccess {
color: var(--vscode-testing-iconPassed, #73c991);
.modelCardActions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.testError {
.deleteButton:hover {
color: var(--vscode-errorForeground, #f48771);
}
.testResultText {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.testResultHint {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
color: currentColor;
cursor: help;
&:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 2px;
.sortHandle {
position: absolute;
top: 8px;
left: 8px;
z-index: 1;
width: 30px;
height: 30px;
display: grid;
place-items: center;
padding: 0;
color: transparent;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
opacity: 0;
cursor: grab;
touch-action: none;
transition: opacity 160ms ease, color 160ms ease, border-color 160ms ease, background-color 160ms ease;
&:focus-visible,
.modelCard:hover & {
color: var(--vscode-foreground);
background: var(--vscode-list-hoverBackground);
border-color: var(--vscode-sideBar-border);
opacity: 1;
}
&:active {
cursor: grabbing;
}
&:disabled {
cursor: not-allowed;
opacity: 0.3;
}
}
.sortGhost {
opacity: 0.4;
}
.sortChosen {
border-color: var(--vscode-focusBorder);
}
.sortDragging {
cursor: grabbing;
}
.editor {
display: flex;
flex-direction: column;
gap: 14px;
}
.editorTestResult {
margin-top: 14px;
}
.command {
margin: 0;
padding: 10px;
@@ -145,6 +171,17 @@
width: 100%;
grid-column: 1 / -1;
}
.discoverButton {
min-height: 34px;
height: 34px;
flex: 0 0 auto;
}
.urlField {
min-width: 0;
display: flex;
flex-direction: column;
gap: 7px;
}
.switches {
display: flex;
flex-direction: column;
@@ -160,6 +197,21 @@
}
}
.jsonOption {
display: flex;
flex-direction: column;
gap: 8px;
> label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
@media (max-width: 720px) {
.grid {
grid-template-columns: 1fr;
@@ -1,51 +0,0 @@
@use "../../styles/typography" as type;
.title {
min-width: 0;
display: flex;
align-items: center;
gap: 5px;
}
.row {
min-height: 62px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 12px 16px;
& + & {
border-top: 1px solid var(--vscode-sideBar-border);
}
}
.description {
min-width: 0;
display: grid;
gap: 5px;
small {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.selectControl,
.addressInput {
width: min(300px, 44%);
flex: 0 0 auto;
}
@media (max-width: 620px) {
.row {
align-items: stretch;
flex-direction: column;
gap: 10px;
}
.selectControl,
.addressInput {
width: 100%;
}
}
@@ -1,53 +0,0 @@
import cursorIconUrl from "../../assets/icons/cursor.svg";
import type { TabMode, TabSettings } from "../../api";
import { Button } from "../ui/Button";
import { TextInput } from "../ui/FormControls";
import { Icon } from "../ui/Icon";
import { Select } from "../ui/Select";
import { TitledCard } from "../ui/TitledCard";
import styles from "./TabSettingsCard.module.scss";
export function TabSettingsCard({ settings, saving, onChange, onSave }: {
settings: TabSettings;
saving: boolean;
onChange: (settings: TabSettings) => void;
onSave: () => void;
}) {
return <TitledCard
title={<div className={styles.title}><Icon src={cursorIconUrl} size="1.1em" /><span>{t("TAB 设置")}</span></div>}
action={<Button size="small" variant="primary" disabled={saving} onClick={onSave}>{saving ? t("保存中…") : t("保存")}</Button>}
>
<div className={styles.row}>
<div className={styles.description}>
<strong>{t("TAB 选择")}</strong>
<small>{t("控制 Cursor TAB 相关接口的连接方式。")}</small>
</div>
<div className={styles.selectControl}>
<Select
value={settings.mode}
ariaLabel={t("TAB 选择")}
options={[
{ value: "public", label: t("使用公益服务") },
{ value: "direct", label: t("直连") },
{ value: "custom", label: t("自定义") },
]}
onChange={(mode) => onChange({ ...settings, mode: mode as TabMode })}
/>
</div>
</div>
{settings.mode === "custom" && <div className={styles.row}>
<div className={styles.description}>
<strong>{t("TAB 服务地址")}</strong>
<small>{t("原接口路径会追加到此服务地址。")}</small>
</div>
<TextInput
className={styles.addressInput}
value={settings.address}
placeholder="https://tab.leokun.cn"
aria-label={t("TAB 服务地址")}
onChange={(event) => onChange({ ...settings, address: event.target.value })}
onKeyDown={(event) => { if (event.key === "Enter") onSave(); }}
/>
</div>}
</TitledCard>;
}
@@ -0,0 +1,92 @@
@use "../../styles/typography" as type;
.content {
display: grid;
gap: 14px;
}
.source {
display: grid;
gap: 6px;
code {
overflow-wrap: anywhere;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.counts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
> div {
display: grid;
gap: 3px;
padding: 10px;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
border-radius: 5px;
}
small {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.models {
display: grid;
gap: 6px;
}
.model {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 0;
padding: 9px 10px;
background: var(--vscode-list-inactiveSelectionBackground);
border-radius: 5px;
> div {
display: grid;
min-width: 0;
gap: 3px;
}
small {
overflow: hidden;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
text-overflow: ellipsis;
white-space: nowrap;
}
> span {
flex: 0 0 auto;
font-size: type.$font-size-xs;
}
}
.new {
color: var(--vscode-testing-iconPassed, #73c991);
}
.existing,
.hint {
color: var(--vscode-descriptionForeground);
}
.hint {
font-size: type.$font-size-xs;
}
@media (max-width: 680px) {
.model {
align-items: stretch;
flex-direction: column;
}
}
@@ -0,0 +1,92 @@
import { useState, type ReactNode } from "react";
import { api, type LegacyModelImportPreview } from "../../api";
import { appStore } from "../../store/appStore";
import { ConfirmDialog } from "../ui/ConfirmDialog";
import { useMessage } from "../ui/message";
import styles from "./LegacyModelImport.module.scss";
type LegacyModelImportControl = {
busy: boolean;
previewing: boolean;
open: () => void;
};
export function LegacyModelImport({ children }: { children: (control: LegacyModelImportControl) => ReactNode }) {
const message = useMessage();
const [preview, setPreview] = useState<LegacyModelImportPreview | null>(null);
const [previewing, setPreviewing] = useState(false);
const [importing, setImporting] = useState(false);
const open = async () => {
try {
setPreviewing(true);
setPreview(await api.previewV0049Models());
} catch (cause) {
message(errorText(cause));
} finally {
setPreviewing(false);
}
};
const confirm = async () => {
try {
setImporting(true);
const result = await appStore.importV0049Models();
if (!result) {
const error = appStore.getSnapshot().error;
if (error) message(error);
return;
}
setPreview(null);
message(result.imported > 0
? t("导入完成:新增 {imported} 个模型,跳过 {skipped} 个已存在模型", { imported: result.imported, skipped: result.skipped })
: t("配置中的 {count} 个模型均已存在,无需重复导入", { count: result.skipped }));
} catch (cause) {
message(errorText(cause));
} finally {
setImporting(false);
}
};
return <>
{children({ busy: previewing || importing, previewing, open: () => void open() })}
<ConfirmDialog
open={preview !== null}
title={t("确认导入旧版模型配置")}
busy={importing}
wide
cancelLabel={t("取消")}
confirmLabel={t("确认导入")}
onCancel={() => setPreview(null)}
onConfirm={() => void confirm()}
>
{preview && <div className={styles.content}>
<div className={styles.source}>
<strong>{t("配置文件")}</strong>
<code>{preview.source}</code>
</div>
<div className={styles.counts}>
<div><strong>{preview.total}</strong><small>{t("配置模型")}</small></div>
<div><strong>{preview.new_models}</strong><small>{t("将新增")}</small></div>
<div><strong>{preview.existing_models}</strong><small>{t("已存在")}</small></div>
</div>
<div className={styles.models}>
{preview.models.map((model) => <div className={styles.model} key={model.model_hash}>
<div>
<strong>{model.display_name}</strong>
<small>{model.model_id} · {model.type === "openai" ? "OpenAI" : "Anthropic"}</small>
</div>
<span className={model.existing ? styles.existing : styles.new}>
{model.existing ? t("已存在,跳过") : t("新增")}
</span>
</div>)}
</div>
<small className={styles.hint}>{t("重复导入相同配置不会创建重复模型;已经存在的模型会自动跳过。")}</small>
</div>}
</ConfirmDialog>
</>;
}
function errorText(cause: unknown) {
return cause instanceof Error ? cause.message : String(cause);
}
@@ -11,22 +11,19 @@ import styles from "./OverviewTimeRangeFilter.module.scss";
export type OverviewRangePreset = "ten-minutes" | "hour" | "today" | "week" | "month" | "custom";
export function OverviewTimeRangeFilter({ value, customOpen, customStart, customEnd, modelOptions, providerOptions, selectedModels, selectedProviders, busy, onSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onSelectedProvidersChange, onCustomApply, onRefresh }: {
export function OverviewTimeRangeFilter({ value, customOpen, customStart, customEnd, modelOptions, selectedModels, busy, onSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onCustomApply, onRefresh }: {
value: OverviewRangePreset;
customOpen: boolean;
customStart: string;
customEnd: string;
modelOptions: MultiSelectOption[];
providerOptions: MultiSelectOption[];
selectedModels: string[];
selectedProviders: string[];
busy: boolean;
onSelect: (value: Exclude<OverviewRangePreset, "custom">) => void;
onCustomOpenChange: (open: boolean) => void;
onCustomStartChange: (value: string) => void;
onCustomEndChange: (value: string) => void;
onSelectedModelsChange: (value: string[]) => void;
onSelectedProvidersChange: (value: string[]) => void;
onCustomApply: () => void;
onRefresh: () => void;
}) {
@@ -109,7 +106,6 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom
<label><span>{t("开始时间")}</span><input type="text" placeholder={t("如:2026-08-23 09:00、1小时前")} value={customStart} onChange={(event) => onCustomStartChange(event.target.value)} /></label>
<label><span>{t("结束时间")}</span><input type="text" placeholder={t("如:现在、2026-08-23 18:00")} value={customEnd} onChange={(event) => onCustomEndChange(event.target.value)} /></label>
<div className={styles.filterRow}><MultiSelect label={t("模型")} value={selectedModels} options={modelOptions} onChange={onSelectedModelsChange} /></div>
<div className={styles.filterRow}><MultiSelect label={t("上游")} value={selectedProviders} options={providerOptions} onChange={onSelectedProvidersChange} /></div>
<div className={styles.popoverActions}>
<button type="button" className={controls.secondary} onClick={() => onCustomOpenChange(false)}>{t("取消")}</button>
<button type="button" className={controls.primary} disabled={!customValid} onClick={onCustomApply}>{t("应用")}</button>
@@ -0,0 +1,78 @@
@use "../../styles/typography" as type;
.title {
min-width: 0;
display: flex;
align-items: center;
gap: 5px;
}
.content {
display: grid;
gap: 14px;
padding: 16px;
}
.row {
min-width: 0;
min-height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.description {
min-width: 0;
display: grid;
gap: 5px;
small {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.control {
width: min(420px, 62%);
}
.value {
min-width: 0;
max-width: 70%;
color: var(--vscode-descriptionForeground);
overflow-wrap: anywhere;
text-align: right;
}
.headerAction {
padding: 4px;
color: var(--vscode-textLink-foreground);
background: transparent;
border: 0;
font-size: type.$font-size-xs;
&:hover { color: var(--vscode-textLink-activeForeground); }
&:disabled { opacity: .5; }
}
.actionGroup {
display: flex;
align-items: center;
gap: 6px;
}
@media (max-width: 680px) {
.row {
align-items: stretch;
flex-direction: column;
gap: 10px;
}
.control,
.value {
width: 100%;
max-width: none;
text-align: left;
}
}
@@ -0,0 +1,90 @@
import cursorIconUrl from "../../assets/icons/cursor.svg";
import type { TabMode, TabSettings } from "../../api";
import { Button } from "../ui/Button";
import { TextInput } from "../ui/FormControls";
import { Icon } from "../ui/Icon";
import { Select } from "../ui/Select";
import { TitledCard } from "../ui/TitledCard";
import styles from "./TabSettingsCard.module.scss";
export function TabSettingsCard({
settings,
draft,
editing,
saving,
onDraftChange,
onEdit,
onCancel,
onSave,
}: {
settings: TabSettings | null;
draft: TabSettings;
editing: boolean;
saving: boolean;
onDraftChange: (settings: TabSettings) => void;
onEdit: () => void;
onCancel: () => void;
onSave: () => void;
}) {
const modeLabel = (mode: TabMode) => {
if (mode === "public") return t("使用公益服务");
if (mode === "direct") return t("直连");
return t("自定义");
};
const action = editing ? (
<div className={styles.actionGroup}>
<Button size="small" disabled={saving} onClick={onCancel}>{t("取消")}</Button>
<Button variant="primary" size="small" disabled={saving} onClick={onSave}>{saving ? t("保存中…") : t("保存")}</Button>
</div>
) : (
<button type="button" className={styles.headerAction} disabled={!settings} onClick={onEdit}>{t("编辑")}</button>
);
return <TitledCard
title={<div className={styles.title}><Icon src={cursorIconUrl} size="1.1em" /><span>{t("TAB 设置")}</span></div>}
action={action}
>
<div className={styles.content}>
{editing ? <>
<div className={styles.row}>
<div className={styles.description}>
<strong>{t("TAB 选择")}</strong>
<small>{t("控制 Cursor TAB 相关接口的连接方式。")}</small>
</div>
<div className={styles.control}><Select
value={draft.mode}
ariaLabel={t("TAB 选择")}
options={[
{ value: "public", label: t("使用公益服务") },
{ value: "direct", label: t("直连") },
{ value: "custom", label: t("自定义") },
]}
onChange={(mode) => onDraftChange({ ...draft, mode: mode as TabMode })}
/></div>
</div>
{draft.mode === "custom" && <div className={styles.row}>
<div className={styles.description}>
<strong>{t("TAB 服务地址")}</strong>
<small>{t("原接口路径会追加到此服务地址。")}</small>
</div>
<div className={styles.control}><TextInput
value={draft.address}
placeholder="https://tab.leokun.cn"
aria-label={t("TAB 服务地址")}
onChange={(event) => onDraftChange({ ...draft, address: event.target.value })}
onKeyDown={(event) => { if (event.key === "Enter") onSave(); }}
/></div>
</div>}
</> : <>
<div className={styles.row}>
<strong>{t("TAB 选择")}</strong>
<span className={styles.value}>{settings ? modeLabel(settings.mode) : t("加载中…")}</span>
</div>
{settings?.mode === "custom" && <div className={styles.row}>
<strong>{t("TAB 服务地址")}</strong>
<span className={styles.value}>{settings.address}</span>
</div>}
</>}
</div>
</TitledCard>;
}
+4 -5
View File
@@ -1,12 +1,11 @@
import type { ElementType, ReactNode } from "react";
import type { ElementType, HTMLAttributes, ReactNode } from "react";
import styles from "./Card.module.scss";
type CardProps = {
type CardProps = HTMLAttributes<HTMLElement> & {
as?: ElementType;
className?: string;
children: ReactNode;
};
export function Card({ as: Component = "div", className, children }: CardProps) {
return <Component className={[styles.root, className].filter(Boolean).join(" ")}>{children}</Component>;
export function Card({ as: Component = "div", className, children, ...props }: CardProps) {
return <Component {...props} className={[styles.root, className].filter(Boolean).join(" ")}>{children}</Component>;
}
@@ -0,0 +1,33 @@
import { useId, type ReactNode } from "react";
import { Modal } from "./Modal";
export function ConfirmDialog({ id, open, title, children, busy, wide, cancelLabel = t("取消"), confirmLabel = t("确认"), onCancel, onConfirm }: {
id?: string;
open: boolean;
title: string;
children: ReactNode;
busy?: boolean;
wide?: boolean;
cancelLabel?: string;
confirmLabel?: string;
onCancel: () => void;
onConfirm: () => void;
}) {
const contentId = useId();
return <Modal
id={id}
open={open}
title={title}
busy={busy}
wide={wide}
role="alertdialog"
ariaDescribedBy={contentId}
initialFocus="submit"
closeLabel={cancelLabel}
submitLabel={confirmLabel}
onClose={onCancel}
onSubmit={onConfirm}
>
<div id={contentId}>{children}</div>
</Modal>;
}
@@ -52,6 +52,12 @@
width: min(1080px, calc(100vw - 48px));
}
.banner {
flex: 0 0 auto;
padding: 12px 18px;
border-bottom: 1px solid var(--vscode-editorWidget-border);
}
.body {
flex: 0 1 auto;
min-height: 0;
+70 -8
View File
@@ -1,36 +1,98 @@
import { useEffect, useRef, type ReactNode } from "react";
import { useEffect, useId, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { ScrollableContent } from "../virtual/ScrollableContent";
import controls from "./Controls.module.scss";
import styles from "./Modal.module.scss";
export function Modal({ id, open, title, children, busy, wide, onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存") }: { id?: string; open: boolean; title: string; children: ReactNode; busy?: boolean; wide?: boolean; onClose: () => void; onSubmit?: () => void; secondaryAction?: ReactNode; closeLabel?: string; submitLabel?: string }) {
type ModalProps = {
id?: string;
open: boolean;
title: string;
children: ReactNode;
banner?: ReactNode;
busy?: boolean;
wide?: boolean;
role?: "dialog" | "alertdialog";
ariaDescribedBy?: string;
initialFocus?: "first" | "submit";
onClose: () => void;
onSubmit?: () => void;
secondaryAction?: ReactNode;
closeLabel?: string;
submitLabel?: string;
};
const focusableSelector = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
function focusableElements(root: HTMLElement) {
return [...root.querySelectorAll<HTMLElement>(focusableSelector)]
.filter((element) => element.getClientRects().length > 0);
}
export function Modal({ id, open, title, children, banner, busy, wide, role = "dialog", ariaDescribedBy, initialFocus = "first", onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存") }: ModalProps) {
const dialog = useRef<HTMLDivElement>(null);
const submitButton = useRef<HTMLButtonElement>(null);
const closeRef = useRef(onClose);
const busyRef = useRef(Boolean(busy));
const titleId = useId();
closeRef.current = onClose;
busyRef.current = Boolean(busy);
useEffect(() => {
if (!open) return;
const previous = document.activeElement as HTMLElement | null;
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape" && !busyRef.current) closeRef.current(); };
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !busyRef.current) {
event.preventDefault();
closeRef.current();
return;
}
if (event.key !== "Tab" || event.defaultPrevented || !dialog.current) return;
const focusable = focusableElements(dialog.current);
if (!focusable.length) {
event.preventDefault();
dialog.current.focus();
return;
}
const first = focusable[0];
const last = focusable.at(-1)!;
const active = document.activeElement;
if (event.shiftKey && (active === first || !dialog.current.contains(active))) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (active === last || !dialog.current.contains(active))) {
event.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKey);
requestAnimationFrame(() => dialog.current?.querySelector<HTMLElement>("input,button")?.focus());
const focusFrame = requestAnimationFrame(() => {
const target = initialFocus === "submit" ? submitButton.current : focusableElements(dialog.current!)[0];
(target ?? dialog.current)?.focus();
});
return () => {
cancelAnimationFrame(focusFrame);
document.removeEventListener("keydown", onKey);
if (previous && document.contains(previous)) previous.focus();
};
}, [open]);
}, [initialFocus, open]);
if (!open) return null;
return createPortal(<div className={styles.mask}>
<div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" />
<div id={id} ref={dialog} className={[styles.dialog, wide && styles.wide].filter(Boolean).join(" ")} role="dialog" aria-modal="true" aria-label={title}>
<header>{title}</header>
<div id={id} ref={dialog} className={[styles.dialog, wide && styles.wide].filter(Boolean).join(" ")} role={role} aria-modal="true" aria-labelledby={titleId} aria-describedby={ariaDescribedBy} tabIndex={-1}>
<header id={titleId}>{title}</header>
{banner && <div className={styles.banner}>{banner}</div>}
<ScrollableContent alwaysShowVertical className={styles.body} viewportClassName={styles.bodyViewport} contentClassName={styles.bodyContent}>{children}</ScrollableContent>
<footer>
<button type="button" className={controls.primary} disabled={busy} onClick={onClose}>{closeLabel}</button>
{secondaryAction}
{onSubmit && <button type="button" className={controls.primary} disabled={busy} onClick={onSubmit}>{busy ? t("处理中…") : submitLabel}</button>}
{onSubmit && <button ref={submitButton} type="button" className={controls.primary} disabled={busy} onClick={onSubmit}>{busy ? t("处理中…") : submitLabel}</button>}
</footer>
</div>
</div>, document.body);
+2 -1
View File
@@ -5,7 +5,7 @@ import styles from "./Tooltip.module.scss";
export type TooltipAnchor = VirtualElement;
export function Tooltip({ anchor, children }: { anchor: TooltipAnchor | null; children: ReactNode }) {
export function Tooltip({ id, anchor, children }: { id?: string; anchor: TooltipAnchor | null; children: ReactNode }) {
const tooltipRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<{ left: number; top: number } | null>(null);
@@ -32,6 +32,7 @@ export function Tooltip({ anchor, children }: { anchor: TooltipAnchor | null; ch
return createPortal(
<div
ref={tooltipRef}
id={id}
className={styles.root}
role="tooltip"
style={{ left: position?.left ?? 0, top: position?.top ?? 0, visibility: position ? "visible" : "hidden" }}
@@ -1,4 +1,4 @@
import { cloneElement, useState, type FocusEventHandler, type MouseEventHandler, type ReactElement } from "react";
import { cloneElement, useEffect, useId, useState, type FocusEventHandler, type PointerEventHandler, type ReactElement } from "react";
import { Tooltip, type TooltipAnchor } from "./Tooltip";
function anchorFor(element: HTMLElement): TooltipAnchor {
@@ -6,26 +6,43 @@ function anchorFor(element: HTMLElement): TooltipAnchor {
}
type TriggerProps = {
onMouseEnter?: MouseEventHandler<HTMLElement>;
onMouseLeave?: MouseEventHandler<HTMLElement>;
"aria-describedby"?: string;
onPointerMove?: PointerEventHandler<HTMLElement>;
onPointerLeave?: PointerEventHandler<HTMLElement>;
onPointerDown?: PointerEventHandler<HTMLElement>;
onFocus?: FocusEventHandler<HTMLElement>;
onBlur?: FocusEventHandler<HTMLElement>;
};
export function TooltipTrigger({ label, children }: { label: string; children: ReactElement<TriggerProps> }) {
const [anchor, setAnchor] = useState<TooltipAnchor | null>(null);
const tooltipId = useId();
useEffect(() => {
if (!anchor) return;
const close = (event: KeyboardEvent) => { if (event.key === "Escape") setAnchor(null); };
document.addEventListener("keydown", close);
return () => document.removeEventListener("keydown", close);
}, [anchor]);
const trigger = cloneElement(children, {
onMouseEnter: (event) => {
children.props.onMouseEnter?.(event);
setAnchor(anchorFor(event.currentTarget));
"aria-describedby": [children.props["aria-describedby"], anchor ? tooltipId : null].filter(Boolean).join(" ") || undefined,
onPointerMove: (event) => {
children.props.onPointerMove?.(event);
if (event.pointerType !== "touch" && event.buttons === 0) {
const element = event.currentTarget;
setAnchor((current) => current ?? anchorFor(element));
}
},
onMouseLeave: (event) => {
children.props.onMouseLeave?.(event);
onPointerLeave: (event) => {
children.props.onPointerLeave?.(event);
setAnchor(null);
},
onPointerDown: (event) => {
children.props.onPointerDown?.(event);
setAnchor(null);
},
onFocus: (event) => {
children.props.onFocus?.(event);
setAnchor(anchorFor(event.currentTarget));
setAnchor(event.currentTarget.matches(":focus-visible") ? anchorFor(event.currentTarget) : null);
},
onBlur: (event) => {
children.props.onBlur?.(event);
@@ -35,6 +52,6 @@ export function TooltipTrigger({ label, children }: { label: string; children: R
return <>
{trigger}
<Tooltip anchor={anchor}>{label}</Tooltip>
<Tooltip id={tooltipId} anchor={anchor}>{label}</Tooltip>
</>;
}
+1
View File
@@ -32,6 +32,7 @@ export const informationOutlineIcon = icon('<path fill="currentColor" d="M11 9h2
export const cilBadgeIcon = icon('<path fill="currentColor" d="m328.375 384l3.698 74.999l-75.862-52.719l-76.287 52.769L183.625 384h-32.039l-5.522 112h36.692l73.413-50.78L329.242 496h36.694l-5.522-112zm87.034-229.086l-2.194-48.054L372.7 80.933l-25.932-40.519l-48.055-2.2L256 16.093l-42.713 22.126l-48.055 2.2L139.3 80.933L98.785 106.86l-2.194 48.054l-22.127 42.714l22.127 42.715l2.2 48.053l40.509 25.927l25.928 40.52l48.055 2.195L256 379.164l42.713-22.126l48.055-2.195l25.928-40.52l40.518-25.923l2.195-48.053l22.127-42.715Zm-31.646 76.949L382 270.377l-32.475 20.78l-20.78 32.475l-38.515 1.76L256 343.125l-34.234-17.733l-38.515-1.76l-20.78-32.475L130 270.377l-1.759-38.514l-17.741-34.235l17.737-34.228L130 124.88l32.471-20.78l20.78-32.474l38.515-1.76L256 52.132l34.234 17.733l38.515 1.76l20.78 32.474L382 124.88l1.759 38.515l17.741 34.233Z"/>', 512, 512); // cil:badge
export const refreshIcon = icon('<path fill="currentColor" d="M17.65 6.35A7.96 7.96 0 0 0 12 4a8 8 0 0 0-8 8a8 8 0 0 0 8 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18a6 6 0 0 1-6-6a6 6 0 0 1 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4z"/>'); // mdi:refresh
export const trashIcon = icon('<path fill="currentColor" d="M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6zM8 9h8v10H8zm7.5-5l-1-1h-5l-1 1H5v2h14V4z"/>'); // mdi:delete-outline
export const dragIcon = icon('<path fill="currentColor" d="M7 4h2v2H7zm8 0h2v2h-2zM7 11h2v2H7zm8 0h2v2h-2zM7 18h2v2H7zm8 0h2v2h-2z"/>'); // mdi:drag-vertical
export const checkIcon = icon('<path fill="currentColor" d="M21 7L9 19l-5.5-5.5l1.41-1.41L9 16.17L19.59 5.59z"/>');
export const chevronDownIcon = icon('<path fill="currentColor" d="M7.41 8.58L12 13.17l4.59-4.59L18 10l-6 6l-6-6z"/>');
export const chevronLeftIcon = icon('<path fill="currentColor" d="M15.41 16.58L10.83 12l4.58-4.59L14 6l-6 6l6 6z"/>');