This commit is contained in:
leokun
2026-06-30 10:38:52 +08:00
commit c083be5ec2
312 changed files with 146628 additions and 0 deletions
+237
View File
@@ -0,0 +1,237 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { getAdRuntime, openAdExternalURL } from "@/services/clientApi";
const OPEN_AD_EVENT = "cursor:open-ad";
const BRIDGE_SOURCE = "cursor-ad";
const visible = ref(false);
const runtimeState = ref(null);
const iframeSrc = ref("");
const viewport = ref({
width: typeof window === "undefined" ? 1024 : window.innerWidth,
height: typeof window === "undefined" ? 768 : window.innerHeight,
});
const showingHashes = new Set();
let refreshPending = false;
let hideTimer = 0;
const frameStyle = computed(() => {
const win = runtimeState.value?.window ?? {};
const maxWidth = Math.max(220, viewport.value.width - 32);
const maxHeight = Math.max(160, viewport.value.height - 32);
const width = Math.min(clampNumber(win.width, 280, 1200, 640), maxWidth);
const height = Math.min(clampNumber(win.height, 180, 900, 420), maxHeight);
return {
width: `${width}px`,
height: `${height}px`,
maxWidth: "calc(100vw - 32px)",
maxHeight: "calc(100vh - 32px)",
};
});
function asString(value) {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return "";
}
function asBoolean(value) {
return value === true || value === "true" || value === 1 || value === "1";
}
function asNumber(value, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function clampNumber(value, min, max, fallback) {
const parsed = asNumber(value, fallback);
return Math.min(max, Math.max(min, parsed || fallback));
}
function normalizeRuntime(source, preferredSlotId = "") {
const raw = source && typeof source === "object" ? source : {};
const slots = Array.isArray(raw.slots) ? raw.slots : [];
const selectedSlot =
slots.find((slot) => asString(slot?.id) === asString(preferredSlotId)) ||
slots[0] ||
raw;
const slot = selectedSlot && typeof selectedSlot === "object" ? selectedSlot : {};
const win = raw.window && typeof raw.window === "object" ? raw.window : {};
const slotWin = slot.window && typeof slot.window === "object" ? slot.window : win;
return {
id: asString(slot.id) || asString(preferredSlotId) || "1",
available: asBoolean(slot.available),
enabled: asBoolean(slot.enabled),
packageHash: asString(slot.packageHash),
assetBaseURL: asString(slot.assetBaseURL).replace(/\/+$/, ""),
indexURL: asString(slot.indexURL),
window: {
width: Math.round(asNumber(slotWin.width, 640)),
height: Math.round(asNumber(slotWin.height, 420)),
},
};
}
function expectedAdOrigin() {
const baseURL = runtimeState.value?.assetBaseURL;
if (!baseURL) {
return "";
}
try {
return new URL(baseURL).origin;
} catch (_error) {
return "";
}
}
function canOpen(runtime) {
if (!runtime?.available || !runtime.enabled) {
return false;
}
return Boolean(runtime.packageHash && runtime.assetBaseURL);
}
async function openCurrentAd(slotId = "") {
if (visible.value || refreshPending) {
return;
}
refreshPending = true;
try {
const nextRuntime = normalizeRuntime(await getAdRuntime(), slotId);
runtimeState.value = nextRuntime;
if (canOpen(nextRuntime)) {
await showAd(nextRuntime);
}
} catch (_error) {
// 广告入口失败不影响主界面。
} finally {
refreshPending = false;
}
}
async function showAd(runtime) {
const hash = runtime.packageHash;
if (showingHashes.has(hash)) {
return;
}
showingHashes.add(hash);
try {
const indexURL = runtime.indexURL || `${runtime.assetBaseURL}/index.html`;
const separator = indexURL.includes("?") ? "&" : "?";
iframeSrc.value = `${indexURL}${separator}hash=${encodeURIComponent(hash)}&ts=${Date.now()}`;
visible.value = true;
} finally {
showingHashes.delete(hash);
}
}
function closeAd() {
visible.value = false;
if (hideTimer) {
window.clearTimeout(hideTimer);
}
hideTimer = window.setTimeout(() => {
iframeSrc.value = "";
}, 260);
}
function handleMessage(event) {
const origin = expectedAdOrigin();
if (origin && event.origin !== origin) {
return;
}
const data = event.data && typeof event.data === "object" ? event.data : {};
if (data.source !== BRIDGE_SOURCE) {
return;
}
if (data.type === "close") {
closeAd();
return;
}
if (data.type === "openExternal") {
const targetURL = asString(data.url);
if (targetURL) {
void openAdExternalURL(targetURL).catch(() => {});
}
}
}
function handleOpenRequested(event) {
void openCurrentAd(asString(event?.detail?.slotId));
}
function updateViewport() {
viewport.value = {
width: window.innerWidth,
height: window.innerHeight,
};
}
onMounted(() => {
window.addEventListener("message", handleMessage);
window.addEventListener(OPEN_AD_EVENT, handleOpenRequested);
window.addEventListener("resize", updateViewport);
});
onBeforeUnmount(() => {
if (hideTimer) {
window.clearTimeout(hideTimer);
}
window.removeEventListener("message", handleMessage);
window.removeEventListener(OPEN_AD_EVENT, handleOpenRequested);
window.removeEventListener("resize", updateViewport);
});
</script>
<template>
<Teleport to="body">
<Transition name="modal-mask">
<div
v-show="visible"
class="modal-mask-layer fixed inset-0 z-999 flex items-center justify-center bg-black/50 p-4"
>
<Transition name="ad-frame">
<iframe
v-show="visible && iframeSrc"
:src="iframeSrc"
:style="frameStyle"
class="block overflow-hidden rounded-none border-none bg-transparent shadow-[0_25px_50px_-12px_rgba(0,0,0,0.6)]"
sandbox="allow-scripts allow-forms allow-same-origin"
title="Advertisement"
/>
</Transition>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-mask-enter-active,
.modal-mask-leave-active {
transition: opacity 0.25s ease, backdrop-filter 0.25s ease;
}
.modal-mask-enter-from,
.modal-mask-leave-to {
opacity: 0;
backdrop-filter: blur(0);
}
.ad-frame-enter-active,
.ad-frame-leave-active {
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.ad-frame-enter-from,
.ad-frame-leave-to {
opacity: 0;
transform: scale(0.96) translateY(-8px);
}
</style>
+403
View File
@@ -0,0 +1,403 @@
<script setup>
import CacheHitRateChart from "@/components/charts/CacheHitRateChart.vue";
import Switch from "@/components/ui/Switch.vue";
import Tooltip from "@/components/ui/Tooltip.vue";
import { appState, saveIncludeCacheWriteInHitRate } from "@/state/appState";
import { formatCompactInteger, formatInteger } from "@/utils/numberFormat";
import { computed, ref } from "vue";
const emit = defineEmits(["refresh", "open-ad"]);
const TOKEN_PRICE_PER_MILLION = {
input: 5,
output: 25,
cacheRead: 0.5,
cacheWrite: 6.25,
};
const props = defineProps({
metrics: {
type: Object,
required: true,
},
loading: {
type: Boolean,
default: false,
},
error: {
type: String,
default: "",
},
homeAd: {
type: Object,
default: null,
},
homeAds: {
type: Array,
default: () => [],
},
});
const homeMetricsConfigSaving = ref(false);
const homeMetricsConfigError = ref("");
function normalizeNumber(value) {
const number = Number(value);
if (!Number.isFinite(number)) {
return 0;
}
return Math.round(number);
}
function formatMetricValue(value) {
const full = formatInteger(value);
const compact = formatCompactInteger(value);
return full === compact ? full : `${full} (${compact})`;
}
function formatRateLabel(value) {
const rate = Number(value);
if (!Number.isFinite(rate)) {
return "暂无数据";
}
return `${(Math.max(0, Math.min(1, rate)) * 100).toFixed(2)}%`;
}
function calculateRate(numerator, denominator) {
const top = normalizeNumber(numerator);
const bottom = normalizeNumber(denominator);
if (bottom <= 0) {
return null;
}
return top / bottom;
}
function priceTokens(tokens, pricePerMillion) {
return (normalizeNumber(tokens) / 1_000_000) * pricePerMillion;
}
function formatUSD(value) {
const amount = Number(value);
if (!Number.isFinite(amount)) {
return "$0.00";
}
if (amount > 0 && amount < 0.01) {
return "<$0.01";
}
return `$${amount.toFixed(2)}`;
}
const cacheReadTokensTotal = computed(() => normalizeNumber(props.metrics?.cacheReadTokens));
const cacheWriteTokensTotal = computed(() => normalizeNumber(props.metrics?.cacheWriteTokens));
const inputTokensTotal = computed(() => {
const promptTokensTotal = normalizeNumber(props.metrics?.promptTokensTotal);
return Math.max(0, promptTokensTotal - cacheReadTokensTotal.value - cacheWriteTokensTotal.value);
});
const defaultCacheHitRate = computed(() =>
calculateRate(cacheReadTokensTotal.value, cacheReadTokensTotal.value + inputTokensTotal.value),
);
const cacheReuseRate = computed(() =>
calculateRate(
cacheReadTokensTotal.value,
cacheReadTokensTotal.value + cacheWriteTokensTotal.value + inputTokensTotal.value,
),
);
const includeCacheWriteInHitRate = computed(() => appState.includeCacheWriteInHitRate);
const selectedCacheHitRate = computed(() =>
includeCacheWriteInHitRate.value ? cacheReuseRate.value : defaultCacheHitRate.value,
);
const selectedCacheRateModeLabel = computed(() =>
includeCacheWriteInHitRate.value ? "计入缓存创建" : "默认口径",
);
const validTurnsRate = computed(() => {
const turnsTotal = normalizeNumber(props.metrics?.turnsTotal);
if (turnsTotal <= 0) {
return null;
}
return normalizeNumber(props.metrics?.validTurnsTotal) / turnsTotal;
});
const completionTokensTotal = computed(() => {
const requestTokensTotal = normalizeNumber(props.metrics?.requestTokensTotal);
const promptTokensTotal = normalizeNumber(props.metrics?.promptTokensTotal);
return Math.max(0, requestTokensTotal - promptTokensTotal);
});
const estimatedTokenCost = computed(() => {
const input = priceTokens(inputTokensTotal.value, TOKEN_PRICE_PER_MILLION.input);
const output = priceTokens(completionTokensTotal.value, TOKEN_PRICE_PER_MILLION.output);
const cacheRead = priceTokens(cacheReadTokensTotal.value, TOKEN_PRICE_PER_MILLION.cacheRead);
const cacheWrite = priceTokens(cacheWriteTokensTotal.value, TOKEN_PRICE_PER_MILLION.cacheWrite);
return {
input,
output,
cacheRead,
cacheWrite,
total: input + output + cacheRead + cacheWrite,
};
});
const cacheTooltipContent = computed(() => {
const formula = includeCacheWriteInHitRate.value
? "缓存读取 /(缓存读取 + 缓存创建 + 非缓存输入)"
: "缓存读取 /(缓存读取 + 非缓存输入)";
return [
`当前:${formatRateLabel(selectedCacheHitRate.value)}`,
`公式:${formula}`,
`默认 ${formatRateLabel(defaultCacheHitRate.value)} / 计入创建 ${formatRateLabel(cacheReuseRate.value)}`,
].join("\n");
});
const turnsTooltipContent = computed(() =>
[
"按历史记录里扫描到的回合 summary 汇总。",
"",
`总轮次:${formatMetricValue(props.metrics?.turnsTotal)}`,
`有效轮次:${formatMetricValue(props.metrics?.validTurnsTotal)}`,
`异常轮次:${formatMetricValue(props.metrics?.invalidTurnsTotal)}`,
`有效占比:${formatRateLabel(validTurnsRate.value)}`,
].join("\n"),
);
const tokensTooltipContent = computed(() =>
[
"总请求 Token 包含 Prompt 和模型输出。",
"",
`总请求:${formatMetricValue(props.metrics?.requestTokensTotal)}`,
`Prompt${formatMetricValue(props.metrics?.promptTokensTotal)}`,
`输出推算:${formatMetricValue(completionTokensTotal.value)}`,
`非缓存输入:${formatMetricValue(inputTokensTotal.value)}`,
`缓存读取:${formatMetricValue(cacheReadTokensTotal.value)}`,
`缓存写入:${formatMetricValue(cacheWriteTokensTotal.value)}`,
"",
"缓存读写已计入 Prompt 侧统计。",
].join("\n"),
);
const costTooltipContent = computed(() =>
[
"按 Claude Opus 4.7 价格估算。",
`缓存统计策略:${selectedCacheRateModeLabel.value}${formatRateLabel(selectedCacheHitRate.value)}`,
"",
`普通输入:${formatMetricValue(inputTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.input}/1M = ${formatUSD(estimatedTokenCost.value.input)}`,
`模型输出:${formatMetricValue(completionTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.output}/1M = ${formatUSD(estimatedTokenCost.value.output)}`,
`缓存读取:${formatMetricValue(cacheReadTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.cacheRead}/1M = ${formatUSD(estimatedTokenCost.value.cacheRead)}`,
`缓存写入:${formatMetricValue(cacheWriteTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.cacheWrite}/1M = ${formatUSD(estimatedTokenCost.value.cacheWrite)}`,
"",
`合计:${formatUSD(estimatedTokenCost.value.total)}`,
].join("\n"),
);
function normalizeHomeAd(item, index) {
const source = item && typeof item === "object" ? item : {};
const title = typeof source.title === "string" ? source.title.trim() : "";
if (!title) {
return null;
}
return {
id: typeof source.id === "string" && source.id.trim() ? source.id.trim() : String(index + 1),
title,
subtitle: typeof source.subtitle === "string" ? source.subtitle.trim() : "",
};
}
async function toggleIncludeCacheWriteInHitRate(value) {
const nextValue = Boolean(value);
homeMetricsConfigSaving.value = true;
homeMetricsConfigError.value = "";
try {
const result = await saveIncludeCacheWriteInHitRate(nextValue);
if (!result?.ok) {
homeMetricsConfigError.value = result?.error || "保存失败";
}
} catch (error) {
homeMetricsConfigError.value = error?.message || "保存失败";
} finally {
homeMetricsConfigSaving.value = false;
}
}
const normalizedHomeAds = computed(() => {
const list = Array.isArray(props.homeAds) && props.homeAds.length > 0 ? props.homeAds : [props.homeAd];
return list.map(normalizeHomeAd).filter(Boolean);
});
const hasHomeAd = computed(() => normalizedHomeAds.value.length > 0);
</script>
<template>
<div>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-4 h-[42px]">
<div v-if="!hasHomeAd" class="flex flex-col gap-1 w-[200px] shrink-0">
<h2 class="text-[14px] font-medium text-white/80">会话统计</h2>
</div>
<div v-else class="grid min-w-0 grid-cols-3 gap-2 shrink-0">
<div
v-for="ad in normalizedHomeAds"
:key="ad.id"
style="font-family: var(--font-num)"
class="center-row h-[42px] min-w-0 cursor-pointer gap-[8px] rounded-[6px] border border-[#343434] bg-[#242424] px-[8px] pr-[10px] text-left transition-colors duration-150 hover:border-[#4a4a4a] hover:bg-[#2a2a2a] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/50"
role="button"
tabindex="0"
:title="ad.subtitle ? `${ad.title}\n${ad.subtitle}` : ad.title"
@click="emit('open-ad', ad.id)"
@keydown.enter.prevent="emit('open-ad', ad.id)"
@keydown.space.prevent="emit('open-ad', ad.id)"
>
<div
class="center-row h-[20px] w-[20px] shrink-0 justify-center text-[20px] text-amber-400"
>
<span class="icon-[cil--badge]"></span>
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-[13px] font-medium leading-[16px] text-white">
{{ ad.title }}
</div>
<div
v-if="ad.subtitle"
class="mt-[2px] center-row min-w-0 gap-[2px] text-[11px] leading-[12px] text-[#8A8A8A]"
>
<span class="truncate">{{ ad.subtitle }}</span>
</div>
</div>
</div>
</div>
<div
class="flex-1 center-row justify-end shrink-0 gap-2 text-xs text-[#6f6f6f] pr-4 w-[200px]"
>
<span>刷新统计</span>
<button
type="button"
class="center-row justify-center h-[24px] w-[24px] rounded-[6px] border border-[#3b3b3b] bg-[#242424] text-[#9d9d9d] transition-colors duration-150 hover:border-[#4c4c4c] hover:text-white disabled:cursor-not-allowed disabled:opacity-60"
:disabled="loading"
:title="loading ? '刷新中' : '刷新统计'"
@click="emit('refresh')"
>
<span
class="icon-[mdi--refresh] text-[14px]"
:class="{ '!animate-spin': loading }"
></span>
</button>
</div>
</div>
<div
class="mt-[-4px] grid grid-cols-4 gap-0 overflow-hidden rounded-[8px] border border-[#343434] bg-[#242424] h-[130px]"
>
<div class="min-w-0 px-4 py-4 flex flex-col justify-between">
<div class="center-row justify-start gap-1 text-xs text-[#7f7f7f]">
<span>缓存命中率</span>
<Tooltip>
<div class="w-[280px] space-y-3">
<div class="border-b border-[#343434] pb-3">
<Switch
compact
label="计入缓存创建"
description="开启后把缓存创建纳入分母"
enabled-text="当前按复用率口径显示"
disabled-text="当前按默认命中率口径显示"
:enabled="includeCacheWriteInHitRate"
:busy="homeMetricsConfigSaving"
:disabled="homeMetricsConfigSaving"
@change="toggleIncludeCacheWriteInHitRate"
/>
</div>
<div class="whitespace-pre-wrap">{{ cacheTooltipContent }}</div>
<div v-if="homeMetricsConfigError" class="text-[11px] text-[#f87171]">
{{ homeMetricsConfigError }}
</div>
</div>
</Tooltip>
</div>
<CacheHitRateChart :rate="selectedCacheHitRate" />
</div>
<div
class="min-w-0 border-l border-[#343434] px-4 py-4 flex flex-col justify-between"
>
<div class="center-row justify-start gap-1 text-xs text-[#7f7f7f]">
<span>对话轮次</span>
<Tooltip :content="turnsTooltipContent" />
</div>
<div>
<div
class="text-[30px] leading-none text-white"
style="font-family: var(--font-num)"
:title="formatInteger(metrics.turnsTotal)"
>
{{ formatCompactInteger(metrics.turnsTotal) }}
</div>
<div class="mt-3 text-xs leading-5 text-[#8c8c8c]">
有效
<span :title="formatInteger(metrics.validTurnsTotal)">
{{ formatCompactInteger(metrics.validTurnsTotal) }}
</span>
/ 异常
<span :title="formatInteger(metrics.invalidTurnsTotal)">
{{ formatCompactInteger(metrics.invalidTurnsTotal) }}
</span>
</div>
</div>
</div>
<div
class="min-w-0 border-l border-[#343434] px-4 py-4 flex flex-col justify-between"
>
<div class="center-row justify-start gap-1 text-xs text-[#7f7f7f]">
<span>Token 消耗</span>
<Tooltip :content="tokensTooltipContent" />
</div>
<div>
<div
class="truncate text-[30px] leading-none text-white"
style="font-family: var(--font-num)"
:title="formatInteger(metrics.requestTokensTotal)"
>
{{ formatCompactInteger(metrics.requestTokensTotal) }}
</div>
<div class="mt-3 text-xs leading-5 text-[#8c8c8c]">
Prompt
<span :title="formatInteger(metrics.promptTokensTotal)">
{{ formatCompactInteger(metrics.promptTokensTotal) }}
</span>
</div>
</div>
</div>
<div
class="min-w-0 border-l border-[#343434] px-4 py-4 flex flex-col justify-between"
>
<div class="center-row justify-start gap-1 text-xs text-[#7f7f7f]">
<span>价值估算</span>
<Tooltip :content="costTooltipContent" />
</div>
<div>
<div
class="truncate text-[30px] leading-none text-white"
style="font-family: var(--font-num)"
:title="formatUSD(estimatedTokenCost.total)"
>
{{ formatUSD(estimatedTokenCost.total) }}
</div>
<div class="mt-3 text-xs leading-5 text-[#8c8c8c]">
缓存读写
<span :title="formatUSD(estimatedTokenCost.cacheRead + estimatedTokenCost.cacheWrite)">
{{ formatUSD(estimatedTokenCost.cacheRead + estimatedTokenCost.cacheWrite) }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped></style>
+30
View File
@@ -0,0 +1,30 @@
<script setup>
import Select from "@/components/ui/Select.vue";
import { useLocale } from "@/i18n/runtime";
const props = defineProps({
border: { type: Boolean, default: true },
ariaLabel: { type: String, default: "界面语言" },
buttonClass: { type: String, default: "" },
menuClass: { type: String, default: "" },
wrapperClass: { type: String, default: "w-[180px] max-w-full" },
placeholder: { type: String, default: "选择语言" },
});
const { locale, localeOptions, setLocale } = useLocale();
</script>
<template>
<div :class="wrapperClass">
<Select
:model-value="locale"
:options="localeOptions"
:border="border"
:aria-label="ariaLabel"
:button-class="buttonClass"
:menu-class="menuClass"
:placeholder="placeholder"
@update:model-value="setLocale"
/>
</div>
</template>
@@ -0,0 +1,325 @@
<script setup>
import Button from "@/components/ui/Button.vue";
import Select from "@/components/ui/Select.vue";
import Tooltip from "@/components/ui/Tooltip.vue";
import {
ANTHROPIC_THINKING_EFFORT_DEFAULT,
createEmptyModelAdapter,
normalizeModelAdapter,
OPENAI_ENDPOINT_CHAT_COMPLETIONS,
OPENAI_ENDPOINT_RESPONSES,
OPENAI_EXTRA_PARAMS_DEFAULT_JSON,
} from "@/state/appState";
import { computed, reactive, watch } from "vue";
const modelTypeOptions = [
{ 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]" },
];
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: "最大", 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]" },
];
const fieldTips = {
openAIExtraParams: "开启后会把 JSON 对象合并到 OpenAI 请求体。OpenAI service_tier 支持 auto、default、flex、scale、prioritypriority 可用于高优先级/Fast 类场景。",
};
const props = defineProps({
visible: { type: Boolean, default: false },
title: { type: String, default: "模型配置" },
adapter: {
type: Object,
default: () => createEmptyModelAdapter(),
},
errorMessage: { type: String, default: "" },
});
const emit = defineEmits(["cancel", "save"]);
const draft = reactive(createEmptyModelAdapter());
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");
function ensureOpenAIExtraParamsJSON() {
if (!String(draft.openAIExtraParamsJSON || "").trim()) {
draft.openAIExtraParamsJSON = OPENAI_EXTRA_PARAMS_DEFAULT_JSON;
}
}
function ensureAnthropicThinkingEffort() {
if (!String(draft.anthropicThinkingEffort || "").trim()) {
draft.anthropicThinkingEffort = ANTHROPIC_THINKING_EFFORT_DEFAULT;
}
}
function syncDraft() {
Object.assign(draft, normalizeModelAdapter(props.adapter));
if (!draft.type) {
draft.type = "openai";
}
}
watch(() => props.visible, (visible) => {
if (visible) {
syncDraft();
}
}, { immediate: true });
watch(() => props.adapter, () => {
if (props.visible) {
syncDraft();
}
});
watch(() => draft.type, (type) => {
if (type === "openai" && !draft.openAIEndpoint) {
draft.openAIEndpoint = OPENAI_ENDPOINT_RESPONSES;
} else if (type === "anthropic") {
ensureAnthropicThinkingEffort();
}
});
watch(() => draft.openAIExtraParamsEnabled, (enabled) => {
if (enabled) {
ensureOpenAIExtraParamsJSON();
}
});
function handleCancel() {
emit("cancel");
}
function handleSave() {
emit("save", normalizeModelAdapter(draft));
}
</script>
<template>
<Teleport to="body">
<Transition name="modal-mask">
<div
v-show="visible"
class="fixed inset-0 z-999 flex items-center justify-center bg-black/50 p-4"
@click.self="handleCancel"
>
<Transition name="modal-content">
<div
v-show="visible"
class="relative z-10 w-full max-w-[560px] 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%);"
@click.stop
>
<div class="rounded-[7px] bg-[#292929] p-5">
<h3 class="mb-4 text-base font-medium text-white">{{ title }}</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="flex flex-col gap-1">
<span class="text-sm text-[#d4d4d4]">显示名称</span>
<input
v-model="draft.displayName"
type="text"
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="text-sm text-[#d4d4d4]">ModelID</span>
<input
v-model="draft.modelID"
type="text"
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="text-sm text-[#d4d4d4]">类型</span>
<Select
v-model="draft.type"
:options="modelTypeOptions"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm text-[#d4d4d4]">API Key</span>
<input
v-model="draft.apiKey"
type="text"
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</label>
</div>
<label class="mt-3 flex flex-col gap-1">
<span class="text-sm text-[#d4d4d4]">baseURL</span>
<input
v-model="draft.baseURL"
type="text"
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</label>
<label class="mt-3 flex flex-col gap-1">
<span class="text-sm text-[#d4d4d4]">context_window_tokens</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>
<div v-if="draft.type === 'openai'" class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="flex flex-col gap-1">
<span class="text-sm text-[#d4d4d4]">reasoning_effort</span>
<Select
v-model="draft.reasoningEffort"
:options="reasoningEffortOptions"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm text-[#d4d4d4]">max token</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="text-sm text-[#d4d4d4]">endpoint</span>
<Select
v-model="draft.openAIEndpoint"
:options="openAIEndpointOptions"
/>
</label>
</div>
<div v-if="draft.type === 'openai'" class="mt-3 rounded-[8px] border border-[#343434] bg-[#252525] p-3">
<div class="flex items-center justify-between gap-3">
<span class="flex items-center gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="fieldTips.openAIExtraParams" />
<span>额外参数 JSON</span>
</span>
<label class="flex items-center 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="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="flex flex-col gap-1">
<span class="text-sm text-[#d4d4d4]">max_tokens</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 class="flex flex-col gap-1">
<span class="text-sm text-[#d4d4d4]">thinking effort</span>
<Select
v-model="draft.anthropicThinkingEffort"
:options="anthropicThinkingEffortOptions"
/>
</label>
</div>
<label class="mt-3 flex flex-col gap-1">
<span class="text-sm text-[#d4d4d4]">tooltipData</span>
<textarea
v-model="draft.tooltipData"
rows="5"
class="min-h-[120px] resize-none rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 py-2 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</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>
</div>
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-mask-enter-active,
.modal-mask-leave-active {
transition: opacity 0.25s ease, backdrop-filter 0.25s ease;
}
.modal-mask-enter-from,
.modal-mask-leave-to {
opacity: 0;
backdrop-filter: blur(0);
}
.modal-content-enter-active,
.modal-content-leave-active {
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal-content-enter-from,
.modal-content-leave-to {
opacity: 0;
transform: scale(0.9) translateY(-10px);
}
</style>
@@ -0,0 +1,154 @@
<script setup>
import { computed } from "vue";
import Tooltip from "@/components/ui/Tooltip.vue";
import { formatDuration } from "@/state/appState";
const props = defineProps({
result: {
type: Object,
default: null,
},
stale: {
type: Boolean,
default: false,
},
compact: {
type: Boolean,
default: false,
},
showMetrics: {
type: Boolean,
default: false,
},
title: {
type: String,
default: "模型测试",
},
emptyText: {
type: String,
default: "尚未测试",
},
});
const normalizedStatus = computed(() => {
const status = String(props.result?.status || "").trim().toLowerCase();
return ["running", "success", "error"].includes(status) ? status : "idle";
});
const summaryText = computed(() => {
const text = String(props.result?.summaryText || "").trim();
if (text) {
return text;
}
if (normalizedStatus.value === "running") {
return "测试中...";
}
if (normalizedStatus.value === "error") {
return "测试失败";
}
return props.emptyText;
});
const rawResponseText = computed(() => {
const raw = String(props.result?.rawResponse || "").trim();
if (raw) {
return raw;
}
if (normalizedStatus.value === "error") {
return String(props.result?.error || "").trim();
}
return "";
});
const panelClass = computed(() => {
if (props.stale) {
return "border-[#6b5b1e] bg-[#2c2612]";
}
if (normalizedStatus.value === "running") {
return "border-[#164e63] bg-[#0b2530]";
}
if (normalizedStatus.value === "error") {
return "border-[#4b1d1d] bg-[#2a1313]";
}
if (normalizedStatus.value === "success" && props.result?.tokensEstimated) {
return "border-[#5a4314] bg-[#2f2612]";
}
if (normalizedStatus.value === "success") {
return "border-[#14532d] bg-[#102418]";
}
return "border-[#343434] bg-[#232323]";
});
const summaryClass = computed(() => {
if (props.stale) {
return "text-[#f6d77a]";
}
if (normalizedStatus.value === "running") {
return "text-[#67e8f9]";
}
if (normalizedStatus.value === "error") {
return "text-[#fca5a5]";
}
if (normalizedStatus.value === "success" && props.result?.tokensEstimated) {
return "text-[#fcd34d]";
}
if (normalizedStatus.value === "success") {
return "text-[#86efac]";
}
return "text-[#a3a3a3]";
});
</script>
<template>
<div class="rounded-[8px] border 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'"
>
{{ title }}
</div>
<div v-if="rawResponseText" class="center-row gap-1 text-[11px] text-[#8f8f8f]">
<span>原始返回</span>
<Tooltip :content="rawResponseText" copyable />
</div>
</div>
<div class="mt-1 text-sm leading-relaxed" :class="summaryClass">
{{ 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]">
配置已变更请重新测试
</div>
<div
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="text-[11px] uppercase tracking-[0.08em] text-[#666]">总耗时</div>
<div class="mt-1 text-sm text-[#d4d4d4]">{{ formatDuration(result?.totalDurationMS) }}</div>
</div>
<div class="rounded-[8px] bg-[#1c1c1c] 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>
</div>
<div
v-if="normalizedStatus === 'success' && result?.tokensEstimated"
class="mt-2 text-xs text-[#8f8f8f]"
>
输出 Token 为估算值
</div>
</div>
</template>
@@ -0,0 +1,128 @@
<script setup>
import {
ArcElement,
Chart as ChartJS,
Tooltip,
} from "chart.js";
import { computed } from "vue";
import { Doughnut } from "vue-chartjs";
ChartJS.register(ArcElement, Tooltip);
const props = defineProps({
rate: {
type: Number,
default: 0,
},
});
const percentage = computed(() => {
const rate = Number(props.rate);
if (!Number.isFinite(rate)) {
return 0;
}
return Math.max(0, Math.min(100, rate * 100));
});
const label = computed(() => {
const rate = Number(props.rate);
if (!Number.isFinite(rate)) {
return "--";
}
return `${percentage.value.toFixed(2)}%`;
});
function getSegmentBorderRadius(dataIndex) {
const radius = 5;
if (percentage.value <= 0) {
return dataIndex === 1
? {
outerStart: radius,
outerEnd: radius,
innerStart: radius,
innerEnd: radius,
}
: 0;
}
if (percentage.value >= 100) {
return dataIndex === 0
? {
outerStart: radius,
outerEnd: radius,
innerStart: radius,
innerEnd: radius,
}
: 0;
}
return dataIndex === 0
? {
outerStart: radius,
outerEnd: 0,
innerStart: radius,
innerEnd: 0,
}
: {
outerStart: 0,
outerEnd: radius,
innerStart: 0,
innerEnd: radius,
};
}
const chartData = computed(() => ({
labels: ["命中", "未命中"],
datasets: [
{
data: [percentage.value, Math.max(0, 100 - percentage.value)],
backgroundColor: ["#4ade80", "#373737"],
borderWidth: 0,
hoverBorderWidth: 0,
selfJoin: false,
borderRadius: ({ dataIndex }) => getSegmentBorderRadius(dataIndex),
},
],
}));
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
cutout: "82%",
rotation: -90,
circumference: 180,
animation: {
duration: 450,
},
events: [],
plugins: {
legend: {
display: false,
},
tooltip: {
enabled: false,
},
},
};
</script>
<template>
<div class="flex flex-col items-center gap-3">
<div
class="relative h-[82px] w-[132px] shrink-0"
role="img"
:aria-label="`缓存命中率 ${label}`"
>
<Doughnut class="h-full w-full" :data="chartData" :options="chartOptions" />
<div class="pointer-events-none absolute inset-x-0 bottom-[10px] flex justify-center">
<div
class="text-[20px] leading-none text-white"
style="font-family: var(--font-num)"
>
{{ label }}
</div>
</div>
</div>
</div>
</template>
+38
View File
@@ -0,0 +1,38 @@
<script setup>
defineProps({
variant: {
type: String,
default: "default",
validator: (v) => ["default", "primary", "text"].includes(v),
},
});
</script>
<template>
<button
v-if="variant === 'text'"
type="button"
class="!whitespace-nowrap shrink-0 cursor-pointer text-sm text-[#a3a3a3] transition-colors duration-150 active:text-[#10AD5D] hover:text-[#10ad5cd9]"
>
<slot />
</button>
<button
v-else
type="button"
class="!whitespace-nowrap relative cursor-pointer overflow-hidden center-row min-h-[24px] gap-[2px] rounded-[6px] text-sm transition-transform duration-150 active:scale-105"
:class="{
'bg-[linear-gradient(to_bottom,#656565_0%,#3A3A3A_10px,#3A3A3A_100%)]': variant === 'default',
'bg-gradient-to-b from-[#1D8010] to-[#25B433]': variant === 'primary',
}"
>
<span
class="relative center-row z-10 w-full justify-center rounded-[5px] !px-[7px] py-[3px] text-white transition-colors"
:class="{
'bg-gradient-to-b from-[#2a2a2a] to-[#1f1f1f] ': variant === 'default',
'font-medium bg-gradient-to-b from-[#10AD5D] to-[#0F8A4C] ': variant === 'primary',
}"
>
<slot />
</span>
</button>
</template>
+12
View File
@@ -0,0 +1,12 @@
<script setup></script>
<template>
<div
class="rounded-[8px] p-[1px]"
style="background: linear-gradient(to bottom, #656565 0%, #3A3A3A 10px, #3A3A3A 100%);"
>
<div class="rounded-[7px] bg-[#292929] p-4">
<slot />
</div>
</div>
</template>
+80
View File
@@ -0,0 +1,80 @@
<script setup>
import { computed, ref, useAttrs, watch } from "vue";
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
modelValue: { type: String, default: "" },
type: { type: String, default: "text" },
placeholder: { type: String, default: "" },
disabled: { type: Boolean, default: false },
allowVisibilityToggle: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue"]);
const attrs = useAttrs();
const isPasswordVisible = ref(false);
const canToggleVisibility = computed(() => props.type === "password" && props.allowVisibilityToggle);
const inputType = computed(() => {
if (!canToggleVisibility.value) {
return props.type;
}
return isPasswordVisible.value ? "text" : "password";
});
watch(
() => [props.type, props.allowVisibilityToggle],
([type, allowVisibilityToggle]) => {
if (type !== "password" || !allowVisibilityToggle) {
isPasswordVisible.value = false;
}
},
{ immediate: true },
);
function handleInput(event) {
emit("update:modelValue", event?.target?.value ?? "");
}
function toggleVisibility() {
if (!canToggleVisibility.value || props.disabled) {
return;
}
isPasswordVisible.value = !isPasswordVisible.value;
}
</script>
<template>
<div class="relative w-full">
<input
v-bind="attrs"
:value="modelValue"
:type="inputType"
:placeholder="placeholder"
:disabled="disabled"
class="h-9 w-full rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none transition-colors focus:border-[#10AD5D] disabled:cursor-not-allowed disabled:opacity-60"
:class="canToggleVisibility ? 'pr-10' : ''"
@input="handleInput"
/>
<button
v-if="canToggleVisibility"
type="button"
class="absolute inset-y-0 right-0 center-row px-3 text-[#8f8f8f] transition-colors hover:text-[#d4d4d4] focus:text-[#d4d4d4] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
:aria-label="isPasswordVisible ? '隐藏访问密钥' : '显示访问密钥'"
:aria-pressed="isPasswordVisible"
:disabled="disabled"
@click="toggleVisibility"
>
<span
:class="[
isPasswordVisible ? 'icon-[mdi--eye-off-outline]' : 'icon-[mdi--eye-outline]',
'text-[18px]',
]"
></span>
</button>
</div>
</template>
+100
View File
@@ -0,0 +1,100 @@
<script setup>
import Button from "@/components/ui/Button.vue";
const props = defineProps({
visible: { type: Boolean, default: false },
title: { type: String, default: "提示" },
content: { type: String, default: "" },
placeholder: { type: String, default: "" },
modelValue: { type: String, default: "" },
});
const emit = defineEmits(["update:visible", "update:modelValue", "confirm", "cancel"]);
function handleConfirm() {
emit("confirm");
emit("update:visible", false);
}
function handleCancel() {
emit("cancel");
emit("update:visible", false);
}
function onMaskClick() {
handleCancel();
}
function onInput(event) {
emit("update:modelValue", event?.target?.value ?? "");
}
function onEnter(event) {
event.preventDefault();
handleConfirm();
}
</script>
<template>
<Teleport to="body">
<Transition name="modal-mask">
<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="onMaskClick"
>
<Transition name="modal-content">
<div
v-show="visible"
class="relative z-10 w-full max-w-[380px] 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%);"
@click.stop
>
<div class="rounded-[7px] bg-[#292929] p-5">
<h3 class="mb-3 text-base font-medium text-white">
{{ title }}
</h3>
<p class="mb-3 text-sm leading-relaxed text-[#a3a3a3]">
{{ content }}
</p>
<input
:value="modelValue"
:placeholder="placeholder"
type="text"
class="mb-5 h-9 w-full rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
@input="onInput"
@keydown.enter="onEnter"
/>
<div class="flex justify-end gap-2">
<Button variant="default" @click="handleCancel">取消</Button>
<Button variant="primary" @click="handleConfirm">确定</Button>
</div>
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-mask-enter-active,
.modal-mask-leave-active {
transition: opacity 0.25s ease, backdrop-filter 0.25s ease;
}
.modal-mask-enter-from,
.modal-mask-leave-to {
opacity: 0;
backdrop-filter: blur(0);
}
.modal-content-enter-active,
.modal-content-leave-active {
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal-content-enter-from,
.modal-content-leave-to {
opacity: 0;
transform: scale(0.9) translateY(-10px);
}
</style>
@@ -0,0 +1,83 @@
<script setup>
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>
</template>
<style scoped>
.message-slide-enter-active,
.message-slide-leave-active {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.message-slide-enter-from {
opacity: 0;
transform: translateY(-12px);
}
.message-slide-enter-to,
.message-slide-leave-from {
opacity: 1;
transform: translateY(0);
}
.message-slide-leave-to {
opacity: 0;
transform: translateY(-12px);
}
</style>
+85
View File
@@ -0,0 +1,85 @@
<script setup>
import Button from "@/components/ui/Button.vue";
const props = defineProps({
visible: { type: Boolean, default: false },
title: { type: String, default: "提示" },
content: { type: String, default: "" },
confirmText: { type: String, default: "确定" },
cancelText: { type: String, default: "取消" },
showCancel: { type: Boolean, default: true },
confirmDisabled: { type: Boolean, default: false },
});
const emit = defineEmits(["update:visible", "confirm", "cancel"]);
function handleConfirm() {
emit("confirm");
emit("update:visible", false);
}
function handleCancel() {
emit("cancel");
emit("update:visible", false);
}
function onMaskClick() {
handleCancel();
}
</script>
<template>
<Teleport to="body">
<Transition name="modal-mask">
<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="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%);"
@click.stop
>
<div class="rounded-[7px] bg-[#292929] p-5">
<h3 class="mb-3 text-base font-medium text-white">
{{ title }}
</h3>
<p class="mb-5 max-h-[55vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed text-[#a3a3a3]">
{{ content }}
</p>
<div class="flex justify-end gap-2">
<Button v-if="showCancel" variant="default" @click="handleCancel">{{ cancelText }}</Button>
<Button variant="primary" :disabled="confirmDisabled" @click="handleConfirm">{{ confirmText }}</Button>
</div>
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-mask-enter-active,
.modal-mask-leave-active {
transition: opacity 0.25s ease, backdrop-filter 0.25s ease;
}
.modal-mask-enter-from,
.modal-mask-leave-to {
opacity: 0;
backdrop-filter: blur(0);
}
.modal-content-enter-active,
.modal-content-leave-active {
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal-content-enter-from,
.modal-content-leave-to {
opacity: 0;
transform: scale(0.9) translateY(-10px);
}
</style>
+343
View File
@@ -0,0 +1,343 @@
<script setup>
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
import { computed, nextTick, onBeforeUnmount, ref, watch, watchPostEffect } from "vue";
const props = defineProps({
modelValue: { type: String, default: "" },
options: {
type: Array,
default: () => [],
},
placeholder: { type: String, default: "请选择" },
disabled: { type: Boolean, default: false },
border: { type: Boolean, default: true },
ariaLabel: { type: String, default: "" },
buttonClass: { type: String, default: "" },
menuClass: { type: String, default: "" },
});
const emit = defineEmits(["update:modelValue", "change", "blur"]);
const rootRef = ref(null);
const buttonRef = ref(null);
const menuRef = ref(null);
const optionRefs = ref([]);
const isOpen = ref(false);
const activeIndex = ref(-1);
const menuStyle = ref({});
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 ?? option?.iconClass ?? "",
};
}));
const selectedOption = computed(() => normalizedOptions.value.find((option) => option.value === props.modelValue) ?? null);
const selectedLabel = computed(() => selectedOption.value?.label || props.placeholder);
function setOptionRef(el, index) {
if (el) {
optionRefs.value[index] = el;
return;
}
delete optionRefs.value[index];
}
function focusActiveOption() {
nextTick(() => {
const option = optionRefs.value[activeIndex.value];
option?.focus();
});
}
function openMenu() {
if (props.disabled || isOpen.value) {
return;
}
isOpen.value = true;
const selectedIndex = normalizedOptions.value.findIndex((option) => option.value === props.modelValue);
activeIndex.value = selectedIndex >= 0 ? selectedIndex : 0;
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());
}
emit("blur");
}
function toggleMenu() {
if (isOpen.value) {
closeMenu();
return;
}
openMenu();
}
function selectOption(option) {
if (!option || option.value === props.modelValue) {
closeMenu({ restoreFocus: true });
return;
}
emit("update:modelValue", option.value);
emit("change", option.value);
closeMenu({ restoreFocus: true });
}
function moveActiveIndex(step) {
if (!normalizedOptions.value.length) {
return;
}
if (!isOpen.value) {
openMenu();
return;
}
const total = normalizedOptions.value.length;
const current = activeIndex.value >= 0 ? activeIndex.value : 0;
activeIndex.value = (current + step + total) % total;
focusActiveOption();
}
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();
selectOption(option);
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, 160)}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(() => props.modelValue, () => {
if (!isOpen.value) {
return;
}
const selectedIndex = normalizedOptions.value.findIndex((option) => option.value === props.modelValue);
activeIndex.value = selectedIndex >= 0 ? selectedIndex : 0;
});
watch(isOpen, (open) => {
if (open) {
document.addEventListener("pointerdown", handlePointerDown);
return;
}
document.removeEventListener("pointerdown", handlePointerDown);
});
onBeforeUnmount(() => {
document.removeEventListener("pointerdown", handlePointerDown);
});
</script>
<template>
<div ref="rootRef" class="relative">
<button
ref="buttonRef"
type="button"
:disabled="disabled"
class="flex h-9 items-center rounded-[6px] bg-[#232323] px-3 text-left text-sm text-[#e5e5e5] outline-none transition-colors disabled:cursor-not-allowed disabled:opacity-60"
:class="[
border
? 'w-full justify-between gap-2 border border-[#3f3f3f] focus:border-[#10AD5D]'
: 'w-auto justify-start gap-2 border border-transparent focus-visible:ring-2 focus-visible:ring-[#10AD5D]/35',
buttonClass,
]"
:aria-expanded="isOpen"
:aria-label="ariaLabel || undefined"
aria-haspopup="listbox"
@click="toggleMenu"
@keydown="handleButtonKeydown"
>
<span
class="flex min-w-0 items-center gap-2"
:class="[
border ? 'flex-1' : 'shrink-0',
selectedOption
? (border ? 'text-[#e5e5e5]' : 'text-current')
: 'text-[#7b7b7b]',
]"
>
<span v-if="selectedOption?.icon" :class="[selectedOption.icon, 'text-[16px] shrink-0']" aria-hidden="true"></span>
<span class="truncate">{{ selectedLabel }}</span>
</span>
<span
class="pointer-events-none center-row transition-transform duration-200"
:class="[border ? 'text-[#8f8f8f]' : 'text-current', 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] overflow-hidden rounded-[8px] border border-[#3f3f3f] bg-[#232323] p-1 shadow-[0_16px_30px_-12px_rgba(0,0,0,0.7)]"
:class="menuClass"
:style="menuStyle"
>
<ul role="listbox" 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 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]',
activeIndex === index ? 'bg-[#303030]' : '',
]"
:aria-selected="option.value === modelValue"
tabindex="0"
@click="selectOption(option)"
@mouseenter="activeIndex = index"
@keydown="handleOptionKeydown($event, option, index)"
>
<span class="flex min-w-0 items-center gap-2">
<span v-if="option.icon" :class="[option.icon, 'text-[16px] shrink-0']" aria-hidden="true"></span>
<span class="truncate">{{ option.label }}</span>
</span>
</button>
</li>
</ul>
</div>
</Transition>
</Teleport>
</template>
+65
View File
@@ -0,0 +1,65 @@
<script setup>
const props = defineProps({
enabled: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
busy: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
label: { type: String, default: "" },
description: { type: String, default: "" },
enabledText: { type: String, default: "已开启" },
disabledText: { type: String, default: "已关闭" },
busyText: { type: String, default: "切换中..." },
});
const emit = defineEmits(["change"]);
function handleToggle() {
if (props.disabled || props.busy) {
return;
}
emit("change", !props.enabled);
}
</script>
<template>
<div
class="flex items-center justify-between gap-4"
:class="compact ? 'py-0' : 'py-1'"
>
<div class="flex min-w-0 flex-col" :class="compact ? 'gap-[2px]' : 'gap-1'">
<div :class="compact ? 'text-[12px]' : 'text-sm'" class="font-medium text-white">
{{ label }}
</div>
<div
v-if="description"
:class="compact ? 'text-[11px] leading-[16px]' : 'text-xs'"
class="text-[#a3a3a3]"
>
{{ description }}
</div>
<div
:class="[
compact ? 'text-[11px] leading-[16px]' : 'text-xs',
enabled ? 'text-[#10AD5D]' : 'text-[#a3a3a3]',
]"
>
{{ busy ? busyText : enabled ? enabledText : disabledText }}
</div>
</div>
<button
type="button"
role="switch"
:aria-checked="enabled"
:disabled="disabled || busy"
class="relative inline-flex h-[22px] w-[40px] shrink-0 cursor-pointer rounded-full outline-none transition-all duration-200 ease-out disabled:cursor-not-allowed disabled:opacity-55 focus-visible:ring-2 focus-visible:ring-[#10AD5D]/35"
:class="enabled ? 'bg-[#10AD5D]' : 'bg-[rgba(255,255,255,0.22)]'"
@click="handleToggle"
>
<span
class="absolute left-[2px] top-[2px] inline-flex h-[18px] w-[18px] rounded-full bg-white shadow-[0_2px_5px_rgba(0,0,0,0.22)] transition-all duration-200 ease-out"
:class="enabled ? 'translate-x-[18px]' : 'translate-x-0'"
/>
</button>
</div>
</template>
+156
View File
@@ -0,0 +1,156 @@
<script setup>
import { autoUpdate, computePosition, flip, offset, shift } from "@floating-ui/dom";
import copyTextToClipboard from "copy-text-to-clipboard";
import { computed, nextTick, onBeforeUnmount, ref, useSlots, watchPostEffect } from "vue";
const props = defineProps({
content: { type: String, default: "" },
copyable: { type: Boolean, default: false },
copyText: { type: String, default: "" },
});
const slots = useSlots();
const HIDE_DELAY_MS = 300;
const COPY_RESET_DELAY_MS = 1500;
const triggerRef = ref(null);
const tooltipRef = ref(null);
const isOpen = ref(false);
const tooltipStyle = ref({});
const copied = ref(false);
let hideTimer = null;
let copyResetTimer = null;
const copyValue = computed(() => String(props.copyText || props.content || "").trim());
const hasContent = computed(() => !!props.content || !!slots.default);
const showCopyButton = computed(() => props.copyable && !!copyValue.value);
function showTooltip() {
if (!hasContent.value) {
return;
}
clearHideTimer();
isOpen.value = true;
nextTick(() => {
updatePosition();
});
}
function hideTooltip() {
isOpen.value = false;
}
function clearHideTimer() {
if (hideTimer) {
window.clearTimeout(hideTimer);
hideTimer = null;
}
}
function clearCopyResetTimer() {
if (copyResetTimer) {
window.clearTimeout(copyResetTimer);
copyResetTimer = null;
}
}
function scheduleHideTooltip() {
clearHideTimer();
hideTimer = window.setTimeout(() => {
hideTooltip();
hideTimer = null;
}, HIDE_DELAY_MS);
}
function updatePosition() {
if (!triggerRef.value || !tooltipRef.value) {
return;
}
computePosition(triggerRef.value, tooltipRef.value, {
placement: "top",
middleware: [
offset(10),
flip({ padding: 12 }),
shift({ padding: 12 }),
],
}).then(({ x, y }) => {
tooltipStyle.value = {
left: `${x}px`,
top: `${y}px`,
};
});
}
function handleCopy() {
if (!copyValue.value) {
return;
}
copyTextToClipboard(copyValue.value);
copied.value = true;
clearCopyResetTimer();
copyResetTimer = window.setTimeout(() => {
copied.value = false;
copyResetTimer = null;
}, COPY_RESET_DELAY_MS);
}
watchPostEffect((cleanup) => {
if (!isOpen.value || !triggerRef.value || !tooltipRef.value) {
return;
}
const stop = autoUpdate(triggerRef.value, tooltipRef.value, updatePosition);
cleanup(() => {
stop();
});
});
onBeforeUnmount(() => {
clearHideTimer();
clearCopyResetTimer();
hideTooltip();
});
</script>
<template>
<span class="inline-flex">
<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]"
@mouseenter="showTooltip"
@mouseleave="scheduleHideTooltip"
@focus="showTooltip"
@blur="scheduleHideTooltip"
>
<span class="icon-[mdi--information-outline] text-[14px]"></span>
</button>
<Teleport to="body">
<div
v-if="isOpen"
ref="tooltipRef"
class="fixed z-[10000] flex max-h-[320px] max-w-[420px] flex-col overflow-hidden rounded-[8px] border border-[#3f3f3f] bg-[#202020] px-3 py-2 text-left text-[12px] leading-relaxed text-[#d4d4d4] shadow-[0_12px_32px_rgba(0,0,0,0.45)]"
:style="tooltipStyle"
@mouseenter="showTooltip"
@mouseleave="scheduleHideTooltip"
>
<div v-if="showCopyButton" class="mb-2 flex shrink-0 justify-end">
<button
type="button"
class="center-row gap-1 rounded-[6px] border border-[#3f3f3f] bg-[#272727] px-2 py-1 text-[11px] text-[#d4d4d4] transition-colors duration-150 hover:border-[#4c4c4c] hover:bg-[#2f2f2f]"
@click="handleCopy"
>
<span :class="copied ? 'icon-[mdi--check]' : 'icon-[mdi--content-copy]'" class="text-[13px]"></span>
<span>{{ copied ? "已复制" : "拷贝" }}</span>
</button>
</div>
<div class="min-h-0 overflow-auto break-words">
<slot>
<div class="whitespace-pre-wrap">{{ content }}</div>
</slot>
</div>
</div>
</Teleport>
</span>
</template>