refactor: UI

This commit is contained in:
leookun
2026-08-07 01:50:20 +08:00
parent 5bd39ed317
commit 225de8bb85
39 changed files with 1734 additions and 1604 deletions
@@ -196,6 +196,7 @@ onBeforeUnmount(() => {
<div
v-show="visible"
class="modal-mask-layer fixed inset-0 z-999 flex items-center justify-center bg-black/50 p-4"
@click.self="closeAd"
>
<Transition name="ad-frame">
<iframe
@@ -2,6 +2,7 @@
import Button from "@/components/ui/Button.vue";
import Card from "@/components/ui/Card.vue";
import Tooltip from "@/components/ui/Tooltip.vue";
import { useMessage } from "@/composables/useMessage";
import { showModal } from "@/composables/useModal";
import {
disconnectCursorAccount,
@@ -13,6 +14,7 @@ import { Browser } from "@wailsio/runtime";
import { computed, onMounted, onUnmounted, ref } from "vue";
const CURSOR_ACCOUNT_CONTRIBUTOR_URL = "https://github.com/aike0210";
const message = useMessage();
const cursorAccountStatus = ref({
state: "signed_out",
@@ -35,18 +37,16 @@ const cursorAccountStateText = computed(() => {
return "未连接";
});
async function showActionError(title, error) {
await showModal({
title,
content: String(error || "服务错误").trim() || "服务错误",
});
function showActionError(title, error) {
const detail = String(error || "服务错误").trim() || "服务错误";
message(`${title}${detail}`);
}
async function handleOpenContributor() {
try {
await Browser.OpenURL(CURSOR_ACCOUNT_CONTRIBUTOR_URL);
} catch (error) {
await showActionError("打开贡献者主页失败", toUserError(error));
showActionError("打开贡献者主页失败", toUserError(error));
}
}
@@ -59,7 +59,7 @@ async function handleCursorAccountLogin() {
try {
cursorAccountStatus.value = await startCursorAccountLogin();
} catch (error) {
await showActionError("登录失败", toUserError(error));
showActionError("登录失败", toUserError(error));
await refreshCursorAccountStatus().catch(() => {});
} finally {
cursorAccountBusy.value = false;
@@ -80,7 +80,7 @@ async function handleCursorAccountDisconnect() {
try {
cursorAccountStatus.value = await disconnectCursorAccount();
} catch (error) {
await showActionError("退出登录失败", toUserError(error));
showActionError("退出登录失败", toUserError(error));
} finally {
cursorAccountBusy.value = false;
}
@@ -51,7 +51,6 @@ const props = defineProps({
type: Object,
default: () => createEmptyModelAdapter(),
},
errorMessage: { type: String, default: "" },
});
const emit = defineEmits(["cancel", "save"]);
@@ -286,13 +285,6 @@ function handleSave() {
/>
</label>
<div
v-if="errorMessage"
class="mt-4 rounded-[8px] border border-[#4b1d1d] bg-[#2a1313] px-3 py-2 text-sm text-[#fca5a5]"
>
{{ errorMessage }}
</div>
<div class="mt-5 flex justify-end gap-2">
<Button variant="default" @click="handleCancel">取消</Button>
<Button variant="primary" @click="handleSave">保存</Button>
@@ -44,7 +44,7 @@ const summaryText = computed(() => {
return "测试中...";
}
if (normalizedStatus.value === "error") {
return "测试失败";
return "测试失败,请查看原始信息";
}
return props.emptyText;
});
@@ -100,16 +100,16 @@ const summaryClass = computed(() => {
</script>
<template>
<div class="rounded-[8px] border px-3 py-3" :class="panelClass">
<div class="rounded-[8px] border-none px-3 py-3" :class="panelClass">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<div
:class="compact ? 'text-[11px] uppercase tracking-[0.08em] text-[#666]' : 'text-sm font-medium text-white'"
:class="compact ? 'text-[11px] uppercase tracking-[0.08em] text-white/80' : 'text-[11px] uppercase tracking-[0.08em] text-white/80'"
>
{{ title }}
</div>
<div v-if="rawResponseText" class="center-row gap-1 text-[11px] text-[#8f8f8f]">
<div v-if="rawResponseText" class="center-row gap-1 text-[11px] text-white/80">
<span>原始返回</span>
<Tooltip :content="rawResponseText" copyable />
</div>
@@ -118,12 +118,7 @@ const summaryClass = computed(() => {
{{ summaryText }}
</div>
</div>
<span
v-if="stale"
class="shrink-0 rounded-[999px] border border-[#8a6d1a] px-2 py-1 text-xs text-[#f6d77a]"
>
需重测
</span>
</div>
<div v-if="stale" class="mt-2 text-xs text-[#f6d77a]">
@@ -134,19 +129,19 @@ const summaryClass = computed(() => {
v-if="showMetrics && normalizedStatus === 'success'"
class="mt-3 grid grid-cols-1 gap-2 md:grid-cols-2"
>
<div class="rounded-[8px] bg-[#1c1c1c] px-3 py-2">
<div class="rounded-[8px] bg-black/20 px-3 py-2">
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">总耗时</div>
<div class="mt-1 text-sm text-[#d4d4d4]">{{ formatDuration(result?.totalDurationMS) }}</div>
<div class="mt-1 text-sm text-white/80">{{ formatDuration(result?.totalDurationMS) }}</div>
</div>
<div class="rounded-[8px] bg-[#1c1c1c] px-3 py-2">
<div class="rounded-[8px] bg-black/20 px-3 py-2">
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">输出 Token</div>
<div class="mt-1 text-sm text-[#d4d4d4]">{{ result?.outputTokens ?? 0 }}</div>
<div class="mt-1 text-sm text-white/80">{{ result?.outputTokens ?? 0 }}</div>
</div>
</div>
<div
v-if="normalizedStatus === 'success' && result?.tokensEstimated"
class="mt-2 text-xs text-[#8f8f8f]"
class="mt-2 text-xs text-white/80"
>
输出 Token 为估算值
</div>
+619
View File
@@ -0,0 +1,619 @@
<script setup>
import Button from "@/components/ui/Button.vue";
import Combobox from "@/components/ui/Combobox.vue";
import Input from "@/components/ui/Input.vue";
import ModelAdapterTestCard from "@/components/ModelAdapterTestCard.vue";
import Select from "@/components/ui/Select.vue";
import Tooltip from "@/components/ui/Tooltip.vue";
import { useMessage } from "@/composables/useMessage";
import {
ANTHROPIC_THINKING_EFFORT_DEFAULT,
appState,
buildModelAdapterTestRequestHash,
createEmptyModelAdapter,
CUSTOM_HEADERS_DEFAULT_JSON,
EXTRA_PARAMS_DEFAULT_JSON,
fetchAvailableModelIDs,
getModelAdapterTestResult,
getModelAdapterTestResultByID,
isModelAdapterTestResultStale,
normalizeModelAdapter,
OPENAI_ENDPOINT_CHAT_COMPLETIONS,
OPENAI_ENDPOINT_CUSTOM,
OPENAI_ENDPOINT_RESPONSES,
OPENAI_EXTRA_PARAMS_DEFAULT_JSON,
runModelAdapterTest,
saveModelAdapterAt,
toUserError,
validateModelAdapters,
} from "@/state/appState";
import { computed, onBeforeUnmount, reactive, ref, watch } from "vue";
const modelTypeTabs = [
{ label: "OpenAI", value: "openai", icon: "icon-[bxl--openai]" },
{ label: "Anthropic", value: "anthropic", icon: "icon-[logos--claude-icon]" },
];
const reasoningEffortOptions = [
{ label: "低", value: "low", icon: "icon-[mdi--head-outline]" },
{ label: "中", value: "medium", icon: "icon-[mdi--head-lightbulb-outline]" },
{ label: "高", value: "high", icon: "icon-[mdi--brain]" },
{ label: "极高", value: "xhigh", icon: "icon-[mdi--head-cog-outline]" },
{ label: "最高", value: "max", icon: "icon-[mdi--brain]" },
];
const anthropicThinkingEffortOptions = [
{ label: "低", value: "low", icon: "icon-[mdi--head-outline]" },
{ label: "中", value: "medium", icon: "icon-[mdi--head-lightbulb-outline]" },
{ label: "高", value: "high", icon: "icon-[mdi--brain]" },
{ label: "极高", value: "xhigh", icon: "icon-[mdi--head-cog-outline]" },
{ label: "Max", value: "max", icon: "icon-[mdi--brain]" },
];
const openAIEndpointOptions = [
{ label: "/v1/responses", value: OPENAI_ENDPOINT_RESPONSES, icon: "icon-[mdi--api]" },
{ label: "/v1/chat/completions", value: OPENAI_ENDPOINT_CHAT_COMPLETIONS, icon: "icon-[mdi--message-text-outline]" },
{ label: "自定义路径(请输入完整请求地址)", value: OPENAI_ENDPOINT_CUSTOM, icon: "icon-[mdi--pencil-outline]" },
];
const props = defineProps({
index: { type: Number, default: -1 },
adapter: { type: Object, default: () => createEmptyModelAdapter() },
});
const emit = defineEmits(["close", "saved"]);
const message = useMessage();
const editorIndex = ref(props.index);
const draft = reactive(normalizeModelAdapter(props.adapter));
if (!draft.type) {
draft.type = "openai";
}
const lastTestAdapterID = ref("");
const localTestFailure = ref("");
const availableModelIDs = ref(draft.modelID ? [draft.modelID] : []);
const modelListLoading = ref(false);
const modelListRequestSeq = ref(0);
let modelListDebounceTimer = 0;
function createOptionalPositiveIntegerModel(key) {
return computed({
get() {
return draft[key] > 0 ? String(draft[key]) : "";
},
set(value) {
const text = String(value || "").trim();
draft[key] = /^\d+$/.test(text) && Number(text) > 0 ? Number(text) : 0;
},
});
}
const maxCompletionTokensInput = createOptionalPositiveIntegerModel("maxCompletionTokens");
const anthropicMaxTokensInput = createOptionalPositiveIntegerModel("anthropicMaxTokens");
const contextWindowTokensInput = createOptionalPositiveIntegerModel("contextWindowTokens");
const interfacePlaceholder = computed(() =>
draft.type === "anthropic" ? "例如:https://api.anthropic.com" : "例如:https://api.openai.com/v1",
);
const modelOptions = computed(() => availableModelIDs.value.map((modelID) => ({
label: modelID,
value: modelID,
icon: "icon-[mdi--cube-outline]",
})));
const canFetchModels = computed(() => Boolean(
draft.type && String(draft.baseURL || "").trim() && String(draft.apiKey || "").trim(),
));
const selectedTestAdapter = computed(() => normalizeModelAdapter(draft));
const currentRequestHash = computed(() => buildModelAdapterTestRequestHash(selectedTestAdapter.value));
const directModelTestResult = computed(() => getModelAdapterTestResult(selectedTestAdapter.value));
const rememberedModelTestResult = computed(() =>
lastTestAdapterID.value ? getModelAdapterTestResultByID(lastTestAdapterID.value) : null,
);
const activeModelTestResult = computed(() => directModelTestResult.value || rememberedModelTestResult.value);
const modelTestResultStale = computed(() =>
isModelAdapterTestResultStale(selectedTestAdapter.value, activeModelTestResult.value),
);
const isCurrentConfigTesting = computed(() => directModelTestResult.value?.status === "running");
const modelTestSummary = computed(() => {
if (localTestFailure.value) {
return localTestFailure.value;
}
return activeModelTestResult.value?.summaryText || "尚未测试";
});
function ensureOpenAIExtraParamsJSON() {
if (!String(draft.openAIExtraParamsJSON || "").trim()) {
draft.openAIExtraParamsJSON = OPENAI_EXTRA_PARAMS_DEFAULT_JSON;
}
}
function ensureCustomHeadersJSON() {
if (!String(draft.customHeadersJSON || "").trim()) {
draft.customHeadersJSON = CUSTOM_HEADERS_DEFAULT_JSON;
}
}
function ensureAnthropicExtraParamsJSON() {
if (!String(draft.anthropicExtraParamsJSON || "").trim()) {
draft.anthropicExtraParamsJSON = EXTRA_PARAMS_DEFAULT_JSON;
}
}
function ensureAnthropicThinkingEffort() {
if (!String(draft.anthropicThinkingEffort || "").trim()) {
draft.anthropicThinkingEffort = ANTHROPIC_THINKING_EFFORT_DEFAULT;
}
}
const fieldTips = {
displayName: "仅用于界面展示,便于你区分不同模型。",
modelID: "可以直接输入模型标识,或从服务端返回的列表中选择。",
baseURL: "模型服务的 API 根地址,通常为兼容 OpenAI 或 Anthropic 的接口入口。",
apiKey: "调用该模型服务需要使用的访问密钥。",
contextWindowTokens: "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
reasoningEffort: "推理强度仅对部分支持 reasoning_effort 的模型生效,并不是所有模型都支持。越高通常越稳,但也可能更慢。",
maxCompletionTokens: "单次回复允许生成的最大 Token 数。留空时使用默认值。",
openAIEndpoint: "选择接口协议端点。选“自定义路径”时,请在接口地址栏填写完整请求地址(含 /chat/completions 或 /responses 路径后缀),系统会根据末段自动判断协议形态。",
openAIExtraParams: "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority。",
customHeaders: "开启后会把 JSON 对象覆盖到最终请求头。同名请求头以这里为准,值必须是字符串。",
anthropicExtraParams: "开启后会把 JSON 对象覆盖到 Anthropic 请求体。同名字段以这里为准。",
anthropicMaxTokens: "Anthropic 模型单次回复允许生成的最大 Token 数。留空时使用默认值。",
anthropicThinkingEffort: "Anthropic adaptive thinking 的思考强度。请求会固定使用新版 thinking.type=adaptive。",
tooltipData: "模型列表 hover 时显示的备注说明。",
};
async function refreshModelList() {
const baseURL = String(draft.baseURL || "").trim();
const apiKey = String(draft.apiKey || "").trim();
if (!baseURL || !apiKey || !draft.type) {
modelListRequestSeq.value += 1;
availableModelIDs.value = [];
modelListLoading.value = false;
return [];
}
const requestSeq = modelListRequestSeq.value + 1;
modelListRequestSeq.value = requestSeq;
modelListLoading.value = true;
availableModelIDs.value = [];
try {
const models = await fetchAvailableModelIDs({
type: draft.type,
baseURL,
apiKey,
customHeadersEnabled: draft.customHeadersEnabled,
customHeadersJSON: draft.customHeadersJSON,
});
if (requestSeq !== modelListRequestSeq.value) {
return availableModelIDs.value;
}
availableModelIDs.value = models;
return models;
} catch (_error) {
if (requestSeq === modelListRequestSeq.value) {
availableModelIDs.value = [];
}
return availableModelIDs.value;
} finally {
if (requestSeq === modelListRequestSeq.value) {
modelListLoading.value = false;
}
}
}
async function persistDraft() {
const adapter = normalizeModelAdapter(draft);
const singleCheck = validateModelAdapters([adapter]);
if (singleCheck) {
message(singleCheck);
return { ok: false, error: singleCheck, adapter: null };
}
const result = await saveModelAdapterAt(editorIndex.value, adapter);
if (!result.ok) {
message(result.error);
return { ok: false, error: result.error, adapter: null };
}
if (typeof result.index === "number") {
editorIndex.value = result.index;
}
if (result.adapter) {
Object.assign(draft, normalizeModelAdapter(result.adapter));
} else {
Object.assign(draft, adapter);
}
return {
ok: true,
error: "",
adapter: result.adapter ? normalizeModelAdapter(result.adapter) : normalizeModelAdapter(adapter),
};
}
async function handleSave() {
const result = await persistDraft();
if (!result.ok) {
return;
}
emit("saved", result.adapter);
emit("close");
}
function handleCancel() {
emit("close");
}
function handleModelTypeChange(type) {
draft.type = type;
modelListRequestSeq.value += 1;
modelListLoading.value = false;
availableModelIDs.value = [];
draft.modelID = "";
if (type === "openai" && !draft.openAIEndpoint) {
draft.openAIEndpoint = OPENAI_ENDPOINT_RESPONSES;
} else if (type === "anthropic") {
ensureAnthropicThinkingEffort();
}
}
async function handleTest() {
localTestFailure.value = "";
try {
const saved = await persistDraft();
if (!saved.ok || !saved.adapter) {
return;
}
const result = await runModelAdapterTest(saved.adapter);
if (result?.adapterID) {
lastTestAdapterID.value = result.adapterID;
}
} catch (error) {
const latest = getModelAdapterTestResult(draft);
if (latest?.adapterID) {
lastTestAdapterID.value = latest.adapterID;
return;
}
localTestFailure.value = toUserError(error);
}
}
watch(
directModelTestResult,
(result) => {
if (!result?.adapterID) {
return;
}
lastTestAdapterID.value = result.adapterID;
if (result.status !== "running") {
localTestFailure.value = "";
}
},
{ immediate: true },
);
watch(currentRequestHash, () => {
localTestFailure.value = "";
});
watch(
() => draft.openAIExtraParamsEnabled,
(enabled) => {
if (enabled) {
ensureOpenAIExtraParamsJSON();
}
},
);
watch(
() => draft.customHeadersEnabled,
(enabled) => {
if (enabled) {
ensureCustomHeadersJSON();
}
},
);
watch(
() => draft.anthropicExtraParamsEnabled,
(enabled) => {
if (enabled) {
ensureAnthropicExtraParamsJSON();
}
},
);
watch(
() => [draft.type, draft.baseURL, draft.apiKey, draft.customHeadersEnabled, draft.customHeadersJSON],
() => {
window.clearTimeout(modelListDebounceTimer);
const baseURL = String(draft.baseURL || "").trim();
const apiKey = String(draft.apiKey || "").trim();
if (!baseURL || !apiKey) {
modelListRequestSeq.value += 1;
modelListLoading.value = false;
availableModelIDs.value = [];
return;
}
modelListDebounceTimer = window.setTimeout(() => {
void refreshModelList();
}, 600);
},
{ immediate: true },
);
onBeforeUnmount(() => {
window.clearTimeout(modelListDebounceTimer);
});
</script>
<template>
<div class="flex h-full flex-col text-[#e5e5e5]">
<div class="flex-shrink-0 p-4" v-if="localTestFailure || activeModelTestResult">
<ModelAdapterTestCard
:result="localTestFailure ? { status: 'error', error: '测试失败', summaryText: '测试失败', rawResponse: modelTestSummary } : activeModelTestResult"
:stale="modelTestResultStale"
:show-metrics="true"
/>
</div>
<div class="flex-1 min-h-0 overflow-y-auto px-4 py-4 scroll-shadow-bottom">
<div class="flex flex-col gap-4">
<div class="center-row gap-2">
<button
v-for="tab in modelTypeTabs"
:key="tab.value"
type="button"
class="center-row gap-2 rounded-[8px] border px-3 py-2 text-sm transition-colors duration-150"
:class="draft.type === tab.value
? 'border-[#1ca35a] bg-[#123322] text-white'
: 'border-[#343434] bg-[#252525] text-[#a3a3a3] hover:border-[#4a4a4a] hover:text-[#e5e5e5]'"
@click="handleModelTypeChange(tab.value)"
>
<span :class="[tab.icon, 'text-[16px]']"></span>
<span>{{ tab.label }}</span>
</button>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="fieldTips.baseURL" />
<span>接口地址</span>
</span>
<input
v-model="draft.baseURL"
type="text"
:placeholder="interfacePlaceholder"
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</label>
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="fieldTips.apiKey" />
<span>访问密钥</span>
</span>
<Input
v-model="draft.apiKey"
type="password"
allow-visibility-toggle
placeholder="例如:sk-xxxxxx"
autocomplete="off"
/>
</label>
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="fieldTips.displayName" />
<span>显示名称</span>
</span>
<input
v-model="draft.displayName"
type="text"
placeholder="例如:GPT-5"
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</label>
<div class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="fieldTips.modelID" />
<span>模型标识</span>
</span>
<Combobox
v-model="draft.modelID"
:options="modelOptions"
:loading="modelListLoading"
placeholder="例如:gpt-4.1"
empty-text="没有匹配的模型"
aria-label="选择模型"
>
<template #append>
<button
type="button"
class="center-row h-9 shrink-0 gap-1.5 whitespace-nowrap rounded-[6px] border border-[#3f3f3f] bg-[#292929] px-[8px] text-sm text-[#d4d4d4] outline-none transition-colors hover:border-[#505050] hover:bg-[#303030] hover:text-white focus-visible:border-[#10AD5D] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="modelListLoading || !canFetchModels"
@click="refreshModelList"
>
<span>获取模型</span>
</button>
</template>
</Combobox>
</div>
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="fieldTips.contextWindowTokens" />
<span>上下文窗口</span>
</span>
<input
v-model="contextWindowTokensInput"
type="text"
inputmode="numeric"
placeholder="例如:200000(留空用默认值)"
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</label>
<label v-if="draft.type === 'openai'" class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="fieldTips.reasoningEffort" />
<span>推理强度</span>
</span>
<Select
v-model="draft.reasoningEffort"
:options="reasoningEffortOptions"
/>
</label>
<label v-if="draft.type === 'anthropic'" class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="fieldTips.anthropicMaxTokens" />
<span>最大输出 Token</span>
</span>
<input
v-model="anthropicMaxTokensInput"
type="text"
inputmode="numeric"
placeholder="例如:65536(留空用默认值)"
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</label>
<label v-if="draft.type === 'anthropic'" class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="fieldTips.anthropicThinkingEffort" />
<span>思考强度</span>
</span>
<Select
v-model="draft.anthropicThinkingEffort"
:options="anthropicThinkingEffortOptions"
/>
</label>
</div>
<div v-if="draft.type === 'openai'" class="grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="fieldTips.maxCompletionTokens" />
<span>最大输出 Token</span>
</span>
<input
v-model="maxCompletionTokensInput"
type="text"
inputmode="numeric"
placeholder="例如:65536(留空用默认值)"
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</label>
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="fieldTips.openAIEndpoint" />
<span>接口端点</span>
</span>
<Select
v-model="draft.openAIEndpoint"
:options="openAIEndpointOptions"
/>
</label>
</div>
<div v-if="draft.type === 'openai'" class="rounded-[8px] border border-[#343434] bg-[#252525] p-3">
<div class="flex items-center justify-between gap-3">
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="fieldTips.openAIExtraParams" />
<span>额外参数 JSON</span>
</span>
<label class="center-row gap-2 text-xs text-[#d4d4d4]">
<input
v-model="draft.openAIExtraParamsEnabled"
type="checkbox"
class="size-4 accent-[#10AD5D]"
/>
<span>启用</span>
</label>
</div>
<textarea
v-if="draft.openAIExtraParamsEnabled"
v-model="draft.openAIExtraParamsJSON"
rows="5"
spellcheck="false"
class="mt-3 min-h-[120px] w-full resize-none rounded-[6px] border border-[#3f3f3f] bg-[#1f1f1f] px-3 py-2 font-mono text-xs text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</div>
<div v-if="draft.type === 'anthropic'" class="rounded-[8px] border border-[#343434] bg-[#252525] p-3">
<div class="flex items-center justify-between gap-3">
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="fieldTips.anthropicExtraParams" />
<span>Anthropic 额外参数 JSON</span>
</span>
<label class="center-row gap-2 text-xs text-[#d4d4d4]">
<input
v-model="draft.anthropicExtraParamsEnabled"
type="checkbox"
class="size-4 accent-[#10AD5D]"
/>
<span>启用</span>
</label>
</div>
<textarea
v-if="draft.anthropicExtraParamsEnabled"
v-model="draft.anthropicExtraParamsJSON"
rows="5"
spellcheck="false"
class="mt-3 min-h-[120px] w-full resize-none rounded-[6px] border border-[#3f3f3f] bg-[#1f1f1f] px-3 py-2 font-mono text-xs text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</div>
<div class="rounded-[8px] border border-[#343434] bg-[#252525] p-3">
<div class="flex items-center justify-between gap-3">
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="fieldTips.customHeaders" />
<span>自定义请求头 JSON</span>
</span>
<label class="center-row gap-2 text-xs text-[#d4d4d4]">
<input
v-model="draft.customHeadersEnabled"
type="checkbox"
class="size-4 accent-[#10AD5D]"
/>
<span>启用</span>
</label>
</div>
<textarea
v-if="draft.customHeadersEnabled"
v-model="draft.customHeadersJSON"
rows="5"
spellcheck="false"
class="mt-3 min-h-[120px] w-full resize-none rounded-[6px] border border-[#3f3f3f] bg-[#1f1f1f] px-3 py-2 font-mono text-xs text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</div>
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="fieldTips.tooltipData" />
<span>备注</span>
</span>
<textarea
v-model="draft.tooltipData"
rows="3"
placeholder="例如:用于日常代码补全与问答"
class="min-h-[96px] resize-none rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 py-2 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</label>
</div>
</div>
<div class="flex shrink-0 items-center justify-end gap-2 px-4 py-3">
<Button variant="default" :disabled="appState.configSaving" @click="handleCancel">取消</Button>
<Button variant="default" :disabled="isCurrentConfigTesting || appState.configSaving" @click="handleTest">
{{ isCurrentConfigTesting ? "测试中..." : "保存并测试" }}
</Button>
<Button variant="primary" :disabled="appState.configSaving" @click="handleSave">
{{ appState.configSaving ? "保存中..." : "保存" }}
</Button>
</div>
</div>
</template>
+3 -6
View File
@@ -1,12 +1,9 @@
<script setup></script>
<template>
<div
class="rounded-[8px] p-[1px]"
style="background: linear-gradient(to bottom, #656565 0%, #3A3A3A 10px, #3A3A3A 100%);"
class="rounded-[8px] border border-[#343434] bg-[#292929] p-4 "
>
<div class="rounded-[7px] bg-[#292929] p-4">
<slot />
</div>
<slot />
</div>
</template>
+337
View File
@@ -0,0 +1,337 @@
<script setup>
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
import { computed, nextTick, onBeforeUnmount, ref, useId, watch, watchPostEffect } from "vue";
const props = defineProps({
modelValue: { type: String, default: "" },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: "" },
emptyText: { type: String, default: "没有匹配项" },
loading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
ariaLabel: { type: String, default: "" },
});
const emit = defineEmits(["update:modelValue", "change", "blur"]);
const rootRef = ref(null);
const inputRef = ref(null);
const menuRef = ref(null);
const optionRefs = ref([]);
const isOpen = ref(false);
const activeIndex = ref(-1);
const showAllOptions = ref(false);
const menuStyle = ref({});
const listboxID = useId();
const normalizedOptions = computed(() => props.options.map((option, optionIndex) => {
if (typeof option === "string") {
return { label: option, value: option, icon: "", optionIndex };
}
return {
label: option?.label ?? option?.value ?? "",
value: option?.value ?? "",
icon: option?.icon ?? option?.iconClass ?? "",
optionIndex,
};
}));
const filteredOptions = computed(() => {
const query = String(props.modelValue || "").trim().toLocaleLowerCase();
if (showAllOptions.value || !query) {
return normalizedOptions.value;
}
return normalizedOptions.value.filter((option) => (
String(option.label).toLocaleLowerCase().includes(query)
|| String(option.value).toLocaleLowerCase().includes(query)
));
});
const activeOption = computed(() => filteredOptions.value[activeIndex.value] ?? null);
const activeDescendant = computed(() => (
isOpen.value && activeOption.value ? `${listboxID}-option-${activeOption.value.optionIndex}` : undefined
));
function setOptionRef(el, index) {
if (el) {
optionRefs.value[index] = el;
return;
}
delete optionRefs.value[index];
}
function focusActiveOption() {
nextTick(() => optionRefs.value[activeOption.value?.optionIndex]?.scrollIntoView({ block: "nearest" }));
}
function openMenu({ showAll = false } = {}) {
if (props.disabled || normalizedOptions.value.length === 0) {
return;
}
showAllOptions.value = showAll;
isOpen.value = true;
const selectedIndex = filteredOptions.value.findIndex((option) => option.value === props.modelValue);
activeIndex.value = selectedIndex >= 0 ? selectedIndex : filteredOptions.value.length ? 0 : -1;
nextTick(updatePosition);
}
function closeMenu({ restoreFocus = false, notifyBlur = false } = {}) {
if (!isOpen.value) {
if (notifyBlur) {
emit("blur");
}
return;
}
isOpen.value = false;
activeIndex.value = -1;
if (restoreFocus) {
nextTick(() => inputRef.value?.focus());
}
if (notifyBlur) {
emit("blur");
}
}
function handleInput(event) {
emit("update:modelValue", event.target.value);
showAllOptions.value = false;
isOpen.value = normalizedOptions.value.length > 0;
activeIndex.value = filteredOptions.value.length ? 0 : -1;
}
function selectOption(option) {
if (!option) {
return;
}
emit("update:modelValue", option.value);
emit("change", option.value);
closeMenu();
}
function toggleMenu() {
if (isOpen.value) {
closeMenu({ restoreFocus: true });
return;
}
openMenu({ showAll: true });
nextTick(() => inputRef.value?.focus());
}
function moveActiveIndex(step) {
if (!isOpen.value) {
openMenu({ showAll: true });
return;
}
const total = filteredOptions.value.length;
if (!total) {
return;
}
const current = activeIndex.value >= 0 ? activeIndex.value : 0;
activeIndex.value = (current + step + total) % total;
focusActiveOption();
}
function handleInputKeydown(event) {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
moveActiveIndex(1);
break;
case "ArrowUp":
event.preventDefault();
moveActiveIndex(-1);
break;
case "Enter":
if (isOpen.value && activeIndex.value >= 0) {
event.preventDefault();
selectOption(filteredOptions.value[activeIndex.value]);
}
break;
case "Escape":
if (isOpen.value) {
event.preventDefault();
closeMenu({ restoreFocus: true });
}
break;
case "Tab":
closeMenu({ notifyBlur: true });
break;
default:
break;
}
}
function handlePointerDown(event) {
if (rootRef.value?.contains(event.target) || menuRef.value?.contains(event.target)) {
return;
}
closeMenu({ notifyBlur: true });
}
function updatePosition() {
if (!rootRef.value || !menuRef.value) {
return;
}
computePosition(rootRef.value, menuRef.value, {
placement: "bottom-start",
middleware: [
offset(6),
flip({ padding: 12 }),
shift({ padding: 12 }),
size({
apply({ rects, elements, availableHeight }) {
Object.assign(elements.floating.style, {
width: `${rects.reference.width}px`,
maxHeight: `${Math.max(availableHeight, 160)}px`,
});
},
padding: 12,
}),
],
}).then(({ x, y }) => {
menuStyle.value = { left: `${x}px`, top: `${y}px` };
});
}
watch(filteredOptions, (options) => {
if (!isOpen.value) {
return;
}
activeIndex.value = options.length ? Math.min(Math.max(activeIndex.value, 0), options.length - 1) : -1;
nextTick(updatePosition);
});
watch(normalizedOptions, (options) => {
if (options.length === 0) {
closeMenu();
}
});
watchPostEffect((cleanup) => {
if (!isOpen.value || !rootRef.value || !menuRef.value) {
return;
}
const stopAutoUpdate = autoUpdate(rootRef.value, menuRef.value, updatePosition);
cleanup(stopAutoUpdate);
});
watch(isOpen, (open) => {
if (open) {
document.addEventListener("pointerdown", handlePointerDown);
return;
}
document.removeEventListener("pointerdown", handlePointerDown);
});
watch(() => props.disabled, (disabled) => {
if (disabled) {
closeMenu();
}
});
onBeforeUnmount(() => {
document.removeEventListener("pointerdown", handlePointerDown);
});
</script>
<template>
<div class="flex w-full min-w-0 items-center gap-2">
<div
ref="rootRef"
class="flex h-9 min-w-0 flex-1 items-center rounded-[6px] border border-[#3f3f3f] bg-[#232323] transition-colors focus-within:border-[#10AD5D]"
>
<input
ref="inputRef"
:value="modelValue"
type="text"
role="combobox"
autocomplete="off"
autocapitalize="none"
autocorrect="off"
spellcheck="false"
class="min-w-0 flex-1 bg-transparent px-3 text-sm text-[#e5e5e5] outline-none placeholder:text-[#7b7b7b] disabled:cursor-not-allowed disabled:opacity-60"
:placeholder="placeholder"
:disabled="disabled"
:aria-label="ariaLabel || undefined"
:aria-expanded="isOpen"
:aria-controls="listboxID"
:aria-activedescendant="activeDescendant"
aria-autocomplete="list"
aria-haspopup="listbox"
@focus="openMenu({ showAll: true })"
@input="handleInput"
@change="emit('change', $event.target.value)"
@keydown="handleInputKeydown"
/>
<button
type="button"
class="center-row h-full w-9 shrink-0 text-[#8f8f8f] outline-none transition-colors hover:text-[#d4d4d4] disabled:cursor-not-allowed disabled:opacity-60"
:disabled="disabled || loading"
:aria-label="ariaLabel || undefined"
:aria-expanded="isOpen"
tabindex="-1"
@mousedown.prevent
@click="toggleMenu"
>
<span v-if="loading" class="icon-[mdi--loading] animate-spin text-[17px]"></span>
<span v-else class="icon-[mdi--chevron-down] text-[18px] transition-transform duration-200" :class="isOpen ? 'rotate-180' : ''"></span>
</button>
</div>
<div v-if="$slots.append" class="shrink-0">
<slot name="append" />
</div>
</div>
<Teleport to="body">
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-1 opacity-0"
>
<div
v-show="isOpen && normalizedOptions.length"
ref="menuRef"
:id="listboxID"
role="listbox"
class="fixed z-[999] overflow-hidden rounded-[8px] border border-[#3f3f3f] bg-[#232323] p-1 shadow-[0_16px_30px_-12px_rgba(0,0,0,0.7)]"
:style="menuStyle"
>
<ul v-show="filteredOptions.length" role="presentation" class="overflow-y-auto py-1">
<li
v-for="option in normalizedOptions"
v-show="filteredOptions.includes(option)"
:key="option.value"
role="presentation"
>
<button
:ref="(el) => setOptionRef(el, option.optionIndex)"
:id="`${listboxID}-option-${option.optionIndex}`"
type="button"
role="option"
class="flex w-full items-center rounded-[6px] px-3 py-2 text-left text-sm outline-none transition-colors"
:class="[
option.value === modelValue
? 'bg-[#10AD5D]/15 text-[#10d06f]'
: 'text-[#e5e5e5] hover:bg-[#303030]',
activeOption === option ? 'bg-[#303030]' : '',
]"
:aria-selected="option.value === modelValue"
tabindex="-1"
@mousedown.prevent
@click="selectOption(option)"
@mouseenter="activeIndex = filteredOptions.indexOf(option)"
>
<span class="flex min-w-0 items-center gap-2">
<span v-if="option.icon" :class="[option.icon, 'shrink-0 text-[16px]']" aria-hidden="true"></span>
<span class="truncate">{{ option.label }}</span>
</span>
</button>
</li>
</ul>
<div v-show="!filteredOptions.length" class="px-3 py-2 text-sm text-[#8f8f8f]">{{ emptyText }}</div>
</div>
</Transition>
</Teleport>
</template>
+138
View File
@@ -0,0 +1,138 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
const props = defineProps({
open: { type: Boolean, default: false },
title: { type: String, default: "" },
size: {
type: String,
default: "md",
validator: (value) => ["md", "lg", "xl"].includes(value),
},
closeOnBackdrop: { type: Boolean, default: true },
closeOnEscape: { type: Boolean, default: true },
closeDisabled: { type: Boolean, default: false },
});
const emit = defineEmits(["close"]);
const panelRef = ref(null);
const closeButtonRef = ref(null);
let previouslyFocusedElement = null;
const panelClass = computed(() => ({
md: "max-w-[420px]",
lg: "max-w-[680px]",
xl: "h-full max-h-[760px] max-w-[880px]",
}[props.size]));
function requestClose() {
if (!props.closeDisabled) {
emit("close");
}
}
function handleBackdropClick() {
if (props.closeOnBackdrop) {
requestClose();
}
}
function handleKeydown(event) {
if (event.key === "Escape" && props.open && props.closeOnEscape) {
requestClose();
return;
}
if (event.key !== "Tab" || !props.open || !panelRef.value) {
return;
}
const focusable = Array.from(panelRef.value.querySelectorAll(
"button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex='-1'])",
));
if (focusable.length === 0) {
event.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
watch(
() => props.open,
(open) => {
if (open) {
previouslyFocusedElement = document.activeElement;
document.addEventListener("keydown", handleKeydown);
nextTick(() => {
const firstField = panelRef.value?.querySelector(
"input:not([disabled]), textarea:not([disabled]), select:not([disabled])",
);
(firstField || closeButtonRef.value)?.focus();
});
return;
}
document.removeEventListener("keydown", handleKeydown);
if (previouslyFocusedElement instanceof HTMLElement && document.contains(previouslyFocusedElement)) {
previouslyFocusedElement.focus();
}
previouslyFocusedElement = null;
},
{ immediate: true },
);
onBeforeUnmount(() => {
document.removeEventListener("keydown", handleKeydown);
});
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-opacity duration-150 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-100 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-show="open"
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 px-7 pb-[38px] pt-[calc(48px+env(safe-area-inset-top))]"
@click.self="handleBackdropClick"
>
<section
ref="panelRef"
role="dialog"
aria-modal="true"
:aria-label="title || undefined"
class="flex w-full max-h-[660px] min-h-0 flex-col overflow-hidden rounded-[8px] border border-[#3a3a3a] bg-[#202020] shadow-[0_24px_64px_rgba(0,0,0,0.65)]"
:class="panelClass"
@click.stop
>
<header class="flex h-12 shrink-0 items-center justify-between border-b border-[#343434] px-4">
<h2 class="min-w-0 truncate text-base font-medium text-white">{{ title }}</h2>
<button
ref="closeButtonRef"
type="button"
class="center-row w-[28px] h-[28px] justify-center size-8 shrink-0 rounded-[6px] text-[#8f8f8f] outline-none transition-colors hover:bg-[#303030] hover:text-white focus-visible:ring-2 focus-visible:ring-[#10AD5D]/40 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="closeDisabled"
aria-label="关闭"
@click="requestClose"
>
<span class="icon-[mdi--close] text-[19px]"></span>
</button>
</header>
<div class="min-h-0 flex-1 overflow-hidden">
<slot />
</div>
</section>
</div>
</Transition>
</Teleport>
</template>
+17 -54
View File
@@ -2,56 +2,24 @@
import { messageState, provideMessage } from "@/composables/useMessage";
provideMessage();
const MESSAGE_THEME = {
success: {
containerClass: "bg-[#10AD5D] text-white",
iconClass: "icon-[dashicons--yes]",
iconExtraClass: "",
},
error: {
containerClass: "bg-[#D84C4C] text-white",
iconClass: "",
iconExtraClass: "",
},
info: {
containerClass: "bg-[#F08A24] text-white",
iconClass: "",
iconExtraClass: "",
},
loading: {
containerClass: "bg-[#3a3a3a] text-white",
iconClass: "icon-[mingcute--loading-fill]",
iconExtraClass: "animate-spin",
},
};
function resolveTheme(type) {
return MESSAGE_THEME[type] || MESSAGE_THEME.info;
}
</script>
<template>
<div class="pointer-events-none fixed inset-x-0 top-4 z-[1000] flex justify-center px-4">
<Transition name="message-slide" mode="out-in">
<div
v-if="messageState.current"
:key="messageState.current.id"
class="pointer-events-auto inline-flex max-w-full items-center gap-2 rounded-full px-4 py-2 text-sm shadow-[0_8px_24px_rgba(0,0,0,0.28)]"
:class="resolveTheme(messageState.current.type).containerClass"
>
<span
v-if="resolveTheme(messageState.current.type).iconClass"
class="text-[14px]"
:class="[
resolveTheme(messageState.current.type).iconClass,
resolveTheme(messageState.current.type).iconExtraClass,
]"
/>
<span class="leading-none whitespace-nowrap">{{ messageState.current.content }}</span>
</div>
</Transition>
</div>
<Teleport to="body">
<div class="pointer-events-none fixed inset-x-0 top-[calc(48px+env(safe-area-inset-top))] z-[11000] flex justify-center px-4">
<Transition name="message-slide" mode="out-in">
<div
v-if="messageState.current"
:key="messageState.current.id"
role="status"
aria-live="polite"
class="max-w-[min(520px,calc(100vw-32px))] rounded-[8px] border border-[#454545] bg-[#2b2b2b] px-4 py-2.5 text-center text-sm leading-5 text-[#ededed] shadow-[0_10px_30px_rgba(0,0,0,0.38)]"
>
{{ messageState.current.content }}
</div>
</Transition>
</div>
</Teleport>
</template>
<style scoped>
@@ -62,7 +30,7 @@ function resolveTheme(type) {
.message-slide-enter-from {
opacity: 0;
transform: translateY(-12px);
transform: translateY(-8px);
}
.message-slide-enter-to,
@@ -73,11 +41,6 @@ function resolveTheme(type) {
.message-slide-leave-to {
opacity: 0;
transform: translateY(-12px);
transform: translateY(-8px);
}
</style>
+2 -3
View File
@@ -39,11 +39,10 @@ function onMaskClick() {
<Transition name="modal-content">
<div
v-show="visible"
class="relative z-10 w-full max-w-[360px] overflow-hidden rounded-[8px] p-px shadow-[0_25px_50px_-12px_rgba(0,0,0,0.6)]"
style="background: linear-gradient(to bottom, #656565 0%, #3A3A3A 10px, #3A3A3A 100%);"
class="relative z-10 w-full max-w-[360px] overflow-hidden rounded-[8px] p-px shadow-[0_25px_50px_-12px_rgba(0,0,0,0.6)] border border-[#3A3A3A] bg-[#292929]"
@click.stop
>
<div class="rounded-[7px] bg-[#292929] p-5">
<div class="rounded-[7px] p-5">
<h3 class="mb-3 text-base font-medium text-white">
{{ title }}
</h3>
-362
View File
@@ -1,362 +0,0 @@
<script setup>
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
import { computed, onBeforeUnmount, nextTick, ref, watch, watchPostEffect } from "vue";
const props = defineProps({
modelValue: {
type: Array,
default: () => [],
},
options: {
type: Array,
default: () => [],
},
placeholder: { type: String, default: "请选择" },
disabled: { type: Boolean, default: false },
ariaLabel: { type: String, default: "" },
summaryFormatter: { type: Function, default: null },
});
const emit = defineEmits(["update:modelValue", "change"]);
const rootRef = ref(null);
const buttonRef = ref(null);
const menuRef = ref(null);
const selectAllRef = ref(null);
const optionRefs = ref([]);
const isOpen = ref(false);
const menuStyle = ref({});
// -1 表示"全选"按钮,0..n-1 表示选项,共同组成一个可循环的键盘焦点环
const activeIndex = ref(-1);
const normalizedOptions = computed(() => props.options.map((option) => {
if (typeof option === "string") {
return { label: option, value: option };
}
return {
label: option?.label ?? option?.value ?? "",
value: option?.value ?? "",
icon: option?.icon ?? "",
};
}));
const selectedValues = computed(() => new Set(props.modelValue ?? []));
const allSelected = computed(() =>
normalizedOptions.value.length > 0
&& normalizedOptions.value.every((option) => selectedValues.value.has(option.value)),
);
const summaryLabel = computed(() => {
const count = selectedValues.value.size;
if (count === 0) {
return props.placeholder;
}
if (props.summaryFormatter) {
return props.summaryFormatter(count, normalizedOptions.value.length);
}
return `已选择 ${count}`;
});
function emitSelection(values) {
emit("update:modelValue", values);
emit("change", values);
}
function toggleOption(option) {
const next = normalizedOptions.value
.filter((item) => (item.value === option.value
? !selectedValues.value.has(item.value)
: selectedValues.value.has(item.value)))
.map((item) => item.value);
emitSelection(next);
}
function toggleSelectAll() {
if (allSelected.value) {
emitSelection([]);
return;
}
emitSelection(normalizedOptions.value.map((option) => option.value));
}
function setOptionRef(el, index) {
if (el) {
optionRefs.value[index] = el;
return;
}
delete optionRefs.value[index];
}
function focusActiveOption() {
nextTick(() => {
if (activeIndex.value < 0) {
selectAllRef.value?.focus();
return;
}
optionRefs.value[activeIndex.value]?.focus();
});
}
function moveActiveIndex(step) {
if (!isOpen.value) {
openMenu();
return;
}
const total = normalizedOptions.value.length;
if (total === 0) {
return;
}
// 焦点环长度为 total + 1(含全选),内部用 0..total 表示,再映射回 -1..total-1
const ringSize = total + 1;
const current = activeIndex.value + 1;
activeIndex.value = ((current + step + ringSize) % ringSize) - 1;
focusActiveOption();
}
function openMenu() {
if (props.disabled || isOpen.value) {
return;
}
isOpen.value = true;
const firstSelected = normalizedOptions.value.findIndex((option) => selectedValues.value.has(option.value));
activeIndex.value = firstSelected;
nextTick(() => {
updatePosition();
focusActiveOption();
});
}
function closeMenu({ restoreFocus = false } = {}) {
if (!isOpen.value) {
return;
}
isOpen.value = false;
activeIndex.value = -1;
optionRefs.value = [];
menuStyle.value = {};
if (restoreFocus) {
nextTick(() => buttonRef.value?.focus());
}
}
function toggleMenu() {
if (isOpen.value) {
closeMenu();
return;
}
openMenu();
}
function handleButtonKeydown(event) {
if (props.disabled) {
return;
}
switch (event.key) {
case "ArrowDown":
event.preventDefault();
moveActiveIndex(1);
break;
case "ArrowUp":
event.preventDefault();
moveActiveIndex(-1);
break;
case "Enter":
case " ":
event.preventDefault();
toggleMenu();
break;
case "Escape":
if (isOpen.value) {
event.preventDefault();
closeMenu();
}
break;
default:
break;
}
}
function handleOptionKeydown(event, option, index) {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
activeIndex.value = index;
moveActiveIndex(1);
break;
case "ArrowUp":
event.preventDefault();
activeIndex.value = index;
moveActiveIndex(-1);
break;
case "Enter":
case " ":
event.preventDefault();
if (option) {
toggleOption(option);
break;
}
toggleSelectAll();
break;
case "Escape":
event.preventDefault();
closeMenu({ restoreFocus: true });
break;
case "Tab":
closeMenu();
break;
default:
break;
}
}
function handlePointerDown(event) {
if (rootRef.value?.contains(event.target) || menuRef.value?.contains(event.target)) {
return;
}
closeMenu();
}
function updatePosition() {
if (!buttonRef.value || !menuRef.value) {
return;
}
computePosition(buttonRef.value, menuRef.value, {
placement: "bottom-start",
middleware: [
offset(6),
flip({ padding: 12 }),
shift({ padding: 12 }),
size({
apply({ rects, elements, availableHeight }) {
Object.assign(elements.floating.style, {
minWidth: `${rects.reference.width}px`,
maxHeight: `${Math.max(availableHeight, 200)}px`,
});
},
padding: 12,
}),
],
}).then(({ x, y }) => {
menuStyle.value = {
left: `${x}px`,
top: `${y}px`,
};
});
}
watchPostEffect((cleanup) => {
if (!isOpen.value || !buttonRef.value || !menuRef.value) {
return;
}
const stopAutoUpdate = autoUpdate(buttonRef.value, menuRef.value, updatePosition);
cleanup(() => {
stopAutoUpdate();
});
});
watch(isOpen, (open) => {
if (open) {
document.addEventListener("pointerdown", handlePointerDown);
return;
}
document.removeEventListener("pointerdown", handlePointerDown);
});
watch(() => props.disabled, (disabled) => {
if (disabled) {
closeMenu();
}
});
onBeforeUnmount(() => {
document.removeEventListener("pointerdown", handlePointerDown);
});
</script>
<template>
<div ref="rootRef" class="relative">
<button
ref="buttonRef"
type="button"
:disabled="disabled"
class="flex h-9 w-full items-center justify-between gap-2 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-left text-sm text-[#e5e5e5] outline-none transition-colors focus:border-[#10AD5D] disabled:cursor-not-allowed disabled:opacity-60"
:aria-expanded="isOpen"
:aria-label="ariaLabel || undefined"
aria-haspopup="listbox"
@click="toggleMenu"
@keydown="handleButtonKeydown"
>
<span class="flex min-w-0 flex-1 items-center gap-2" :class="selectedValues.size ? 'text-[#e5e5e5]' : 'text-[#7b7b7b]'">
<span class="truncate">{{ summaryLabel }}</span>
</span>
<span
class="pointer-events-none center-row text-[#8f8f8f] transition-transform duration-200"
:class="isOpen ? 'rotate-180' : ''"
>
<span class="icon-[mdi--chevron-down] text-[18px]"></span>
</span>
</button>
</div>
<Teleport to="body">
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-1 opacity-0"
>
<div
v-if="isOpen"
ref="menuRef"
class="fixed z-[999] flex flex-col overflow-hidden rounded-[8px] border border-[#3f3f3f] bg-[#232323] p-1 shadow-[0_16px_30px_-12px_rgba(0,0,0,0.7)]"
:style="menuStyle"
>
<button
ref="selectAllRef"
type="button"
class="flex w-full items-center gap-2 rounded-[6px] px-3 py-2 text-left text-sm text-[#d4d4d4] outline-none transition-colors hover:bg-[#303030]"
:class="activeIndex === -1 ? 'bg-[#303030]' : ''"
@click="toggleSelectAll"
@mouseenter="activeIndex = -1"
@keydown="handleOptionKeydown($event, null, -1)"
>
<span :class="[allSelected ? 'icon-[mdi--checkbox-marked]' : 'icon-[mdi--checkbox-blank-outline]', 'text-[16px] shrink-0']"></span>
<span class="truncate">{{ allSelected ? "取消全选" : "全选" }}</span>
</button>
<ul role="listbox" aria-multiselectable="true" class="overflow-y-auto py-1">
<li v-for="(option, index) in normalizedOptions" :key="option.value">
<button
:ref="(el) => setOptionRef(el, index)"
type="button"
role="option"
class="flex w-full items-center gap-2 rounded-[6px] px-3 py-2 text-left text-sm outline-none transition-colors"
:class="[
selectedValues.has(option.value)
? 'bg-[#10AD5D]/15 text-[#10d06f]'
: 'text-[#e5e5e5] hover:bg-[#303030]',
activeIndex === index ? 'bg-[#303030]' : '',
]"
:aria-selected="selectedValues.has(option.value)"
tabindex="0"
@click="toggleOption(option)"
@mouseenter="activeIndex = index"
@keydown="handleOptionKeydown($event, option, index)"
>
<span
:class="[
selectedValues.has(option.value) ? 'icon-[mdi--checkbox-marked]' : 'icon-[mdi--checkbox-blank-outline]',
'text-[16px] shrink-0',
]"
></span>
<span v-if="option.icon" :class="[option.icon, 'text-[16px] shrink-0']" aria-hidden="true"></span>
<span class="truncate">{{ option.label }}</span>
</button>
</li>
</ul>
</div>
</Transition>
</Teleport>
</template>
+1 -1
View File
@@ -117,7 +117,7 @@ onBeforeUnmount(() => {
<button
ref="triggerRef"
type="button"
class="center-row h-[16px] w-[16px] cursor-help rounded-full text-[#727272] transition-colors duration-150 hover:text-[#cfcfcf]"
class="center-row h-[16px] w-[16px] cursor-help rounded-full text-white/60 transition-colors duration-150 hover:text-white/80"
@mouseenter="showTooltip"
@mouseleave="scheduleHideTooltip"
@focus="showTooltip"