refactor: 重构UI组件库

This commit is contained in:
2025-12-22 05:45:09 +07:00
parent de18ebf370
commit 66e2222c48
18 changed files with 38 additions and 54 deletions

View File

@@ -1,17 +0,0 @@
<script lang='ts' setup></script>
<template>
<ion-avatar v-bind="$attrs">
<img
src="https://api.iconify.design/material-icon-theme:remix-light.svg"
alt="Avatar"
>
</ion-avatar>
</template>
<style lang='css' scoped>
ion-avatar {
background-color: var(--ion-color-success);
padding: 10px;
}
</style>

View File

@@ -1,2 +0,0 @@
export { default as Collapse } from "./index.vue";
export type * from "./types";

View File

@@ -1,138 +0,0 @@
<script lang='ts' setup>
import type { CollapseEmits, CollapseProps } from "./types";
import { IonIcon } from "@ionic/vue";
import { chevronDownOutline } from "ionicons/icons";
import { nextTick, ref } from "vue";
const props = withDefaults(defineProps<CollapseProps>(), {
disabled: false,
bordered: true,
size: "medium",
});
defineEmits<CollapseEmits>();
const active = defineModel<boolean>("active", { type: Boolean, default: true });
const contentRef = ref<HTMLElement>();
async function toggle() {
if (props.disabled)
return;
if (!active.value) {
// 展开
active.value = true;
await nextTick();
if (contentRef.value) {
const height = contentRef.value.scrollHeight;
contentRef.value.style.height = "0px";
requestAnimationFrame(() => {
contentRef.value!.style.height = `${height}px`;
});
}
}
else {
// 收起
if (contentRef.value) {
const height = contentRef.value.scrollHeight;
contentRef.value.style.height = `${height}px`;
requestAnimationFrame(() => {
contentRef.value!.style.height = "0px";
});
}
setTimeout(() => {
active.value = false;
}, 300);
}
setTimeout(() => {
if (contentRef.value && active.value) {
contentRef.value.style.height = "auto";
}
}, 300);
}
function onTransitionEnd() {
if (contentRef.value && !active.value) {
contentRef.value.style.height = "0px";
}
}
// 暴露给父组件的方法和属性
defineExpose({
toggle,
});
</script>
<template>
<div
class="w-full overflow-hidden"
:class="{
'border border-gray-200 dark:border-gray-600 rounded-lg': bordered,
'opacity-60': disabled,
}"
>
<!-- 头部触发区域 -->
<div
class="flex items-center justify-between cursor-pointer transition-colors duration-200 select-none"
:class="[
{
'border-b border-gray-200 dark:border-gray-600': bordered,
},
{
'p-3 text-sm': size === 'small',
'p-4 text-base': size === 'medium',
'p-5 text-lg': size === 'large',
},
{
'cursor-not-allowed': disabled,
},
]"
@click="toggle"
>
<div class="font-medium text-gray-900 dark:text-white flex-1">
<slot name="title">
{{ title }}
</slot>
</div>
<IonIcon
:icon="chevronDownOutline"
class="text-gray-500 dark:text-gray-400 transition-transform duration-300"
:class="[
{
'w-4 h-4': size === 'small',
'w-5 h-5': size === 'medium',
'w-6 h-6': size === 'large',
},
{
'rotate-180': active,
},
]"
/>
</div>
<!-- 内容区域 -->
<div
ref="contentRef"
class="overflow-hidden transition-all duration-300 ease-in-out"
:style="{ height: active ? 'auto' : '0' }"
@transitionend="onTransitionEnd"
>
<div
class="p-4 text-gray-600 dark:text-gray-300 space-y-5"
>
<slot />
</div>
</div>
</div>
</template>
<style lang='css' scoped>
.select-none {
user-select: none;
}
</style>

View File

@@ -1,29 +0,0 @@
export interface CollapseProps {
/** 面板标题 */
title?: string;
/** 是否禁用 */
disabled?: boolean;
/** 是否显示边框 */
bordered?: boolean;
/** 尺寸大小 */
size?: "small" | "medium" | "large";
/** 是否展开 (v-model:active) */
active?: boolean;
}
export interface CollapseEmits {
/** 展开状态变化时触发 */
"update:active": [value: boolean];
}
export interface CollapseSlots {
/** 默认插槽 - 面板内容 */
default: () => any;
/** 标题插槽 - 自定义标题内容 */
title: () => any;
}
export interface CollapseInstance {
/** 切换展开/收起状态 */
toggle: () => void;
}

View File

