feat: 优化
This commit is contained in:
@@ -1,16 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: antd-to-shadcn
|
name: antd-to-shadcn
|
||||||
description: |
|
description: Vue 3 项目从 Ant Design Vue 迁移到 shadcn-vue 的专业技能。支持组件替换、表单表格弹窗迁移、UI 现代化、颜色解耦和主题配置。触发词:迁移、shadcn、antd 替换、组件升级、UI 现代化
|
||||||
Vue 3 项目从 Ant Design Vue 迁移到 shadcn-vue 的专业技能。
|
|
||||||
|
|
||||||
当用户请求以下操作时使用此技能:
|
|
||||||
- 将 Ant Design 组件替换为 shadcn 组件
|
|
||||||
- 迁移表单、表格、弹窗等复杂组件
|
|
||||||
- 保持业务功能不变的前提下现代化 UI
|
|
||||||
- 颜色解耦和主题配置
|
|
||||||
- 组件间距和布局优化
|
|
||||||
|
|
||||||
触发词:迁移、shadcn、antd 替换、组件升级、UI 现代化
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Ant Design Vue → shadcn-vue 迁移技能
|
# Ant Design Vue → shadcn-vue 迁移技能
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
import { ref, computed, watchEffect } from 'vue'
|
import { ref, watchEffect } from 'vue'
|
||||||
import SvgSprite from '@/components/icons/SvgSprite.vue'
|
import SvgSprite from '@/components/icons/SvgSprite.vue'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import tokenManager from '@gold/utils/token-manager'
|
import tokenManager from '@gold/utils/token-manager'
|
||||||
import zhCN from 'ant-design-vue/es/locale/zh_CN'
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Ant Design Vue 主题配置
|
// 主题配置 - 使用 CSS 变量,无需 Ant Design
|
||||||
// ========================================
|
// ========================================
|
||||||
const isDark = ref(false)
|
const isDark = ref(false)
|
||||||
|
|
||||||
@@ -19,129 +18,49 @@ const initTheme = () => {
|
|||||||
} else {
|
} else {
|
||||||
isDark.value = window.matchMedia('(prefers-color-scheme: dark)').matches
|
isDark.value = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
}
|
}
|
||||||
|
document.documentElement.classList.toggle('dark', isDark.value)
|
||||||
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'light')
|
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'light')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 主题切换
|
// 主题切换
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
isDark.value = !isDark.value
|
isDark.value = !isDark.value
|
||||||
|
document.documentElement.classList.toggle('dark', isDark.value)
|
||||||
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'light')
|
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'light')
|
||||||
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
|
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ant Design 主题 Token
|
|
||||||
const themeToken = computed(() => {
|
|
||||||
const lightToken = {
|
|
||||||
// 品牌色
|
|
||||||
colorPrimary: '#3B82F6',
|
|
||||||
colorSuccess: '#22C55E',
|
|
||||||
colorWarning: '#F59E0B',
|
|
||||||
colorError: '#EF4444',
|
|
||||||
colorInfo: '#3B82F6',
|
|
||||||
// 背景色
|
|
||||||
colorBgContainer: '#FFFFFF',
|
|
||||||
colorBgElevated: '#FFFFFF',
|
|
||||||
colorBgLayout: '#F9FAFB',
|
|
||||||
// 边框
|
|
||||||
colorBorder: '#E5E7EB',
|
|
||||||
colorBorderSecondary: '#F3F4F6',
|
|
||||||
// 文字
|
|
||||||
colorText: '#111827',
|
|
||||||
colorTextSecondary: '#4B5563',
|
|
||||||
colorTextTertiary: '#6B7280',
|
|
||||||
colorTextQuaternary: '#9CA3AF',
|
|
||||||
// 填充
|
|
||||||
colorFill: '#F3F4F6',
|
|
||||||
colorFillSecondary: '#F9FAFB',
|
|
||||||
// 字体
|
|
||||||
fontFamily: '-apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif',
|
|
||||||
fontSize: 14,
|
|
||||||
// 圆角
|
|
||||||
borderRadius: 6,
|
|
||||||
borderRadiusSM: 4,
|
|
||||||
borderRadiusLG: 8,
|
|
||||||
// 控件尺寸
|
|
||||||
controlHeight: 32,
|
|
||||||
controlHeightSM: 28,
|
|
||||||
controlHeightLG: 40,
|
|
||||||
// 动画
|
|
||||||
motionDurationFast: '0.15s',
|
|
||||||
motionDurationMid: '0.2s',
|
|
||||||
motionDurationSlow: '0.3s',
|
|
||||||
// 阴影
|
|
||||||
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)',
|
|
||||||
boxShadowSecondary: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
|
|
||||||
}
|
|
||||||
|
|
||||||
const darkToken = {
|
|
||||||
colorPrimary: '#60A5FA',
|
|
||||||
colorBgContainer: '#1E293B',
|
|
||||||
colorBgElevated: '#334155',
|
|
||||||
colorBgLayout: '#0F172A',
|
|
||||||
colorBorder: '#334155',
|
|
||||||
colorBorderSecondary: '#1E293B',
|
|
||||||
colorText: '#F1F5F9',
|
|
||||||
colorTextSecondary: '#94A3B8',
|
|
||||||
colorTextTertiary: '#64748B',
|
|
||||||
colorTextQuaternary: '#475569',
|
|
||||||
colorFill: '#334155',
|
|
||||||
colorFillSecondary: '#1E293B',
|
|
||||||
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px -1px rgba(0, 0, 0, 0.4)',
|
|
||||||
boxShadowSecondary: '0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.4)',
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
token: isDark.value ? { ...lightToken, ...darkToken } : lightToken,
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
primaryShadow: 'none',
|
|
||||||
defaultShadow: 'none',
|
|
||||||
fontWeight: 500,
|
|
||||||
},
|
|
||||||
Input: {
|
|
||||||
paddingBlock: 6,
|
|
||||||
paddingInline: 12,
|
|
||||||
activeShadow: '0 0 0 3px rgba(59, 130, 246, 0.15)',
|
|
||||||
},
|
|
||||||
Select: {
|
|
||||||
optionSelectedBg: isDark.value ? 'rgba(96, 165, 250, 0.15)' : '#EFF6FF',
|
|
||||||
optionActiveBg: isDark.value ? '#334155' : '#F3F4F6',
|
|
||||||
},
|
|
||||||
Table: {
|
|
||||||
headerBg: isDark.value ? '#1E293B' : '#F9FAFB',
|
|
||||||
rowHoverBg: isDark.value ? '#334155' : '#F9FAFB',
|
|
||||||
borderColor: isDark.value ? '#334155' : '#E5E7EB',
|
|
||||||
headerColor: '#4B5563',
|
|
||||||
},
|
|
||||||
Card: {
|
|
||||||
paddingLG: 20,
|
|
||||||
borderRadiusLG: 12,
|
|
||||||
},
|
|
||||||
Modal: {
|
|
||||||
borderRadiusLG: 12,
|
|
||||||
},
|
|
||||||
Menu: {
|
|
||||||
itemHoverBg: isDark.value ? '#334155' : '#F3F4F6',
|
|
||||||
itemSelectedBg: isDark.value ? 'rgba(96, 165, 250, 0.15)' : '#EFF6FF',
|
|
||||||
itemSelectedColor: isDark.value ? '#60A5FA' : '#3B82F6',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
initTheme()
|
initTheme()
|
||||||
|
|
||||||
// 监听系统主题变化
|
// 监听系统主题变化
|
||||||
watchEffect(() => {
|
watchEffect((onCleanup) => {
|
||||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
const handler = (e) => {
|
const handler = (e) => {
|
||||||
if (!localStorage.getItem('theme')) {
|
if (!localStorage.getItem('theme')) {
|
||||||
isDark.value = e.matches
|
isDark.value = e.matches
|
||||||
|
document.documentElement.classList.toggle('dark', e.matches)
|
||||||
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light')
|
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
media.addEventListener('change', handler)
|
media.addEventListener('change', handler)
|
||||||
|
onCleanup(() => media.removeEventListener('change', handler))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 用户状态
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
// 自动刷新 token
|
||||||
|
watchEffect((onCleanup) => {
|
||||||
|
let interval
|
||||||
|
if (userStore.isLoggedIn) {
|
||||||
|
interval = setInterval(() => {
|
||||||
|
tokenManager.refreshToken()
|
||||||
|
}, 5 * 60 * 1000)
|
||||||
|
}
|
||||||
|
onCleanup(() => {
|
||||||
|
if (interval) clearInterval(interval)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 暴露给模板使用
|
// 暴露给模板使用
|
||||||
@@ -149,54 +68,14 @@ defineExpose({ toggleTheme, isDark })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a-config-provider :theme="themeToken" :locale="zhCN">
|
<div class="app-container min-h-screen bg-background text-foreground antialiased">
|
||||||
<SvgSprite />
|
<SvgSprite />
|
||||||
<keep-alive>
|
<keep-alive>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</keep-alive>
|
</keep-alive>
|
||||||
</a-config-provider>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* 全局样式保持不变 */
|
/* 全局基础样式已由 Tailwind + theme.css 处理 */
|
||||||
.ant-btn{
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.ant-select-selection-item,
|
|
||||||
.ant-select-selection-placeholder {
|
|
||||||
line-height: 30px !important;
|
|
||||||
color: var(--color-text) !important;
|
|
||||||
}
|
|
||||||
.ant-modal-confirm-btns {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
.ant-btn {
|
|
||||||
margin-left: 0 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.ant-select-focused .ant-select-selector {
|
|
||||||
border-color: var(--color-primary) !important;
|
|
||||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-select:hover .ant-select-selector {
|
|
||||||
border-color: var(--color-primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-select-arrow {
|
|
||||||
color: var(--color-text-secondary) !important;
|
|
||||||
}
|
|
||||||
.ant-modal .ant-modal-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: end;
|
|
||||||
}
|
|
||||||
.ant-tooltip{
|
|
||||||
z-index: 100
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const emit = defineEmits(['back'])
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="basic-layout">
|
<div class="flex flex-col h-full overflow-hidden rounded-xl border bg-card">
|
||||||
<LayoutHeader
|
<LayoutHeader
|
||||||
:title="title"
|
:title="title"
|
||||||
:show-back="showBack"
|
:show-back="showBack"
|
||||||
@@ -25,30 +25,12 @@ const emit = defineEmits(['back'])
|
|||||||
</LayoutHeader>
|
</LayoutHeader>
|
||||||
|
|
||||||
<!-- 页面内容 -->
|
<!-- 页面内容 -->
|
||||||
<div class="basic-layout__content">
|
<div class="flex-1 flex flex-col overflow-x-hidden overflow-y-auto bg-card p-4">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.basic-layout {
|
/* 使用 Tailwind 类,已移除旧样式 */
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
border: 1px solid var(--color-gray-200);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.basic-layout__content {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
padding: var(--space-4);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ const handleBack = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="card-layout">
|
<div class="flex flex-col h-full overflow-hidden rounded-xl border bg-card">
|
||||||
<LayoutHeader
|
<LayoutHeader
|
||||||
:title="title"
|
:title="title"
|
||||||
:show-back="showBack"
|
:show-back="showBack"
|
||||||
@back="handleBack"
|
@back="handleBack"
|
||||||
>
|
>
|
||||||
<template #extra>
|
<template #extra>
|
||||||
@@ -41,13 +41,19 @@ const handleBack = () => {
|
|||||||
</LayoutHeader>
|
</LayoutHeader>
|
||||||
|
|
||||||
<!-- 卡片内容 -->
|
<!-- 卡片内容 -->
|
||||||
<div class="card-layout__card" :class="{ 'no-padding': !showPadding }">
|
<div class="flex-1 flex flex-col overflow-hidden bg-card">
|
||||||
<div v-if="!$slots.title && title" class="card-header">
|
<div
|
||||||
|
v-if="!$slots.title && title"
|
||||||
|
class="px-6 py-4 bg-muted/50 border-b text-sm font-medium text-muted-foreground uppercase tracking-wider"
|
||||||
|
>
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</div>
|
</div>
|
||||||
<slot v-else name="title"></slot>
|
<slot v-else name="title"></slot>
|
||||||
|
|
||||||
<div class="card-layout__content">
|
<div
|
||||||
|
class="flex-1 overflow-auto bg-card"
|
||||||
|
:class="{ 'p-0': !showPadding, 'p-4': showPadding }"
|
||||||
|
>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,45 +61,5 @@ const handleBack = () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.card-layout {
|
/* 使用 Tailwind 类,已移除旧样式 */
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
border: 1px solid var(--color-gray-200);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-layout__card {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
|
|
||||||
&.no-padding {
|
|
||||||
.card-layout__content {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
padding: var(--space-4) var(--space-6);
|
|
||||||
background: var(--color-gray-50);
|
|
||||||
border-bottom: 1px solid var(--color-gray-200);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-gray-600);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-layout__content {
|
|
||||||
flex: 1;
|
|
||||||
padding: var(--space-4);
|
|
||||||
overflow: auto;
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons-vue'
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import LayoutHeader from './LayoutHeader.vue'
|
import LayoutHeader from './LayoutHeader.vue'
|
||||||
|
|
||||||
defineOptions({ name: 'FormLayout' })
|
defineOptions({ name: 'FormLayout' })
|
||||||
@@ -39,26 +40,14 @@ const props = defineProps({
|
|||||||
// Emits
|
// Emits
|
||||||
const emit = defineEmits(['submit', 'cancel', 'back'])
|
const emit = defineEmits(['submit', 'cancel', 'back'])
|
||||||
|
|
||||||
// Methods
|
|
||||||
const handleSubmit = () => {
|
|
||||||
emit('submit')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
emit('cancel')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
emit('back')
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="form-layout">
|
<div class="flex flex-col h-full overflow-hidden rounded-xl border bg-card">
|
||||||
<LayoutHeader
|
<LayoutHeader
|
||||||
:title="title"
|
:title="title"
|
||||||
:show-back="showBack"
|
:show-back="showBack"
|
||||||
@back="handleBack"
|
@back="emit('back')"
|
||||||
>
|
>
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<slot name="extra"></slot>
|
<slot name="extra"></slot>
|
||||||
@@ -66,32 +55,31 @@ const handleBack = () => {
|
|||||||
</LayoutHeader>
|
</LayoutHeader>
|
||||||
|
|
||||||
<!-- 表单内容 -->
|
<!-- 表单内容 -->
|
||||||
<div class="form-layout__content">
|
<div class="flex-1 overflow-auto bg-muted/50 flex items-center justify-center p-4">
|
||||||
<div class="form-container">
|
<div class="w-full max-w-[600px] rounded-xl border bg-card p-10">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部操作栏 -->
|
<!-- 底部操作栏 -->
|
||||||
<div v-if="showFooter" class="form-layout__footer">
|
<div v-if="showFooter" class="shrink-0 border-t px-6 py-4 bg-card">
|
||||||
<div class="footer-content">
|
<div class="max-w-[600px] mx-auto flex justify-end gap-3">
|
||||||
<a-space :size="12">
|
<Button v-if="showCancel" variant="outline" @click="emit('cancel')">
|
||||||
<a-button v-if="showCancel" @click="handleCancel">
|
{{ cancelText }}
|
||||||
{{ cancelText }}
|
</Button>
|
||||||
</a-button>
|
<Button :disabled="submitLoading" @click="emit('submit')">
|
||||||
<a-button
|
<Icon v-if="submitLoading" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||||
type="primary"
|
{{ submitText }}
|
||||||
:loading="submitLoading"
|
</Button>
|
||||||
@click="handleSubmit"
|
|
||||||
>
|
|
||||||
{{ submitText }}
|
|
||||||
</a-button>
|
|
||||||
</a-space>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
/* 已移除旧样式,使用 Tailwind 类 */
|
||||||
|
</style>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.form-layout {
|
.form-layout {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons-vue'
|
|
||||||
import LayoutHeader from './LayoutHeader.vue'
|
import LayoutHeader from './LayoutHeader.vue'
|
||||||
|
|
||||||
defineOptions({ name: 'FullWidthLayout' })
|
defineOptions({ name: 'FullWidthLayout' })
|
||||||
@@ -34,27 +33,27 @@ const handleBack = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="full-width-layout">
|
<div class="flex flex-col h-full overflow-hidden bg-card">
|
||||||
<div v-if="$slots.header || showBack" class="full-width-layout__header-wrapper">
|
<div v-if="$slots.header || showBack" class="shrink-0">
|
||||||
<LayoutHeader
|
<LayoutHeader
|
||||||
:show-back="showBack"
|
:show-back="showBack"
|
||||||
:ghost="headerGhost"
|
:ghost="headerGhost"
|
||||||
:padding="headerPadding"
|
:padding="headerPadding"
|
||||||
@back="handleBack"
|
@back="handleBack"
|
||||||
>
|
>
|
||||||
<template #header v-if="$slots.header">
|
<template #header v-if="$slots.header">
|
||||||
<slot name="header"></slot>
|
<slot name="header"></slot>
|
||||||
</template>
|
</template>
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<slot name="extra"></slot>
|
<slot name="extra"></slot>
|
||||||
</template>
|
</template>
|
||||||
</LayoutHeader>
|
</LayoutHeader>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 全宽内容 -->
|
<!-- 全宽内容 -->
|
||||||
<div
|
<div
|
||||||
class="full-width-layout__content"
|
class="flex-1 overflow-auto bg-background"
|
||||||
:class="{ 'no-padding': !showPadding }"
|
:class="{ 'p-0': !showPadding, 'p-4': showPadding }"
|
||||||
>
|
>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,25 +61,5 @@ const handleBack = () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.full-width-layout {
|
/* 使用 Tailwind 类,已移除旧样式 */
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.full-width-layout__header-wrapper {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.full-width-layout__content {
|
|
||||||
flex: 1;
|
|
||||||
overflow: auto;
|
|
||||||
background: var(--color-bg);
|
|
||||||
|
|
||||||
&.no-padding {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons-vue'
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
defineOptions({ name: 'LayoutHeader' })
|
defineOptions({ name: 'LayoutHeader' })
|
||||||
|
|
||||||
@@ -30,98 +31,36 @@ const handleBack = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="layout-header"
|
class="flex justify-between items-center gap-4 border-b bg-card shrink-0 min-h-14 transition-all"
|
||||||
:class="{ 'layout-header--ghost': ghost }"
|
:class="{
|
||||||
|
'border-border': !ghost,
|
||||||
|
'border-transparent bg-transparent': ghost,
|
||||||
|
'px-4 py-3': !padding,
|
||||||
|
'dark:border-border dark:bg-card dark:text-foreground': !ghost
|
||||||
|
}"
|
||||||
:style="padding ? { padding } : {}"
|
:style="padding ? { padding } : {}"
|
||||||
>
|
>
|
||||||
<div class="header-left">
|
<div class="flex items-center gap-4 flex-1 min-w-0">
|
||||||
<a-button v-if="showBack" type="text" @click="handleBack" class="back-btn">
|
<Button
|
||||||
<template #icon>
|
v-if="showBack"
|
||||||
<ArrowLeftOutlined />
|
variant="ghost"
|
||||||
</template>
|
size="icon"
|
||||||
</a-button>
|
class="h-8 w-8"
|
||||||
<div class="header-content">
|
@click="handleBack"
|
||||||
|
>
|
||||||
|
<Icon icon="lucide:arrow-left" class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<div class="flex-1 min-w-0 flex items-center">
|
||||||
<slot name="header">
|
<slot name="header">
|
||||||
<h1 v-if="title" class="header-title">{{ title }}</h1>
|
<h1 v-if="title" class="text-lg font-semibold text-foreground m-0 leading-snug tracking-tight">
|
||||||
|
{{ title }}
|
||||||
|
</h1>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
<slot name="extra"></slot>
|
<slot name="extra"></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="less">
|
|
||||||
.layout-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-4);
|
|
||||||
padding: var(--space-4) var(--space-6);
|
|
||||||
border-bottom: 1px solid var(--color-gray-200);
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-height: 56px;
|
|
||||||
transition: all var(--duration-base);
|
|
||||||
|
|
||||||
&--ghost {
|
|
||||||
background: transparent;
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-4);
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-content {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-btn {
|
|
||||||
padding: var(--space-1);
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-gray-600);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--duration-fast);
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-gray-100);
|
|
||||||
color: var(--color-gray-900);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-title {
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-gray-900);
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.4;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons-vue'
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import LayoutHeader from './LayoutHeader.vue'
|
import LayoutHeader from './LayoutHeader.vue'
|
||||||
|
|
||||||
defineOptions({ name: 'TabLayout' })
|
defineOptions({ name: 'TabLayout' })
|
||||||
@@ -48,14 +49,14 @@ const handleBack = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tab-layout">
|
<div class="flex flex-col h-full overflow-hidden rounded-xl border bg-card">
|
||||||
<div v-if="$slots.header || showBack" class="tab-layout__header-wrapper">
|
<div v-if="$slots.header || showBack" class="shrink-0">
|
||||||
<LayoutHeader
|
<LayoutHeader
|
||||||
:show-back="showBack"
|
:show-back="showBack"
|
||||||
@back="handleBack"
|
@back="handleBack"
|
||||||
>
|
>
|
||||||
<template #header v-if="$slots.header">
|
<template #header v-if="$slots.header">
|
||||||
<slot name="header"></slot>
|
<slot name="header"></slot>
|
||||||
</template>
|
</template>
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<slot name="extra"></slot>
|
<slot name="extra"></slot>
|
||||||
@@ -64,75 +65,26 @@ const handleBack = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 标签页导航 -->
|
<!-- 标签页导航 -->
|
||||||
<div class="tab-nav">
|
<Tabs v-model="activeTabKey" class="w-full" @update:modelValue="handleTabChange">
|
||||||
<button
|
<TabsList class="w-full justify-start border-b bg-muted/50 px-4">
|
||||||
v-for="tab in tabs"
|
<TabsTrigger
|
||||||
:key="tab.key"
|
v-for="tab in tabs"
|
||||||
class="tab-item"
|
:key="tab.key"
|
||||||
:class="{ 'active': activeTabKey === tab.key }"
|
:value="tab.key"
|
||||||
@click="handleTabChange(tab.key)"
|
class="data-[state=active]:bg-card"
|
||||||
>
|
>
|
||||||
{{ tab.tab }}
|
{{ tab.tab }}
|
||||||
</button>
|
</TabsTrigger>
|
||||||
</div>
|
</TabsList>
|
||||||
|
|
||||||
<!-- 标签页内容 -->
|
<!-- 标签页内容 -->
|
||||||
<div class="tab-content">
|
<TabsContent :value="activeTabKey" class="flex-1 overflow-auto p-4">
|
||||||
<slot :name="activeTabKey"></slot>
|
<slot :name="activeTabKey"></slot>
|
||||||
</div>
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.tab-layout {
|
/* 使用 Tailwind + shadcn 组件,已移除旧样式 */
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
border: 1px solid var(--color-gray-200);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-layout__header-wrapper {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-nav {
|
|
||||||
display: flex;
|
|
||||||
border-bottom: 1px solid var(--color-gray-200);
|
|
||||||
background: var(--color-gray-50);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-item {
|
|
||||||
padding: var(--space-2) var(--space-6);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-gray-600);
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
transition: all var(--duration-fast);
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-gray-100);
|
|
||||||
color: var(--color-gray-900);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
color: var(--color-primary-500);
|
|
||||||
border-bottom-color: var(--color-primary-500);
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow: auto;
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
padding: var(--space-4);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user