mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-18 12:07:02 +08:00
v0.3.8
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user