@@ -1,55 +0,0 @@
<script lang='ts' setup>
import type { FieldBindingObject } from "vee-validate";
interface Props extends FieldBindingObject {
datetime: string;
label?: string;
formatterValue?: (value: string) => string;
format?: (value: string) => string;
min?: string;
max?: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: "update:value", value: string): void;
}>();
const model = defineModel({ type: String, required: true });
function handleChange(value: string) {
const formattedValue = props.formatterValue ? props.formatterValue(value) : new Date(value).toISOString();
model.value = formattedValue;
props.onChange(formattedValue);
emit("update:value", formattedValue);
}
function formatDisplay(value: string) {
return props.format ? props.format(value) : useDateFormat(value, "YYYY/MM/DD").value;
}
</script>
<template>
<div class="flex flex-col items-start">
<ion-label class="text-sm font-bold color-(--ion-text-color-secondary) mb-3.5">
{{ props.label }}
</ion-label>
<ion-datetime-button :datetime="datetime" color="primary">
<div slot="date-target">
{{ formatDisplay(props.value) }}
</div>
</ion-datetime-button>
<ion-modal :keep-contents-mounted="true">
<ion-datetime
:id="datetime"
class="ui-datetime"
done-text="Done"
presentation="date-time"
:show-default-buttons="true"
:min="props.min"
:max="props.max"
@ion-change="handleChange($event.detail.value as string)"
/>
</ion-modal>
</div>
</template>
<style lang='css' scoped></style>

View File

@@ -1,31 +0,0 @@
<script lang='ts' setup>
defineProps<{ text?: string }>();
</script>
<template>
<div class="divider ion-margin-vertical" v-bind="$attrs">
<span>{{ text }}</span>
</div>
</template>
<style scoped>
.divider {
display: flex;
align-items: center;
text-align: center;
margin: 20px 0;
}
.divider::before,
.divider::after {
content: "";
flex: 1;
border-bottom: 1px solid var(--ion-color-medium);
}
.divider span {
padding: 0 10px;
color: var(--ion-color-medium);
font-size: 14px;
}
</style>

View File

