mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-05 20:44:07 +08:00
merge old config
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -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"/>');
|
||||
|
||||
Reference in New Issue
Block a user