feat: 优化

This commit is contained in:
2026-03-16 00:43:08 +08:00
parent 6639a751bc
commit 6d891b855e
8 changed files with 125 additions and 450 deletions

View File

@@ -1,16 +1,6 @@
---
name: antd-to-shadcn
description: |
Vue 3 项目从 Ant Design Vue 迁移到 shadcn-vue 的专业技能。
当用户请求以下操作时使用此技能:
- 将 Ant Design 组件替换为 shadcn 组件
- 迁移表单、表格、弹窗等复杂组件
- 保持业务功能不变的前提下现代化 UI
- 颜色解耦和主题配置
- 组件间距和布局优化
触发词迁移、shadcn、antd 替换、组件升级、UI 现代化
description: Vue 3 项目从 Ant Design Vue 迁移到 shadcn-vue 的专业技能。支持组件替换、表单表格弹窗迁移、UI 现代化、颜色解耦和主题配置。触发词迁移、shadcn、antd 替换、组件升级、UI 现代化
---
# Ant Design Vue → shadcn-vue 迁移技能

View File

@@ -1,13 +1,12 @@
<script setup>
import { RouterView } from 'vue-router'
import { ref, computed, watchEffect } from 'vue'
import { ref, watchEffect } from 'vue'
import SvgSprite from '@/components/icons/SvgSprite.vue'
import { useUserStore } from '@/stores/user'
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)
@@ -19,129 +18,49 @@ const initTheme = () => {
} else {
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')
}
// 主题切换
const toggleTheme = () => {
isDark.value = !isDark.value
document.documentElement.classList.toggle('dark', isDark.value)
document.documentElement.setAttribute('data-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()
// 监听系统主题变化
watchEffect(() => {
watchEffect((onCleanup) => {
const media = window.matchMedia('(prefers-color-scheme: dark)')
const handler = (e) => {
if (!localStorage.getItem('theme')) {
isDark.value = e.matches
document.documentElement.classList.toggle('dark', e.matches)
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light')
}
}
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>
<template>
<a-config-provider :theme="themeToken" :locale="zhCN">
<div class="app-container min-h-screen bg-background text-foreground antialiased">
<SvgSprite />
<keep-alive>
<RouterView />
<RouterView />
</keep-alive>
</a-config-provider>
</div>
</template>
<style>
/* 全局样式保持不变 */
.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
}
/* 全局基础样式已由 Tailwind + theme.css 处理 */
</style>

View File

@@ -13,7 +13,7 @@ const emit = defineEmits(['back'])
</script>
<template>
<div class="basic-layout">
<div class="flex flex-col h-full overflow-hidden rounded-xl border bg-card">
<LayoutHeader
:title="title"
:show-back="showBack"
@@ -25,30 +25,12 @@ const emit = defineEmits(['back'])
</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>
</div>
</div>
</template>
<style scoped lang="less">
.basic-layout {
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);
}
/* 使用 Tailwind 类,已移除旧样式 */
</style>

View File

@@ -29,10 +29,10 @@ const handleBack = () => {
</script>
<template>
<div class="card-layout">
<LayoutHeader
:title="title"
:show-back="showBack"
<div class="flex flex-col h-full overflow-hidden rounded-xl border bg-card">
<LayoutHeader
:title="title"
:show-back="showBack"
@back="handleBack"
>
<template #extra>
@@ -41,13 +41,19 @@ const handleBack = () => {
</LayoutHeader>
<!-- 卡片内容 -->
<div class="card-layout__card" :class="{ 'no-padding': !showPadding }">
<div v-if="!$slots.title && title" class="card-header">
<div class="flex-1 flex flex-col overflow-hidden bg-card">
<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 }}
</div>
<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>
</div>
</div>
@@ -55,45 +61,5 @@ const handleBack = () => {
</template>
<style scoped lang="less">
.card-layout {
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);
}
/* 使用 Tailwind 类,已移除旧样式 */
</style>

View File

@@ -1,5 +1,6 @@
<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'
defineOptions({ name: 'FormLayout' })
@@ -39,26 +40,14 @@ const props = defineProps({
// Emits
const emit = defineEmits(['submit', 'cancel', 'back'])
// Methods
const handleSubmit = () => {
emit('submit')
}
const handleCancel = () => {
emit('cancel')
}
const handleBack = () => {
emit('back')
}
</script>
<template>
<div class="form-layout">
<LayoutHeader
:title="title"
:show-back="showBack"
@back="handleBack"
<div class="flex flex-col h-full overflow-hidden rounded-xl border bg-card">
<LayoutHeader
:title="title"
:show-back="showBack"
@back="emit('back')"
>
<template #extra>
<slot name="extra"></slot>
@@ -66,32 +55,31 @@ const handleBack = () => {
</LayoutHeader>
<!-- 表单内容 -->
<div class="form-layout__content">
<div class="form-container">
<div class="flex-1 overflow-auto bg-muted/50 flex items-center justify-center p-4">
<div class="w-full max-w-[600px] rounded-xl border bg-card p-10">
<slot></slot>
</div>
</div>
<!-- 底部操作栏 -->
<div v-if="showFooter" class="form-layout__footer">
<div class="footer-content">
<a-space :size="12">
<a-button v-if="showCancel" @click="handleCancel">
{{ cancelText }}
</a-button>
<a-button
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>
{{ submitText }}
</a-button>
</a-space>
<div v-if="showFooter" class="shrink-0 border-t px-6 py-4 bg-card">
<div class="max-w-[600px] mx-auto flex justify-end gap-3">
<Button v-if="showCancel" variant="outline" @click="emit('cancel')">
{{ cancelText }}
</Button>
<Button :disabled="submitLoading" @click="emit('submit')">
<Icon v-if="submitLoading" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
{{ submitText }}
</Button>
</div>
</div>
</div>
</template>
<style scoped lang="less">
/* 已移除旧样式,使用 Tailwind 类 */
</style>
<style scoped lang="less">
.form-layout {
height: 100%;

View File

@@ -1,5 +1,4 @@
<script setup>
import { ArrowLeftOutlined } from '@ant-design/icons-vue'
import LayoutHeader from './LayoutHeader.vue'
defineOptions({ name: 'FullWidthLayout' })
@@ -34,27 +33,27 @@ const handleBack = () => {
</script>
<template>
<div class="full-width-layout">
<div v-if="$slots.header || showBack" class="full-width-layout__header-wrapper">
<LayoutHeader
:show-back="showBack"
<div class="flex flex-col h-full overflow-hidden bg-card">
<div v-if="$slots.header || showBack" class="shrink-0">
<LayoutHeader
:show-back="showBack"
:ghost="headerGhost"
:padding="headerPadding"
@back="handleBack"
>
<template #header v-if="$slots.header">
<slot name="header"></slot>
<slot name="header"></slot>
</template>
<template #extra>
<slot name="extra"></slot>
</template>
</LayoutHeader>
</div>
<!-- 全宽内容 -->
<div
class="full-width-layout__content"
:class="{ 'no-padding': !showPadding }"
class="flex-1 overflow-auto bg-background"
:class="{ 'p-0': !showPadding, 'p-4': showPadding }"
>
<slot></slot>
</div>
@@ -62,25 +61,5 @@ const handleBack = () => {
</template>
<style scoped lang="less">
.full-width-layout {
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;
}
}
/* 使用 Tailwind 类,已移除旧样式 */
</style>

View File

@@ -1,5 +1,6 @@
<script setup>
import { ArrowLeftOutlined } from '@ant-design/icons-vue'
import { Icon } from '@iconify/vue'
import { Button } from '@/components/ui/button'
defineOptions({ name: 'LayoutHeader' })
@@ -30,98 +31,36 @@ const handleBack = () => {
</script>
<template>
<div
class="layout-header"
:class="{ 'layout-header--ghost': ghost }"
<div
class="flex justify-between items-center gap-4 border-b bg-card shrink-0 min-h-14 transition-all"
: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 } : {}"
>
<div class="header-left">
<a-button v-if="showBack" type="text" @click="handleBack" class="back-btn">
<template #icon>
<ArrowLeftOutlined />
</template>
</a-button>
<div class="header-content">
<div class="flex items-center gap-4 flex-1 min-w-0">
<Button
v-if="showBack"
variant="ghost"
size="icon"
class="h-8 w-8"
@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">
<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>
</div>
</div>
<div class="header-right">
<div class="flex items-center gap-2 shrink-0">
<slot name="extra"></slot>
</div>
</div>
</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>

View File

@@ -1,6 +1,7 @@
<script setup>
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'
defineOptions({ name: 'TabLayout' })
@@ -48,14 +49,14 @@ const handleBack = () => {
</script>
<template>
<div class="tab-layout">
<div v-if="$slots.header || showBack" class="tab-layout__header-wrapper">
<LayoutHeader
:show-back="showBack"
<div class="flex flex-col h-full overflow-hidden rounded-xl border bg-card">
<div v-if="$slots.header || showBack" class="shrink-0">
<LayoutHeader
:show-back="showBack"
@back="handleBack"
>
<template #header v-if="$slots.header">
<slot name="header"></slot>
<slot name="header"></slot>
</template>
<template #extra>
<slot name="extra"></slot>
@@ -64,75 +65,26 @@ const handleBack = () => {
</div>
<!-- 标签页导航 -->
<div class="tab-nav">
<button
v-for="tab in tabs"
:key="tab.key"
class="tab-item"
:class="{ 'active': activeTabKey === tab.key }"
@click="handleTabChange(tab.key)"
>
{{ tab.tab }}
</button>
</div>
<Tabs v-model="activeTabKey" class="w-full" @update:modelValue="handleTabChange">
<TabsList class="w-full justify-start border-b bg-muted/50 px-4">
<TabsTrigger
v-for="tab in tabs"
:key="tab.key"
:value="tab.key"
class="data-[state=active]:bg-card"
>
{{ tab.tab }}
</TabsTrigger>
</TabsList>
<!-- 标签页内容 -->
<div class="tab-content">
<slot :name="activeTabKey"></slot>
</div>
<!-- 标签页内容 -->
<TabsContent :value="activeTabKey" class="flex-1 overflow-auto p-4">
<slot :name="activeTabKey"></slot>
</TabsContent>
</Tabs>
</div>
</template>
<style scoped lang="less">
.tab-layout {
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);
}
/* 使用 Tailwind + shadcn 组件,已移除旧样式 */
</style>