@@ -1,248 +0,0 @@
<script lang='ts' setup>
import { documentAttachOutline, trashOutline } from "ionicons/icons";
interface FileItem {
name: string;
size: number;
url?: string;
}
const props = withDefaults(defineProps<{
modelValue?: string[];
label?: string;
placeholder?: string;
accept?: string;
multiple?: boolean;
maxSize?: number; // MB
maxFiles?: number;
}>(), {
modelValue: () => [],
accept: "*/*",
multiple: true,
maxSize: 10,
maxFiles: 5,
});
const emit = defineEmits<{
(e: "update:modelValue", value: string[]): void;
}>();
const { t } = useI18n();
const fileInput = ref<HTMLInputElement>();
const files = ref<FileItem[]>([]);
// 初始化已有文件
watchEffect(() => {
if (props.modelValue && props.modelValue.length > 0) {
files.value = props.modelValue.map(url => ({
name: url.split("/").pop() || "file",
size: 0,
url,
}));
}
});
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const selectedFiles = Array.from(input.files || []);
if (!selectedFiles.length)
return;
// 检查文件数量限制
if (files.value.length + selectedFiles.length > props.maxFiles) {
// TODO: 显示错误提示
console.warn(`最多只能上传 ${props.maxFiles} 个文件`);
return;
}
selectedFiles.forEach((file) => {
// 检查文件大小
if (file.size > props.maxSize * 1024 * 1024) {
console.warn(`文件 ${file.name} 超过 ${props.maxSize}MB 限制`);
return;
}
// 创建预览 URL
const url = URL.createObjectURL(file);
files.value.push({
name: file.name,
size: file.size,
url,
});
});
// 更新值
updateValue();
// 重置 input
if (fileInput.value) {
fileInput.value.value = "";
}
}
function removeFile(index: number) {
const file = files.value[index];
if (file.url && file.url.startsWith("blob:")) {
URL.revokeObjectURL(file.url);
}
files.value.splice(index, 1);
updateValue();
}
function updateValue() {
const urls = files.value.map(f => f.url || "").filter(Boolean);
emit("update:modelValue", urls);
}
function formatFileSize(bytes: number): string {
if (bytes === 0)
return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
}
function triggerFileInput() {
fileInput.value?.click();
}
</script>
<template>
<div class="file-upload">
<div v-if="label" class="file-upload-label">
{{ label }}
</div>
<input
ref="fileInput"
type="file"
:accept="accept"
:multiple="multiple"
class="file-input-hidden"
@change="handleFileSelect"
>
<!-- 上传按钮 -->
<ion-button
v-if="files.length < maxFiles"
expand="block"
fill="outline"
class="upload-button"
@click="triggerFileInput"
>
<ion-icon slot="start" :icon="documentAttachOutline" />
{{ placeholder || t('common.uploadFile') }}
</ion-button>
<!-- 文件列表 -->
<div v-if="files.length > 0" class="file-list">
<div v-for="(file, index) in files" :key="index" class="file-item">
<div class="file-info">
<ion-icon :icon="documentAttachOutline" class="file-icon" />
<div class="file-details">
<div class="file-name">
{{ file.name }}
</div>
<div v-if="file.size" class="file-size">
{{ formatFileSize(file.size) }}
</div>
</div>
</div>
<ion-button fill="clear" size="small" color="danger" @click="removeFile(index)">
<ion-icon slot="icon-only" :icon="trashOutline" />
</ion-button>
</div>
</div>
<div v-if="files.length > 0" class="file-count">
{{ files.length }} / {{ maxFiles }} {{ t('common.files') }}
</div>
</div>
</template>
<style scoped>
.file-upload {
width: 100%;
}
.file-upload-label {
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
color: var(--ion-text-color);
}
.file-input-hidden {
display: none;
}
.upload-button {
--border-style: dashed;
--border-width: 2px;
margin-bottom: 12px;
}
.file-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
background: var(--ion-color-light);
border-radius: 8px;
transition: background-color 0.2s;
}
.file-item:hover {
background: var(--ion-color-light-shade);
}
.file-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.file-icon {
font-size: 24px;
color: var(--ion-color-primary);
flex-shrink: 0;
}
.file-details {
flex: 1;
min-width: 0;
}
.file-name {
font-size: 14px;
font-weight: 500;
color: var(--ion-text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: 12px;
color: var(--ion-color-medium);
margin-top: 2px;
}
.file-count {
font-size: 12px;
color: var(--ion-color-medium);
text-align: right;
margin-top: 8px;
}
</style>

View File

@@ -1,34 +0,0 @@
<script lang='ts' setup>
import type { ComponentInstance } from "vue";
import UiInput from "@/components/ui/input/index.vue";
defineProps<{
label?: string;
}>();
const vm = getCurrentInstance()!;
function changeRef(exposed: any) {
vm.exposed = exposed;
}
defineExpose({} as ComponentInstance<typeof UiInput>);
</script>
<template>
<div>
<div v-if="label" class="label">
{{ label }}
</div>
<component :is="h(UiInput, { ...$attrs, ref: changeRef })" />
</div>
</template>
<style scoped>
.label {
font-size: 14px;
font-weight: 500;
margin-bottom: 14px;
color: var(--ion-text-color-secondary);
}
</style>

View File

@@ -1,30 +0,0 @@
<script lang='ts' setup>
import type { ComponentInstance } from "vue";
import { IonInput } from "@ionic/vue";
import { h } from "vue";
const vm = getCurrentInstance()!;
function changeRef(exposed: any) {
vm.exposed = exposed;
}
defineExpose({} as ComponentInstance<typeof IonInput>);
</script>
<template>
<component :is="h(IonInput, { ...$attrs, ref: changeRef }, { default: () => { return $slots.default ? $slots.default() : null } })" class="ui-input" />
</template>
<style scoped>
.ui-input {
--padding-start: 12px;
--padding-end: 12px;
--background: var(--ui-input-background, #fff);
--color: var(--ui-input-color, #000);
--border-radius: 8px;
}
.ui-input + .ui-input {
margin-top: 12px;
}
</style>

View File

@@ -1,22 +0,0 @@
<script lang='ts' setup>
defineProps<{
icon?: string;
title?: string;
description?: string;
}>();
</script>
<template>
<div class="flex-col-center space-y-3">
<ion-icon :icon="icon" class="text-5xl text-lime-500" />
<div class="text-xl font-semibold">
{{ title }}
</div>
<p class="text-center text-text-400 max-w-xs">
{{ description }}
</p>
<slot name="extra" />
</div>
</template>
<style lang='css' scoped></style>

View File

@@ -1,84 +0,0 @@
<script lang='ts' setup>
interface TabPaneProps {
name: string | number;
title: string;
disabled?: boolean;
closable?: boolean;
lazy?: boolean;
}
const props = defineProps<TabPaneProps>();
// 注入父组件上下文
const tabsContext = inject<any>("tabs-context");
if (!tabsContext) {
throw new Error("TabPane must be used inside Tabs");
}
// 当前是否为活跃状态
const isActive = computed(() => tabsContext.currentTab.value === props.name);
// 是否已经渲染过用于lazy加载
const hasRendered = ref(false);
// 监听active状态变化
watch(isActive, (active) => {
if (active && !hasRendered.value) {
hasRendered.value = true;
}
}, { immediate: true });
// 注册到父组件
onMounted(() => {
tabsContext.registerTabPane({
name: props.name,
title: props.title,
disabled: props.disabled,
closable: props.closable,
});
});
// 卸载时注销
onUnmounted(() => {
tabsContext.unregisterTabPane({
name: props.name,
title: props.title,
disabled: props.disabled,
closable: props.closable,
});
});
// 计算pane样式类
const paneClasses = computed(() => [
"ui-tab-pane",
{
"ui-tab-pane--active": isActive.value,
"ui-tab-pane--animated": tabsContext.animated.value,
},
]);
</script>
<template>
<!-- Tab Content -->
<div
v-if="!lazy || hasRendered"
v-show="isActive"
:class="paneClasses"
:style="tabsContext.paneStyle.value"
>
<slot />
</div>
</template>
<style scoped>
@reference "tailwindcss";
/* Tab Panes */
.ui-tab-pane {
@apply w-full;
}
.ui-tab-pane--animated {
@apply transition-opacity duration-200;
}
</style>

View File

@@ -1,624 +0,0 @@
<script lang='ts' setup>
interface TabsProps {
modelValue?: string | number;
type?: "bar" | "line" | "segment";
size?: "small" | "medium" | "large";
animated?: boolean;
closable?: boolean;
addable?: boolean;
placement?: "top" | "bottom" | "left" | "right";
sticky?: boolean;
stickyTop?: string | number;
tabStyle?: string | Record<string, any>;
tabClass?: string;
paneStyle?: string | Record<string, any>;
paneClass?: string;
}
interface TabsEmits {
(e: "update:modelValue", value: string | number): void;
(e: "tabClick", key: string | number, event: Event): void;
(e: "tabClose", key: string | number): void;
(e: "tabAdd"): void;
}
const props = withDefaults(defineProps<TabsProps>(), {
type: "bar",
size: "medium",
animated: true,
closable: false,
addable: false,
placement: "top",
sticky: false,
stickyTop: 0,
});
const emit = defineEmits<TabsEmits>();
// 内部状态
const currentTab = ref<string | number>();
const tabsRef = ref<HTMLElement>();
const activeBarRef = ref<HTMLElement>();
// 子组件列表
const tabPanes = ref<Array<{
name: string | number;
title: string;
disabled?: boolean;
closable?: boolean;
instance: any;
}>>([]);
// 使用provide/inject在父子组件间传递数据
const tabsContext = {
currentTab: readonly(currentTab),
type: toRef(props, "type"),
size: toRef(props, "size"),
closable: toRef(props, "closable"),
animated: toRef(props, "animated"),
placement: toRef(props, "placement"),
tabStyle: toRef(props, "tabStyle"),
tabClass: toRef(props, "tabClass"),
paneStyle: toRef(props, "paneStyle"),
paneClass: toRef(props, "paneClass"),
activateTab,
closeTab,
registerTabPane,
unregisterTabPane,
};
provide("tabs-context", tabsContext);
// 注册tab pane
function registerTabPane(pane: any) {
const existingIndex = tabPanes.value.findIndex(p => p.name === pane.name);
if (existingIndex === -1) {
tabPanes.value.push(pane);
// 如果当前没有激活的tab且这是第一个可用的tab则激活它
if (!currentTab.value && !pane.disabled) {
currentTab.value = pane.name;
emit("update:modelValue", pane.name);
// 延迟更新active bar确保DOM完全渲染
setTimeout(() => {
updateActiveBar();
}, 10);
}
}
}
function unregisterTabPane(pane: any) {
const index = tabPanes.value.findIndex(p => p.name === pane.name);
if (index > -1) {
tabPanes.value.splice(index, 1);
}
}
// 激活tab
function activateTab(key: string | number, event?: Event) {
const pane = tabPanes.value.find(p => p.name === key);
if (!pane || pane.disabled)
return;
if (currentTab.value === key)
return;
currentTab.value = key;
emit("update:modelValue", key);
if (event) {
emit("tabClick", key, event);
}
// 更新active bar位置
nextTick(updateActiveBar);
}
// 关闭tab
function closeTab(key: string | number) {
emit("tabClose", key);
}
// 添加tab
function addTab() {
emit("tabAdd");
}
// 更新active bar位置用于bar和line类型
function updateActiveBar() {
if (props.type !== "bar" && props.type !== "line")
return;
if (!tabsRef.value || !activeBarRef.value)
return;
const activeTabEl = tabsRef.value.querySelector(`[data-tab-key="${currentTab.value}"]`) as HTMLElement;
if (!activeTabEl)
return;
// 获取tab label元素来计算更精确的宽度
const labelEl = activeTabEl.querySelector(".ui-tab__label") as HTMLElement;
if (labelEl) {
// 创建一个临时元素来测量文本的实际宽度
const tempEl = document.createElement("span");
tempEl.style.visibility = "hidden";
tempEl.style.position = "absolute";
tempEl.style.fontSize = window.getComputedStyle(labelEl).fontSize;
tempEl.style.fontFamily = window.getComputedStyle(labelEl).fontFamily;
tempEl.style.fontWeight = window.getComputedStyle(labelEl).fontWeight;
tempEl.textContent = labelEl.textContent;
document.body.appendChild(tempEl);
const textWidth = tempEl.offsetWidth;
document.body.removeChild(tempEl);
const tabsRect = tabsRef.value.getBoundingClientRect();
const labelRect = labelEl.getBoundingClientRect();
if (props.placement === "top" || props.placement === "bottom") {
// 居中对齐文本宽度
const leftOffset = labelRect.left - tabsRect.left + (labelRect.width - textWidth) / 2;
activeBarRef.value.style.left = `${leftOffset}px`;
activeBarRef.value.style.width = `${textWidth}px`;
}
else {
activeBarRef.value.style.top = `${labelRect.top - tabsRect.top}px`;
activeBarRef.value.style.height = `${textWidth}px`;
}
}
else {
// 降级到原来的逻辑
const tabsRect = tabsRef.value.getBoundingClientRect();
const activeRect = activeTabEl.getBoundingClientRect();
if (props.placement === "top" || props.placement === "bottom") {
activeBarRef.value.style.left = `${activeRect.left - tabsRect.left}px`;
activeBarRef.value.style.width = `${activeRect.width}px`;
}
else {
activeBarRef.value.style.top = `${activeRect.top - tabsRect.top}px`;
activeBarRef.value.style.height = `${activeRect.height}px`;
}
}
}
// 监听modelValue变化
watch(() => props.modelValue, (newValue) => {
if (newValue !== undefined && newValue !== currentTab.value) {
currentTab.value = newValue;
nextTick(updateActiveBar);
}
}, { immediate: true });
// 监听tabPanes变化自动设置第一个tab为活跃状态
watch(tabPanes, (newPanes) => {
if ((!currentTab.value || currentTab.value === "") && newPanes.length > 0) {
const firstAvailablePane = newPanes.find(pane => !pane.disabled);
if (firstAvailablePane) {
currentTab.value = firstAvailablePane.name;
emit("update:modelValue", firstAvailablePane.name);
nextTick(updateActiveBar);
}
}
}, { flush: "post" });
// 组件挂载后初始化
onMounted(() => {
// 如果没有当前激活的tab但有可用的tabs选择第一个
if (!currentTab.value && tabPanes.value.length > 0) {
const firstAvailablePane = tabPanes.value.find(pane => !pane.disabled);
if (firstAvailablePane) {
currentTab.value = firstAvailablePane.name;
emit("update:modelValue", firstAvailablePane.name);
}
}
// 延迟更新active bar确保DOM完全渲染
setTimeout(() => {
updateActiveBar();
});
});
// 暴露方法
defineExpose({
registerTabPane,
unregisterTabPane,
updateActiveBar,
});
// 计算样式类
const tabsClasses = computed(() => [
"ui-tabs",
`ui-tabs--${props.type}`,
`ui-tabs--${props.size}`,
`ui-tabs--${props.placement}`,
{
"ui-tabs--animated": props.animated,
"ui-tabs--sticky": props.sticky,
},
]);
// 计算sticky样式
const stickyStyle = computed(() => {
if (!props.sticky)
return {};
const topValue = typeof props.stickyTop === "number"
? `${props.stickyTop}px`
: props.stickyTop;
return {
position: "sticky" as const,
top: topValue,
zIndex: 100,
};
});
</script>
<template>
<div :class="tabsClasses">
<div
class="ui-tabs__nav-wrapper"
:class="[`ui-tabs__nav-wrapper--${placement}`]"
:style="props.sticky ? stickyStyle : {}"
>
<div
ref="tabsRef"
class="ui-tabs__nav"
:class="[`ui-tabs__nav--${type}`, `ui-tabs__nav--${size}`]"
>
<!-- 渲染标签列表 -->
<div
v-for="pane in tabPanes"
:key="pane.name"
class="ui-tab" :class="[
`ui-tab--${type}`,
`ui-tab--${size}`,
{
'ui-tab--active': currentTab === pane.name,
'ui-tab--disabled': pane.disabled,
'ui-tab--closable': pane.closable || closable,
},
]"
:data-tab-key="pane.name"
:style="tabStyle"
@click="activateTab(pane.name, $event)"
>
<span class="ui-tab__label">
{{ pane.title }}
</span>
<!-- Close Button -->
<button
v-if="(pane.closable || closable) && type !== 'segment'"
class="ui-tab__close"
@click.stop="closeTab(pane.name)"
>
<ion-icon name="close-outline" />
</button>
</div>
<!-- Active Bar (用于bar和line类型) -->
<div
v-if="type === 'bar' || type === 'line'"
ref="activeBarRef"
class="ui-tabs__active-bar"
:class="`ui-tabs__active-bar--${type}`"
/>
<!-- Add Button -->
<button
v-if="addable"
class="ui-tabs__add-btn"
@click="addTab"
>
<ion-icon name="add-outline" />
</button>
</div>
</div>
<!-- 标签内容区域 -->
<div class="ui-tabs__content">
<slot />
</div>
</div>
</template>
<style scoped>
@reference "tailwindcss";
.ui-tabs {
@apply w-full;
--ui-tabs-primary: var(--ion-color-primary);
--ui-tabs-primary-rgb: var(--ion-color-primary-rgb);
--ui-tabs-background: var(--ion-background-color);
--ui-tabs-text: var(--ion-text-color);
--ui-tabs-text-rgb: var(--ion-text-color-rgb);
}
/* 导航包装器 */
.ui-tabs__nav-wrapper {
@apply relative;
}
/* Sticky 布局 */
.ui-tabs--sticky .ui-tabs__nav-wrapper {
background-color: var(--ui-tabs-background);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.ui-tabs--sticky.ui-tabs--animated .ui-tabs__nav-wrapper {
transition: background-color 0.2s ease;
}
.ui-tabs__nav-wrapper--bottom {
border-top: 1px solid var(--ion-border-color, #e5e7eb);
}
.ui-tabs__nav-wrapper--left {
border-right: 1px solid var(--ion-border-color, #e5e7eb);
}
.ui-tabs__nav-wrapper--right {
border-left: 1px solid var(--ion-border-color, #e5e7eb);
}
/* 导航容器 */
.ui-tabs__nav {
@apply relative flex;
}
.ui-tabs__nav--bar,
.ui-tabs__nav--line {
@apply gap-6;
}
.ui-tabs__nav--segment {
@apply gap-1 p-1 rounded-lg;
background-color: var(--ion-color-light, #f8f9fa);
}
/* 尺寸 */
.ui-tabs__nav--small {
@apply text-sm;
}
.ui-tabs__nav--medium {
@apply text-base;
}
.ui-tabs__nav--large {
@apply text-lg;
}
/* Tab Labels */
.ui-tab {
@apply relative flex items-center justify-between cursor-pointer transition-all duration-200;
color: var(--ion-color-medium, #6b7280);
}
.ui-tab:hover:not(.ui-tab--disabled):not(.ui-tab--active) {
color: var(--ion-color-primary) !important;
}
.ui-tab--active {
@apply font-medium;
color: var(--ion-color-primary) !important;
}
.ui-tab--disabled {
@apply cursor-not-allowed opacity-50;
color: var(--ion-color-light-shade, #d1d5db) !important;
}
/* Bar 类型 */
.ui-tab--bar {
@apply py-3 border-b-2 border-transparent;
}
.ui-tab--bar.ui-tab--active {
border-color: var(--ion-color-primary) !important;
}
/* Line 类型 */
.ui-tab--line {
@apply py-3;
}
/* Segment 类型 */
.ui-tab--segment {
@apply px-3 py-2 rounded-md;
}
.ui-tab--segment.ui-tab--active {
background-color: var(--ui-tabs-background);
box-shadow:
0 1px 3px 0 rgba(0, 0, 0, 0.1),
0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
/* 尺寸变化 */
.ui-tab--small {
@apply text-sm;
}
.ui-tab--small.ui-tab--bar,
.ui-tab--small.ui-tab--line {
@apply py-2;
}
.ui-tab--small.ui-tab--segment {
@apply px-2 py-1;
}
.ui-tab--large {
@apply text-lg;
}
.ui-tab--large.ui-tab--bar,
.ui-tab--large.ui-tab--line {
@apply py-4;
}
.ui-tab--large.ui-tab--segment {
@apply px-4 py-3;
}
/* Tab Label */
.ui-tab__label {
@apply flex-1;
}
/* Close Button */
.ui-tab__close {
@apply ml-2 p-1 rounded-full opacity-60 hover:opacity-100 transition-all duration-200;
background-color: transparent;
}
.ui-tab__close:hover {
background-color: rgba(var(--ui-tabs-primary-rgb), 0.1);
}
.ui-tab__close ion-icon {
@apply text-sm;
}
/* Active Bar */
.ui-tabs__active-bar {
@apply absolute transition-all duration-300 ease-out;
}
.ui-tabs__active-bar--bar {
@apply bottom-0;
height: 2px;
background-color: var(--ui-tabs-primary);
}
.ui-tabs__active-bar--line {
@apply bottom-0 h-px;
background-color: var(--ui-tabs-primary);
}
/* Add Button */
.ui-tabs__add-btn {
@apply flex items-center justify-center px-3 py-2 transition-colors duration-200;
color: var(--ion-color-medium, #6b7280);
}
.ui-tabs__add-btn:hover {
color: var(--ui-tabs-primary);
}
.ui-tabs__add-btn ion-icon {
@apply text-lg;
}
/* 内容区域 */
.ui-tabs__content {
@apply relative;
}
/* 动画支持 */
.ui-tabs--animated .ui-tabs__content {
@apply overflow-hidden;
}
/* 垂直布局 */
.ui-tabs--left,
.ui-tabs--right {
@apply flex;
}
.ui-tabs--left .ui-tabs__nav-wrapper {
@apply min-w-48;
}
.ui-tabs--right .ui-tabs__nav-wrapper {
@apply min-w-48 order-2;
}
.ui-tabs--left .ui-tabs__nav,
.ui-tabs--right .ui-tabs__nav {
@apply flex-col;
}
.ui-tabs--left .ui-tabs__content,
.ui-tabs--right .ui-tabs__content {
@apply flex-1;
}
/* 垂直布局tab调整 */
.ui-tabs--left .ui-tab,
.ui-tabs--right .ui-tab {
@apply justify-start w-full;
}
.ui-tabs--left .ui-tab--bar,
.ui-tabs--right .ui-tab--bar {
@apply border-b-0 border-r-2;
}
.ui-tabs--left .ui-tab--bar.ui-tab--active,
.ui-tabs--right .ui-tab--bar.ui-tab--active {
border-color: var(--ui-tabs-primary);
}
/* 垂直布局active bar */
.ui-tabs--left .ui-tabs__active-bar--bar,
.ui-tabs--right .ui-tabs__active-bar--bar {
@apply right-0 w-0.5 h-0.5;
}
.ui-tabs--left .ui-tabs__active-bar--line,
.ui-tabs--right .ui-tabs__active-bar--line {
@apply right-0 w-px h-0.5;
}
/* 响应式设计 */
@media (max-width: 640px) {
.ui-tabs__nav--small .ui-tab--bar,
.ui-tabs__nav--small .ui-tab--line {
@apply py-2;
}
.ui-tabs__nav--medium .ui-tab--bar,
.ui-tabs__nav--medium .ui-tab--line {
@apply py-2;
}
.ui-tab__close {
@apply ml-1;
}
}
/* Dark mode 支持 */
.ion-palette-dark {
.ui-tabs {
--ui-tabs-primary: var(--ion-color-primary, #ffffff);
--ui-tabs-primary-rgb: var(--ion-color-primary-rgb, 255, 255, 255);
--ui-tabs-background: var(--ion-background-color, #1a1a1a);
--ui-tabs-text: var(--ion-text-color, #ffffff);
}
.ui-tabs__nav--segment {
background-color: var(--ion-color-dark, #2d2d2d);
}
.ui-tab--segment.ui-tab--active {
background-color: var(--ion-color-dark-tint, #404040);
}
}
/* 触摸设备优化 */
@media (hover: none) and (pointer: coarse) {
.ui-tab--bar,
.ui-tab--line,
.ui-tab--segment {
@apply py-3;
}
.ui-tab__close {
@apply p-2;
}
}
</style>

View File

@@ -1,97 +0,0 @@
export interface TabItem {
name: string | number;
title: string;
disabled?: boolean;
closable?: boolean;
lazy?: boolean;
}
export interface TabsProps {
/** 当前激活的标签页名称 */
modelValue?: string | number;
/** 标签页类型 */
type?: "bar" | "line" | "segment";
/** 标签页尺寸 */
size?: "small" | "medium" | "large";
/** 是否开启切换动画 */
animated?: boolean;
/** 是否可关闭 */
closable?: boolean;
/** 是否可添加 */
addable?: boolean;
/** 标签页位置 */
placement?: "top" | "bottom" | "left" | "right";
/** 标签样式 */
tabStyle?: string | Record<string, any>;
/** 标签类名 */
tabClass?: string;
/** 面板样式 */
paneStyle?: string | Record<string, any>;
/** 面板类名 */
paneClass?: string;
}
export interface TabsEmits {
/** 当前激活标签页发生变化时触发 */
(e: "update:modelValue", value: string | number): void;
/** 点击标签时触发 */
(e: "tabClick", key: string | number, event: Event): void;
/** 关闭标签时触发 */
(e: "tabClose", key: string | number): void;
/** 添加标签时触发 */
(e: "tabAdd"): void;
}
export interface TabPaneProps {
/** 标签页标识,必须唯一 */
name: string | number;
/** 标签页标题 */
title: string;
/** 是否禁用 */
disabled?: boolean;
/** 是否可关闭 */
closable?: boolean;
/** 是否懒加载 */
lazy?: boolean;
}
export interface TabsContext {
/** 当前激活的标签页 */
currentTab: Readonly<Ref<string | number | undefined>>;
/** 标签页类型 */
type: Readonly<Ref<"bar" | "line" | "segment">>;
/** 标签页尺寸 */
size: Readonly<Ref<"small" | "medium" | "large">>;
/** 是否可关闭 */
closable: Readonly<Ref<boolean>>;
/** 是否开启动画 */
animated: Readonly<Ref<boolean>>;
/** 标签页位置 */
placement: Readonly<Ref<"top" | "bottom" | "left" | "right">>;
/** 标签样式 */
tabStyle: Readonly<Ref<string | Record<string, any> | undefined>>;
/** 标签类名 */
tabClass: Readonly<Ref<string | undefined>>;
/** 面板样式 */
paneStyle: Readonly<Ref<string | Record<string, any> | undefined>>;
/** 面板类名 */
paneClass: Readonly<Ref<string | undefined>>;
/** 激活标签页方法 */
activateTab: (key: string | number, event?: Event) => void;
/** 关闭标签页方法 */
closeTab: (key: string | number) => void;
/** 注册标签页 */
registerTabPane: (pane: TabItem) => void;
/** 注销标签页 */
unregisterTabPane: (pane: TabItem) => void;
}
/** Tabs 组件实例类型 */
export interface TabsInstance {
/** 注册标签页 */
registerTabPane: (pane: TabItem) => void;
/** 注销标签页 */
unregisterTabPane: (pane: TabItem) => void;
/** 更新活跃指示条位置 */
updateActiveBar: () => void;
}

View File

@@ -1,48 +0,0 @@
<script lang='ts' setup>
const { type = "primary", size = "medium", round = false } = defineProps<{
type?: "primary" | "secondary" | "tertiary" | "success" | "warning" | "danger";
size?: "small" | "medium" | "large";
round?: boolean;
}>();
</script>
<template>
<div class="ui-tag rounded-md" :class="[type, size, { 'rounded-full': round }]">
<slot />
</div>
</template>
<style lang='css' scoped>
@reference "tailwindcss";
.ui-tag {
@apply px-3 py-1 text-xs;
}
.ui-tag.primary {
@apply bg-(--ion-color-primary) text-white;
}
.ui-tag.secondary {
@apply bg-(--ion-color-secondary) text-white;
}
.ui-tag.tertiary {
@apply bg-(--ion-color-tertiary) text-white;
}
.ui-tag.success {
@apply bg-(--ion-color-success) text-white;
}
.ui-tag.warning {
@apply bg-(--ion-color-warning) text-white;
}
.ui-tag.danger {
@apply bg-(--ion-color-danger) text-white;
}
.ui-tag.small {
@apply text-xs px-2 py-0.5;
}
.ui-tag.medium {
@apply text-sm px-3 py-1;
}
.ui-tag.large {
@apply text-base px-4 py-2;
}
</style>

View File

@@ -1,41 +0,0 @@
<script lang='ts' setup>
import type { ComponentInstance } from "vue";
import { IonTextarea } from "@ionic/vue";
defineProps<{
label?: string;
}>();
const vm = getCurrentInstance()!;
function changeRef(exposed: any) {
vm.exposed = exposed;
}
defineExpose({} as ComponentInstance<typeof UiInput>);
</script>
<template>
<div>
<div v-if="label" class="label">
{{ label }}
</div>
<component :is="h(IonTextarea, { ...$attrs, ref: changeRef })" class="ui-textarea" />
</div>
</template>
<style scoped>
.label {
font-size: 14px;
font-weight: 500;
margin-bottom: 14px;
color: var(--ion-text-color-secondary);
}
.ui-textarea {
--padding-start: 12px;
--padding-end: 12px;
--background: var(--ui-input-background, #fff);
--color: var(--ui-input-color, #000);
--border-radius: 8px;
}
</style>