This commit is contained in:
oneao committed 2025-09-21 21:52:08 +08:00
1 parent d37d92996a
commit a8f3e58a27
25 files changed
+1401 -399

No files matched your search

@@ -103,6 +103,7 @@ $bg-color-secondarycomponent: var(--ao-bg-color-secondarycomponent);
$bg-color-secondarycomponent-hover: var(--ao-bg-color-secondarycomponent-hover);
$bg-color-mask: var(--ao-bg-color-mask);
$bg-color-mask-spin: var(--ao-bg-color-mask-spin);
// shadow
// 基础/下层 投影 hover 使用的组件包括:表格 /
@@ -80,6 +80,7 @@
--ao-bg-color-secondarycomponent-hover: #5e5e5e;
--ao-bg-color-mask: rgba(23, 23, 26, 0.6);
--ao-bg-color-mask-spin: rgba(51, 51, 51, 0.6);
// shadow阴影
// 基础/下层 投影 hover 使用的组件包括:表格 /
@@ -81,6 +81,7 @@
--ao-bg-color-secondarycomponent-hover: #c6c6c6;
--ao-bg-color-mask: rgba(29, 33, 41, 0.6);
--ao-bg-color-mask-spin: rgba(255, 255, 255, 0.6);
// shadow阴影
// 基础/下层 投影 hover 使用的组件包括:表格 /
@@ -0,0 +1,79 @@
<script setup lang="ts">
</script>
<template>
<div class="container">
<ABadge count="12">
<AButton>测试</AButton>
</ABadge>
<ABadge type="dot">
<AButton>测试</AButton>
</ABadge>
<ABadge type="custom">
<template #content>
<AIcon icon="material-symbols:10k-outline-rounded" />
</template>
<AButton>测试</AButton>
</ABadge>
<ABadge count="12" :offset="[10, -5]">
<AButton>测试</AButton>
</ABadge>
<ABadge count="12" color="blue">
<AButton>测试</AButton>
</ABadge>
<ABadge count="12" type="ribbon" ribbon-text="123" background-color="red">
<div
style="
width: 200px;
height: 120px;
background-color: #fff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: #333;
"
>
测试卡片
</div>
</ABadge>
<ABadge count="12" type="ribbon" ribbon-text="123" background-color="red" color="blue">
<div
style="
width: 200px;
height: 120px;
background-color: #fff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: #333;
"
>
测试卡片
</div>
</ABadge>
</div>
</template>
<style lang="scss" scoped>
.container {
height: 800px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 16px;
}
</style>
@@ -0,0 +1,83 @@
@use '../_styles/_variables.scss' as *;
@use 'sass:color';
$badge-prefix: '#{$prefix}-badge';
.#{$badge-prefix} {
box-sizing: border-box;
margin: 0;
padding: 0;
list-style: none;
position: relative;
display: inline-block;
.#{$badge-prefix}-dot,
.#{$badge-prefix}-circle,
.#{$badge-prefix}-custom {
position: absolute;
top: 0;
right: 0;
color: $text-color-anti;
text-align: center;
font-size: 12px;
transform: translate(50%, -50%);
transform-origin: 100% 0;
box-sizing: content-box;
}
.#{$badge-prefix}-circle {
padding: 0px 6px;
min-width: 8px;
height: 20px;
background-color: $danger-color;
line-height: 20px;
border-radius: 10px;
box-shadow: 0 0 0 2px $bg-color-container;
}
.#{$badge-prefix}-dot {
right: 1px;
margin-top: 1px;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: $danger-color;
box-shadow: 0 0 0 2px $bg-color-container;
}
.#{$badge-prefix}-custom {
color: $text-color-base;
background-color: $bg-color-container;
}
.#{$badge-prefix}-ribbon {
margin: 0;
padding: 0 6px;
font-size: 14px;
line-height: 22px;
list-style: none;
position: absolute;
top: 8px;
white-space: nowrap;
background-color: $primary-color;
border-radius: 4px;
inset-inline-end: -8px;
border-end-end-radius: 0;
color: $primary-color;
&-text {
color: $text-color-anti;
}
&-corner {
position: absolute;
top: 100%;
right: 0;
width: 0;
height: 0;
border-style: solid;
border-width: 8px 8px 0 0;
border-color: currentColor transparent transparent transparent;
}
}
}
@@ -0,0 +1,160 @@
import type { PropType, VNode } from 'vue'
import { computed, defineComponent } from 'vue'
import { getPrefixCls } from '../_utils/getPrefixCls'
export default defineComponent({
name: 'Badge',
props: {
type: {
type: String as () => 'circle' | 'dot' | 'custom' | 'ribbon',
default: 'circle',
},
count: {
type: [Number, String] as PropType<number | string>,
},
minCount: {
type: [Number, String] as PropType<number | string>,
default: undefined,
},
maxCount: {
type: [Number, String] as PropType<number | string>,
},
showZero: {
type: Boolean,
default: false,
},
ribbonText: {
type: [String, Object, Function] as PropType<string | VNode | (() => VNode)>,
default: '',
},
offset: {
type: Array as PropType<(string | number)[]>,
default: undefined,
},
color: {
type: String,
default: '',
},
backgroundColor: {
type: String,
default: '',
},
class: {
type: [String, Array, Object],
default: '',
},
style: {
type: Object,
default: () => ({}),
},
},
setup(props, { slots }) {
const prefixCls = getPrefixCls('badge')
const displayCount = computed(() => {
if (props.type !== 'circle')
return props.count
let countNum = Number(props.count)
const maxNum = props.maxCount != null ? Number(props.maxCount) : null
const minNum = props.minCount != null ? Number(props.minCount) : null
const isCountNumeric = Number.isFinite(countNum)
const isMaxNumeric = maxNum !== null && Number.isFinite(maxNum)
const isMinNumeric = minNum !== null && Number.isFinite(minNum)
// 如果传了 minCount,showZero 不生效
if (!isMinNumeric && !props.showZero && countNum === 0)
return undefined
// 先处理 minCount
if (isCountNumeric && isMinNumeric && countNum < (minNum as number)) {
countNum = minNum as number
}
// 再处理 maxCount
if (isCountNumeric && isMaxNumeric && countNum > (maxNum as number)) {
return `${maxNum}+`
}
return countNum
})
return () => {
const supStyle: Record<string, string> = {}
if (props.offset && props.offset.length === 2) {
const [x, y] = props.offset
supStyle.top = typeof y === 'number' ? `${y}px` : y
supStyle.right = typeof x === 'number' ? `${x}px` : x
}
// backgroundColor
if (props.backgroundColor) {
if (props.type === 'ribbon') {
supStyle.backgroundColor = props.backgroundColor
supStyle.color = props.backgroundColor
}
else {
supStyle.color = props.backgroundColor
}
}
const ribbonTextStyle: Record<string, string> = {}
// color
if (props.color) {
if (props.type === 'ribbon') {
ribbonTextStyle.color = props.color
}
else {
supStyle.color = props.color
}
}
let supNode = null
if (props.type === 'dot') {
supNode = <sup class={`${prefixCls}-dot`} style={supStyle}></sup>
}
else if (props.type === 'circle' && displayCount.value !== undefined) {
supNode = (
<sup class={`${prefixCls}-circle`} style={supStyle}>
{displayCount.value}
</sup>
)
}
else if (props.type === 'custom') {
supNode = (
<sup class={`${prefixCls}-custom`} style={supStyle}>
{slots.content?.()}
</sup>
)
}
else if (props.type === 'ribbon') {
const renderRibbonText = () => {
if (slots.ribbon)
return slots.ribbon()
if (typeof props.ribbonText === 'function')
return props.ribbonText()
return props.ribbonText
}
supNode = (
<sup class={`${prefixCls}-ribbon`} style={supStyle}>
<div class={`${prefixCls}-ribbon-text`} style={ribbonTextStyle}>
{renderRibbonText()}
</div>
<div class={`${prefixCls}-ribbon-corner`}></div>
</sup>
)
}
return (
<div class={[prefixCls, props.class]} style={{ ...props.style }}>
{slots.default?.()}
{supNode}
</div>
)
}
},
})
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { ref } from 'vue'
const loading = ref(false)
function toggleLoading() {
loading.value = !loading.value
}
</script>
<template>
<div class="main">
<!-- 卡片 -->
<ACard
:loading="loading"
title="标题"
sub-title="副标题"
style="width: 400px;"
hover-shadow
cover="https://tdesign.gtimg.com/site/source/card-demo.png"
>
<template #actions>
actions
</template>
这里是内容
<template #footer>
这里是footer
</template>
</ACard>
<!-- 按钮 -->
<button style="margin-top: 16px;" @click="toggleLoading">
{{ loading ? '关闭 Loading' : '开启 Loading' }}
</button>
</div>
</template>
<style lang="scss" scoped>
.main {
height: 800px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
</style>
@@ -0,0 +1,92 @@
@use '../_styles/_variables.scss' as *;
@use 'sass:color';
$card-prefix: '#{$prefix}-card';
.#{$card-prefix} {
font: 14px;
margin: 0;
padding: 0;
list-style: none;
position: relative;
box-sizing: border-box;
border-radius: $border-radius;
background-color: $bg-color-container;
color: $text-color-base;
&.#{$card-prefix}-hover {
cursor: pointer;
&:hover {
box-shadow: $shadow-surface;
}
.#{$card-prefix}-cover {
margin-top: -1px;
margin-inline-start: -1px;
margin-inline-end: -1px;
}
}
&.#{$card-prefix}-border {
border: 1px solid $border-color;
}
.#{$card-prefix}-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
position: relative;
border-bottom: 1px solid transparent;
&.#{$card-prefix}-separator {
border-color: $separator-color;
}
.#{$card-prefix}-header-wrapper {
flex: 1;
display: flex;
align-items: center;
margin-right: 16px;
.#{$card-prefix}-title {
font-size: 16px;
color: $text-color-base;
font-weight: 600;
word-break: break-all;
}
.#{$card-prefix}-subtitle {
font-size: 14px;
color: $text-color-secondary;
word-break: break-all;
}
}
.#{$card-prefix}-actions {
float: right;
}
}
.#{$card-prefix}-body {
display: flow-root;
padding: 16px;
}
.#{$card-prefix}-footer {
border-top: 1px solid transparent;
&.#{$card-prefix}-separator {
border-color: $separator-color;
}
display: flex;
align-items: center;
padding: 10px 16px;
}
.#{$card-prefix}-cover {
display: flow-root;
img {
display: flow-root;
width: 100%;
max-height: 100%;
}
}
}
@@ -0,0 +1,150 @@
import type { PropType, VNode } from 'vue'
import { defineComponent } from 'vue'
import { getPrefixCls } from '../_utils/getPrefixCls'
import Spin from '../spin/index'
export default defineComponent({
name: 'Card',
props: {
title: {
type: [String, Object, Function] as PropType<string | VNode | (() => VNode)>,
default: '',
},
subTitle: {
type: [String, Object, Function] as PropType<string | VNode | (() => VNode)>,
default: '',
},
hoverShadow: {
type: Boolean,
default: false,
},
// 分割线
separator: {
type: Boolean,
default: true,
},
// 边框线
border: {
type: Boolean,
default: true,
},
cover: {
type: String,
},
loading: {
type: Boolean,
default: false,
},
loadingProp: {
type: Object as PropType<Record<string, any>>,
default: () => ({}),
},
class: {
type: [String, Array, Object],
default: '',
},
style: {
type: Object,
default: () => ({}),
},
},
setup(props, { slots }) {
const prefixCls = getPrefixCls('card')
const renderHeader = () => {
if (slots.header) {
return <div class={`${prefixCls}-header`}>{slots.header()}</div>
}
const titleContent = slots.title?.() ?? (typeof props.title === 'function' ? props.title() : props.title)
const subTitleContent = slots.subTitle?.() ?? (typeof props.subTitle === 'function' ? props.subTitle() : props.subTitle)
const actionsContent = slots.actions?.()
if (!titleContent && !subTitleContent && !actionsContent)
return null
return (
<div
class={[
`${prefixCls}-header`,
{ [`${prefixCls}-separator`]: props.separator },
]}
>
<div class={`${prefixCls}-header-wrapper`}>
<div>
{titleContent && <div class={`${prefixCls}-title`}>{titleContent}</div>}
{subTitleContent && <div class={`${prefixCls}-sub-title`}>{subTitleContent}</div>}
</div>
</div>
{actionsContent && <div class={`${prefixCls}-header-actions`}>{actionsContent}</div>}
</div>
)
}
const renderBody = () => {
if (!slots.default)
return null
return <div class={`${prefixCls}-body`}>{slots.default()}</div>
}
const renderFooter = () => {
if (!slots.footer)
return null
return (
<div
class={[
`${prefixCls}-footer`,
{ [`${prefixCls}-separator`]: props.separator },
]}
>
{slots.footer()}
</div>
)
}
const renderCover = () => {
if (slots.cover) {
return <div class={`${prefixCls}-cover`}>{slots.cover()}</div>
}
if (props.cover) {
return (
<div class={`${prefixCls}-cover`}>
<img src={props.cover} alt="" />
</div>
)
}
return null
}
return () => {
const cardNode = (
<div
class={[
prefixCls,
props.class,
{ [`${prefixCls}-hover`]: props.hoverShadow },
{ [`${prefixCls}-border`]: props.border },
]}
style={{ ...props.style }}
>
{renderHeader()}
{renderBody()}
{renderCover()}
{renderFooter()}
</div>
)
// 如果 loading 就包一层 Spin
if (props.loading) {
return (
<Spin {...props.loadingProp} loading={props.loading}>
{cardNode}
</Spin>
)
}
return cardNode
}
},
})
@@ -0,0 +1,19 @@
@use '../_styles/_variables.scss' as *;
@use 'sass:color';
$collapse-prefix: '#{$prefix}-collapse';
.#{$collapse-prefix} {
font-size: 14px;
color: $text-color-base;
box-sizing: border-box;
margin: 0;
padding: 0;
list-style: none;
border: solid 1px $border-color;
border-bottom: 0;
background: $bg-color-container;
.#{$collapse-prefix}-panel {
}
}
@@ -0,0 +1,25 @@
import { defineComponent } from 'vue'
import { getPrefixCls } from '../_utils/getPrefixCls'
export default defineComponent({
name: 'Collapse',
props: {
class: {
type: [String, Array, Object],
default: '',
},
style: {
type: Object,
default: () => ({}),
},
},
emits: [],
setup(props, { slots, emit }) {
const prefixCls = getPrefixCls('collapse')
return () => (
<div class={[prefixCls, props.class]} style={{ ...props.style }}>
{slots.default?.()}
</div>
)
},
})
@@ -1,7 +1,10 @@
export { default as ConfigProvider } from './_config'
export { default as Badge } from './badge'
export { default as Button } from './button'
export { default as Card } from './card'
export { default as Checkbox } from './checkbox/checkbox'
export { default as CheckboxGroup } from './checkbox/checkbox-group'
export { default as Collapse } from './collapse'
export { default as Drawer } from './drawer'
export { default as Group } from './group'
export { default as Icon } from './icon'
@@ -1,25 +1,28 @@
@use './_styles/index.scss' as *;
@use './_styles/variables' as variables;
@use './badge/index.scss' as *;
@use './button/index.scss' as *;
@use './input/index.scss' as *;
@use './textarea/index.scss' as *;
@use './group/index.scss' as *;
@use './popup/index.scss' as *;
@use './popconfirm/index.scss' as *;
@use './icon/index.scss' as *;
@use './message/index.scss' as *;
@use './notification/index.scss' as *;
@use './drawer/index.scss' as *;
@use './tooltip/index.scss' as *;
@use './tag/index.scss' as *;
@use './space/index.scss' as *;
@use './separator/index.scss' as *;
@use './radio/index.scss' as *;
@use './card/index.scss' as *;
@use './collapse/index.scss' as *;
@use './checkbox/index.scss' as *;
@use './switch/index.scss' as *;
@use './spin/index.scss' as *;
@use './drawer/index.scss' as *;
@use './group/index.scss' as *;
@use './icon/index.scss' as *;
@use './input/index.scss' as *;
@use './message/index.scss' as *;
@use './modal/index.scss' as *;
@use './notification/index.scss' as *;
@use './popconfirm/index.scss' as *;
@use './popup/index.scss' as *;
@use './radio/index.scss' as *;
@use './separator/index.scss' as *;
@use './space/index.scss' as *;
@use './spin/index.scss' as *;
@use './switch/index.scss' as *;
@use './tag/index.scss' as *;
@use './textarea/index.scss' as *;
@use './tooltip/index.scss' as *;
[class*='#{variables.$prefix}-'] {
-webkit-box-sizing: border-box;
@@ -4,6 +4,7 @@ import type { App, Component } from 'vue'
import * as comps from './components'
import { useMessage } from './message'
import { useModal } from './modal/useModal'
import { useNotification } from './notification'
// 按需导出组件(可单独 import)
export * from './components'
@@ -40,4 +41,4 @@ const AoDesign = {
},
}
export { AoDesign, useMessage, useNotification }
export { AoDesign, useMessage, useModal, useNotification }
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { useModal } from '@ao/ao-design'
import { ref } from 'vue'
const visible1 = ref(false)
const visible2 = ref(false)
const visible3 = ref(false)
function onConfirm() {
alert('你点击了确认')
visible3.value = false
}
const modal = useModal()
function showInfo() {
modal.info({
title: '提示',
content: '这是信息提示内容',
confirmBtn: { label: '确定' },
cancelBtn: { label: '取消' },
})
}
function showSuccess() {
modal.success({
title: '成功',
content: '操作成功!',
})
}
function showCustom() {
modal.open({
title: '自定义 Modal',
content: '这里是自定义内容',
width: '500px',
height: '300px',
draggable: true,
})
}
</script>
<template>
<div style="padding: 20px;">
<h2>Modal 示例</h2>
<!-- header 拖拽 -->
<AButton @click="visible1 = true">
打开 Header 拖拽
</AButton>
<AModal v-model:visible="visible1" :draggable="true">
<template #title>
Header 拖拽示例
</template>
<p>这是 header 拖拽</p>
</AModal>
<br><br>
<!-- 自定义拖拽区域 -->
<AButton @click="visible2 = true">
打开 Ref 拖拽
</AButton>
<AModal v-model:visible="visible2" :draggable="true" draggable-selector=".drag-area">
<div class="drag-area" style="padding:10px; background:#ffdede; cursor:move">
拖拽这里
</div>
<p>Modal 内容</p>
</AModal>
<br><br>
<!-- 自定义 layout 覆盖 header+body+footer -->
<AButton @click="visible3 = true">
打开自定义 Layout Modal
</AButton>
<AModal v-model:visible="visible3">
<template #layout>
<div style="padding: 20px; text-align: center;">
<h3 style="margin-bottom: 16px;">
🎉 自定义 Layout
</h3>
<p>这里完全覆盖 header、body、footer</p>
<div style="margin-top: 20px;">
<AButton @click="visible3 = false">
关闭
</AButton>
<AButton type="primary" style="margin-left: 8px;" @click="onConfirm">
确认
</AButton>
</div>
</div>
</template>
</AModal>
<br><br>
<!-- useModal 调用 -->
<AButton @click="showInfo">
Info Modal
</AButton>
<AButton style="margin-left: 8px;" @click="showSuccess">
Success Modal
</AButton>
<AButton style="margin-left: 8px;" @click="showCustom">
Custom Modal
</AButton>
</div>
</template>
@@ -41,6 +41,7 @@ $btn-prefix: '#{$prefix}-btn';
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
.#{$modal-prefix} {
background: #fff;
@@ -52,6 +53,7 @@ $btn-prefix: '#{$prefix}-btn';
height: 300px;
display: flex;
flex-direction: column;
pointer-events: auto;
&-header {
display: flex;
@@ -61,15 +63,35 @@ $btn-prefix: '#{$prefix}-btn';
font-size: 16px;
padding: 12px 16px;
word-break: break-all;
.#{$modal-prefix}-icon:first-of-type {
margin-left: auto;
}
.#{$modal-prefix}-icon {
flex-shrink: 0;
color: $text-color-assist;
cursor: pointer;
border-radius: $border-radius;
width: 20px;
height: 20px;
& + .#{$modal-prefix}-icon {
margin-left: 8px;
}
&:hover {
background-color: $default-color-hover;
}
}
}
&-title {
display: flex;
align-items: center;
line-height: 1;
flex: 1;
min-width: 0; // 防止内容过长撑破布局
}
&-icon {
&-type {
font-size: 24px;
margin-right: 6px;
&-info {
@@ -89,19 +111,6 @@ $btn-prefix: '#{$prefix}-btn';
}
}
&-close {
flex-shrink: 0;
margin-left: auto;
color: $text-color-assist;
cursor: pointer;
border-radius: $border-radius;
width: 20px;
height: 20px;
&:hover {
background-color: $default-color-hover;
}
}
&-body {
flex: 1 1 auto;
padding: 12px 16px;
@@ -1,12 +1,21 @@
import type { PropType } from 'vue'
import type { PropType, VNode } from 'vue'
import { computed, defineComponent, onMounted, ref, Teleport, Transition, watch } from 'vue'
import { getPrefixCls } from '../_utils/getPrefixCls'
import Button from '../button/index'
import Icon from '../icon/index'
import { nextZIndex } from './modalManager'
export default defineComponent({
name: 'Modal',
props: {
title: {
type: [String, Object, Function] as PropType<string | VNode | (() => VNode)>,
default: '',
},
content: {
type: [String, Object, Function] as PropType<string | VNode | (() => VNode)>,
default: '',
},
visible: {
type: Boolean,
default: undefined,
@@ -35,13 +44,13 @@ export default defineComponent({
type: Boolean,
default: true,
},
confirmBtn: {
confirmBtnProps: {
type: null,
default: () => ({ label: '确认', type: 'solid', status: 'primary', size: 'small' }),
default: () => ({}),
},
cancelBtn: {
cancelBtnProps: {
type: null,
default: () => ({ label: '取消', type: 'solid', status: 'default', size: 'small' }),
default: () => ({}),
},
destroyOnClose: {
type: Boolean,
@@ -51,9 +60,13 @@ export default defineComponent({
type: [String, Object] as PropType<string | HTMLElement>,
default: 'body',
},
showFullscreen: {
type: Boolean,
default: true,
},
fullscreen: {
type: Boolean,
default: false,
default: undefined,
},
draggable: {
type: Boolean,
@@ -66,7 +79,33 @@ export default defineComponent({
draggableSelector: {
type: String,
default: '',
}, // 拖拽区域选择器
},
width: {
type: String,
default: undefined,
},
height: {
type: String,
default: undefined,
},
maxWidth: {
type: String,
default: undefined,
},
maxHeight: {
type: String,
default: undefined,
},
onBeforeConfirm: {
type: Function as PropType<
(done: (ok: boolean) => void) => void | boolean | Promise<boolean>
>,
},
onBeforeCancel: {
type: Function as PropType<
(done: (ok: boolean) => void) => void | boolean | Promise<boolean>
>,
},
class: {
type: [String, Array, Object],
default: '',
@@ -88,6 +127,11 @@ export default defineComponent({
const isMounted = ref(isVisible.value)
const isRootVisible = ref(isVisible.value)
const _fullscreen = ref(false)
const isFullscreen = computed(() => props.fullscreen ?? _fullscreen.value)
const zIndex = ref(0)
watch(isVisible, (value) => {
if (_visible.value !== value)
_visible.value = value
@@ -102,6 +146,7 @@ export default defineComponent({
})
onMounted(() => {
zIndex.value = nextZIndex()
if (props.appendTo instanceof HTMLElement) {
targetEl.value = props.appendTo
}
@@ -112,21 +157,98 @@ export default defineComponent({
}
})
const handleMaskClick = () => {
if (props.maskClosable)
emit('update:visible', false)
let promiseNumber = 0
const handleConfirm = async (e?: Event) => {
const currentPromiseNumber = ++promiseNumber
let allow = true
if (props.onBeforeConfirm) {
confirmLoading.value = true
try {
allow = await new Promise<boolean>((resolve) => {
let called = false
const done = (ok: boolean) => {
if (!called) {
called = true
resolve(ok)
}
}
const result = props.onBeforeConfirm!(done)
if (typeof result === 'boolean') {
done(result)
}
else if (result instanceof Promise) {
result
.then(ok => done(ok ?? true))
.catch(() => done(false))
}
// 如果使用 done 回调,resolve 会在调用方触发
})
}
catch {
allow = false
}
}
if (currentPromiseNumber === promiseNumber) {
if (allow) {
emit('update:visible', false)
emit('confirm', e)
}
confirmLoading.value = false
}
}
const handleCancel = async () => {
emit('update:visible', false)
emit('cancel')
confirmLoading.value = false
}
const handleConfirm = async () => {
emit('update:visible', false)
emit('confirm')
confirmLoading.value = false
const handleCancel = async (e?: Event) => {
const currentPromiseNumber = ++promiseNumber
let allow = true
if (props.onBeforeCancel) {
confirmLoading.value = true
try {
allow = await new Promise<boolean>((resolve) => {
let called = false
const done = (ok: boolean) => {
if (!called) {
called = true
resolve(ok)
}
}
const result = props.onBeforeCancel!(done)
if (typeof result === 'boolean') {
done(result)
}
else if (result instanceof Promise) {
result
.then(ok => done(ok ?? true))
.catch(() => done(false))
}
// 如果使用 done 回调,resolve 会在调用方触发
})
}
catch {
allow = false
}
}
if (currentPromiseNumber === promiseNumber) {
if (allow) {
emit('update:visible', false)
emit('cancel', e)
}
confirmLoading.value = false
}
}
const handleAfterEnter = () => emit('open')
const handleAfterLeave = () => {
isRootVisible.value = false
@@ -135,6 +257,10 @@ export default defineComponent({
emit('close')
}
const handleMaskClick = () => {
if (props.maskClosable)
handleCancel()
}
// 拖拽逻辑
const dragData = { dragging: false, offsetX: 0, offsetY: 0 }
@@ -193,6 +319,12 @@ export default defineComponent({
document.addEventListener('mouseup', handleMouseUp)
}
const updateFullscreen = () => {
if (props.fullscreen === undefined) {
_fullscreen.value = !isFullscreen.value
}
}
const typeIconMap: Record<string, string> = {
success: 'ix:success-filled',
danger: 'ix:error-filled',
@@ -201,49 +333,159 @@ export default defineComponent({
question: 'ix:question-filled',
}
const modalStyle = computed(() => {
// 如果 fullscreen 模式,直接返回空对象
if (isFullscreen.value) {
return {}
}
const res: Record<string, string> = {}
if (props.width !== undefined)
res.width = props.width
if (props.height !== undefined)
res.height = props.height
if (props.maxWidth !== undefined)
res.maxWidth = props.maxWidth
if (props.maxHeight !== undefined)
res.maxHeight = props.maxHeight
return res
})
const renderTitle = computed(() => {
if (slots.title)
return slots.title()
if (typeof props.title === 'function')
return props.title()
if (typeof props.title === 'string')
return props.title
return props.title // 假设是 VNode
})
const renderContent = computed(() => {
if (slots.default)
return slots.default()
if (typeof props.content === 'function')
return props.content()
if (typeof props.content === 'string')
return props.content
return props.content // 假设是 VNode
})
// 在 setup 里定义一个合并函数
const mergedconfirmBtnProps = computed(() => ({
label: '确认',
type: 'solid',
status: 'primary',
size: 'small',
...(props.confirmBtnProps || {}),
}))
const mergedcancelBtnProps = computed(() => ({
label: '取消',
type: 'solid',
status: 'default',
size: 'small',
...(props.cancelBtnProps || {}),
}))
return () => (
<Teleport to={targetEl.value}>
{isMounted.value && (
<div v-show={isRootVisible.value} class={`${prefixCls}-container`} style={{ position: 'fixed', zIndex: 1000 }}>
<div v-show={isRootVisible.value} class={`${prefixCls}-container`} style={{ position: 'fixed', zIndex: zIndex.value }}>
{props.mask && (
<Transition name={`${prefixCls}-mask-fade`} appear>
<div v-show={isVisible.value} class={`${prefixCls}-mask`} onClick={handleMaskClick} />
<div
v-show={isVisible.value}
class={`${prefixCls}-mask`}
onClick={handleMaskClick}
/>
</Transition>
)}
<Transition name={`${prefixCls}-content-fade`} appear onAfterEnter={handleAfterEnter} onAfterLeave={handleAfterLeave}>
<Transition
name={`${prefixCls}-content-fade`}
appear
onAfterEnter={handleAfterEnter}
onAfterLeave={handleAfterLeave}
>
<div
v-show={isVisible.value}
class={[`${prefixCls}-wrapper`, `${prefixCls}-position-${props.position}`, props.class]}
style={{ ...props.style }}
class={[`${prefixCls}-wrapper`, `${prefixCls}-position-${props.position}`]}
onClick={e => e.stopPropagation()}
>
<div
ref={modalRef}
onMousedown={handleMouseDown}
class={`${prefixCls} ${props.fullscreen ? `${prefixCls}-fullscreen` : ''}`}
class={[prefixCls, isFullscreen.value && `${prefixCls}-fullscreen`, props.class]}
style={{ ...(props.style as object), ...modalStyle.value }}
>
<div
class={`${prefixCls}-header`}
style={{
cursor: props.draggable && (!props.draggableSelector || props.draggableSelector === `${prefixCls}-header`) ? 'move' : undefined,
}}
>
{props.type !== 'default' && <Icon class={[`${prefixCls}-icon`, `${prefixCls}-icon-${props.type}`]} icon={typeIconMap[props.type]} />}
<span class={`${prefixCls}-title`}>{slots.title?.()}</span>
{props.showClose && <Icon class={`${prefixCls}-close`} icon="ix:close" />}
</div>
<div class={`${prefixCls}-body`}>{slots.default?.()}</div>
<div class={`${prefixCls}-footer`}>
{slots.footer?.() ?? (
<>
<Button {...props.cancelBtn} onClick={handleCancel}>{props.cancelBtn.label}</Button>
<Button loading={confirmLoading.value} {...props.confirmBtn} onClick={handleConfirm}>{props.confirmBtn.label}</Button>
</>
)}
</div>
{slots.layout
? (
// 👉 如果有 layout 插槽,用户完全接管布局
slots.layout()
)
: (
<>
<div
class={`${prefixCls}-header`}
style={{
cursor:
props.draggable
&& (!props.draggableSelector || props.draggableSelector === `${prefixCls}-header`)
? 'move'
: undefined,
}}
>
{slots.header
? slots.header()
: (
<>
{props.type !== 'default' && (
<Icon
class={[`${prefixCls}-type`, `${prefixCls}-type-${props.type}`]}
icon={typeIconMap[props.type]}
/>
)}
<span class={`${prefixCls}-title`}>{renderTitle.value}</span>
{props.showFullscreen && (
<Icon
class={`${prefixCls}-icon`}
icon={isFullscreen.value ? 'ix:full-screen-exit' : 'ix:full-screen'}
onClick={updateFullscreen}
/>
)}
{props.showClose && (
<Icon
class={`${prefixCls}-icon`}
icon="ix:close"
onClick={handleCancel}
/>
)}
</>
)}
</div>
<div class={`${prefixCls}-body`}>{renderContent.value}</div>
<div class={`${prefixCls}-footer`}>
{slots.footer?.() ?? (
<>
<Button {...mergedcancelBtnProps.value} onClick={handleCancel}>
{mergedcancelBtnProps.value.label}
</Button>
<Button
loading={confirmLoading.value}
{...mergedconfirmBtnProps.value}
onClick={handleConfirm}
>
{mergedconfirmBtnProps.value.label}
</Button>
</>
)}
</div>
</>
)}
</div>
</div>
</Transition>
</div>
)}
</Teleport>
@@ -1,23 +1,6 @@
// modalZIndex.ts
let zIndexBase = 1000
const modalStack: number[] = [] // 当前打开的 Modal zIndex
export function addModal() {
const zIndex = ++zIndexBase // 每次直接递增
modalStack.push(zIndex)
return zIndex
}
export function removeModal(zIndex: number) {
const idx = modalStack.indexOf(zIndex)
if (idx !== -1) {
modalStack.splice(idx, 1) // 只移出 stack,不回收 zIndex
}
}
export function getTopZIndex() {
return modalStack.length > 0 ? modalStack[modalStack.length - 1] : zIndexBase
}
export function getMaskZIndex() {
return modalStack.length > 0 ? modalStack[modalStack.length - 1] - 1 : zIndexBase
export function nextZIndex() {
return ++zIndexBase
}
@@ -1,224 +0,0 @@
import type { PropType } from 'vue'
import { computed, defineComponent, onMounted, ref, Teleport, Transition, watch } from 'vue'
import { getPrefixCls } from '../_utils/getPrefixCls'
import Button from '../button/index'
import Icon from '../icon/index'
export default defineComponent({
name: 'Modal',
props: {
visible: {
type: Boolean,
default: undefined,
},
defaultVisible: {
type: Boolean,
default: false,
},
mask: {
type: Boolean,
default: true,
},
maskClosable: {
type: Boolean,
default: true,
},
position: {
type: String as PropType<'top' | 'center' | 'bottom'>,
default: 'center',
},
type: {
type: String as PropType<'default' | 'info' | 'success' | 'warning' | 'danger' | 'question'>,
default: 'default',
},
showClose: {
type: Boolean,
default: true,
},
confirmBtn: {
type: null,
default: () => ({ label: '确认', type: 'solid', status: 'primary', size: 'small' }),
},
cancelBtn: {
type: null,
default: () => ({ label: '取消', type: 'solid', status: 'default', size: 'small' }),
},
onBeforeConfirm: {
type: Function as PropType<(done: (ok: boolean) => void) => void | boolean | Promise<boolean>>,
},
onBeforeCancel: {
type: Function as PropType<(done: (ok: boolean) => void) => void | boolean | Promise<boolean>>,
},
destroyOnClose: {
type: Boolean,
default: false,
},
appendTo: {
type: [String, Object] as PropType<string | HTMLElement>,
default: 'body',
},
class: {
type: [String, Array, Object],
default: '',
},
style: {
type: Object,
default: () => ({}),
},
},
emits: ['update:visible', 'confirm', 'cancel', 'open', 'close', 'beforeOpen', 'beforeClose'],
setup(props, { slots, emit }) {
const prefixCls = getPrefixCls('modal')
const targetEl = ref<HTMLElement>(document.body)
const confirmLoading = ref(false)
const _visible = ref(props.defaultVisible)
const _rendered = ref(props.defaultVisible)
const _mounted = ref(props.defaultVisible)
watch(() => props.visible, (val) => {
if (val !== undefined) {
_visible.value = val
if (val) {
_mounted.value = true
_rendered.value = true
}
}
}, { immediate: true })
onMounted(() => {
if (props.appendTo instanceof HTMLElement) {
targetEl.value = props.appendTo
}
else if (typeof props.appendTo === 'string') {
const el = document.querySelector(props.appendTo)
if (el)
targetEl.value = el as HTMLElement
}
})
const handleMaskClick = () => {
if (props.maskClosable) {
emit('update:visible', false)
}
}
const handleCancel = async () => {
let allow = true
if (props.onBeforeCancel) {
try {
allow = await new Promise<boolean>((resolve) => {
const result = props.onBeforeCancel!(ok => resolve(ok))
if (typeof result === 'boolean')
resolve(result)
else if (result instanceof Promise)
result.then(resolve).catch(() => resolve(false))
})
}
catch {
allow = false
}
}
if (allow) {
emit('update:visible', false)
emit('cancel')
confirmLoading.value = false
}
}
const handleConfirm = async () => {
let allow = true
if (props.onBeforeConfirm) {
confirmLoading.value = true
try {
allow = await new Promise<boolean>((resolve) => {
const result = props.onBeforeConfirm!(ok => resolve(ok))
if (typeof result === 'boolean')
resolve(result)
else if (result instanceof Promise)
result.then(resolve).catch(() => resolve(false))
})
}
catch { allow = false }
}
if (allow) {
emit('update:visible', false)
emit('confirm')
}
confirmLoading.value = false
}
const handleBeforeEnter = () => {
emit('beforeOpen')
}
const handleAfterEnter = () => {
emit('open')
}
const handleBeforeLeave = () => {
emit('beforeClose')
}
const handleAfterLeave = () => {
_mounted.value = false
emit('close')
}
const typeIconMap: Record<string, string> = {
success: 'ix:success-filled',
danger: 'ix:error-filled',
warning: 'ix:warning-filled',
info: 'ix:info-filled',
question: 'ix:question-filled',
}
return () => {
const shouldRenderPopup = props.destroyOnClose
? _visible.value
: _visible.value || _rendered.value
return (
<Teleport to={targetEl.value}>
{shouldRenderPopup && (
<div v-show={_mounted.value} class={`${prefixCls}-container`} style={{ position: 'fixed', zIndex: 1000 }}>
{props.mask && (
<Transition name={`${prefixCls}-mask-fade`} appear>
<div v-show={_visible.value} class={`${prefixCls}-mask`} onClick={handleMaskClick} />
</Transition>
)}
<Transition
name={`${prefixCls}-content-fade`}
appear
onBeforeEnter={handleBeforeEnter}
onAfterEnter={handleAfterEnter}
onBeforeLeave={handleBeforeLeave}
onAfterLeave={handleAfterLeave}
>
<div v-show={_visible.value} class={[`${prefixCls}-wrapper`, `${prefixCls}-position-${props.position}`, props.class]} style={{ ...props.style }}>
<div class={`${prefixCls}`}>
<div class={`${prefixCls}-header`}>
{props.type !== 'default' && <Icon class={[`${prefixCls}-icon`, `${prefixCls}-icon-${props.type}`]} icon={typeIconMap[props.type]} />}
<span class={`${prefixCls}-title`}>{slots.title?.()}</span>
{props.showClose && <Icon class={`${prefixCls}-close`} icon="ix:close" />}
</div>
<div class={`${prefixCls}-body`}>{slots.default?.()}</div>
<div class={`${prefixCls}-footer`}>
{slots.footer?.() ?? (
<>
<Button {...props.cancelBtn} onClick={handleCancel}>{props.cancelBtn.label}</Button>
<Button loading={confirmLoading.value} {...props.confirmBtn} onClick={handleConfirm}>{props.confirmBtn.label}</Button>
</>
)}
</div>
</div>
</div>
</Transition>
</div>
)}
</Teleport>
)
}
},
})
@@ -0,0 +1,158 @@
import type { AppContext, VNode } from 'vue'
import { createVNode, getCurrentInstance, nextTick, render } from 'vue'
import Modal from './index'
function omit<T extends Record<string, any>, K extends keyof T>(
object: T,
keys: K[],
): Omit<T, K> {
const result = { ...object }
keys.forEach(key => delete result[key])
return result
}
export interface ModalOptions {
layout?: string | VNode | (() => VNode)
title?: string | VNode | (() => VNode)
content?: string | VNode | (() => VNode)
footer?: string | VNode | (() => VNode)
position?: 'top' | 'center' | 'bottom'
confirmBtnProps?: Record<string, any>
cancelBtnProps?: Record<string, any>
width?: string
height?: string
fullscreen?: boolean
showClose?: boolean
showFullscreen?: boolean
draggable?: boolean
draggableSelector?: string
confirmBtn?: any
cancelBtn?: any
mask?: boolean
maskClosable?: boolean
onConfirm?: () => void
onCancel?: () => void
onClose?: () => void
}
function createModal(options: ModalOptions & { type?: string }, appContext?: AppContext) {
let container: HTMLDivElement | null = document.createElement('div')
document.body.appendChild(container)
let vm: VNode
const handleConfirm = () => {
if (vm.component)
vm.component.props.visible = false
options.onConfirm?.()
}
const handleCancel = () => {
if (vm.component)
vm.component.props.visible = false
options.onCancel?.()
}
const handleClose = async () => {
await nextTick()
if (container) {
render(null, container)
document.body.removeChild(container)
}
container = null
options.onClose?.()
}
const defaultConfig = {
visible: true,
onConfirm: handleConfirm,
onCancel: handleCancel,
onClose: handleClose,
type: options.type ?? 'default',
}
vm = createVNode(
Modal,
{
...defaultConfig,
...omit(options, ['layout', 'content', 'title', 'footer', 'onConfirm', 'onCancel', 'onClose']),
},
options.layout
? {
layout:
typeof options.layout === 'function'
? options.layout
: () => options.layout,
}
: {
default: options.content
? typeof options.content === 'function'
? options.content
: () => options.content
: undefined,
title: options.title
? typeof options.title === 'function'
? options.title
: () => options.title
: undefined,
footer: options.footer
? typeof options.footer === 'function'
? options.footer
: () => options.footer
: undefined,
},
)
// 自动获取 appContext,除非手动传入
const instanceAppContext = appContext ?? getCurrentInstance()?.appContext ?? undefined
if (instanceAppContext) {
vm.appContext = instanceAppContext
}
render(vm, container)
return {
close: () => {
if (vm.component)
vm.component.props.visible = false
},
update: (newOptions: Partial<ModalOptions>) => {
if (vm.component) {
Object.entries(newOptions).forEach(([key, val]) => {
vm.component!.props[key] = val
})
}
},
}
}
type ModalType = 'default' | 'info' | 'success' | 'warning' | 'danger' | 'question'
export function useModal() {
const open = (options: ModalOptions & { type?: ModalType }, appContext?: AppContext) =>
createModal({ ...options, type: options.type ?? 'default' }, appContext)
const info = (options: ModalOptions, appContext?: AppContext) =>
createModal({ ...options, type: 'info' }, appContext)
const success = (options: ModalOptions, appContext?: AppContext) =>
createModal({ ...options, type: 'success' }, appContext)
const warning = (options: ModalOptions, appContext?: AppContext) =>
createModal({ ...options, type: 'warning' }, appContext)
const danger = (options: ModalOptions, appContext?: AppContext) =>
createModal({ ...options, type: 'danger' }, appContext)
const question = (options: ModalOptions, appContext?: AppContext) =>
createModal({ ...options, type: 'question' }, appContext)
return {
open,
info,
success,
warning,
danger,
question,
}
}
@@ -20,7 +20,7 @@ export default defineComponent({
type: [String, Object, Function] as unknown as () => string | VNode | (() => VNode),
required: false,
},
beforeConfirm: {
onBeforeConfirm: {
type: Function as PropType<
(done: (ok: boolean) => void) => void | boolean | Promise<boolean>
>,
@@ -69,23 +69,37 @@ export default defineComponent({
return val
}
let promiseNumber = 0
const handleConfirm = async () => {
const currentPromiseNumber = ++promiseNumber
let allow = true
if (props.beforeConfirm) {
if (props.onBeforeConfirm) {
confirmLoading.value = true
try {
allow = await new Promise<boolean>((resolve) => {
const result = props.beforeConfirm!(ok => resolve(ok))
let called = false
const done = (ok: boolean) => {
if (!called) {
called = true
resolve(ok)
}
}
const result = props.onBeforeConfirm!(done)
if (typeof result === 'boolean') {
resolve(result)
done(result)
}
else if (result instanceof Promise) {
result.then(ok => resolve(ok)).catch(() => resolve(false))
result
.then(ok => done(ok ?? true))
.catch(() => done(false))
}
// 如果是用 done 回调,就等调用方触发 resolve
// 如果使用 done 回调,resolve 会在调用方触发
})
}
catch {
@@ -93,12 +107,12 @@ export default defineComponent({
}
}
if (allow) {
popVisible.value = false
emit('confirm')
if (currentPromiseNumber === promiseNumber) {
if (allow) {
emit('confirm')
}
confirmLoading.value = false
}
confirmLoading.value = false
}
const handleCancel = () => {
@@ -2,38 +2,44 @@
$spin-prefix: #{$prefix}-spin;
.#{$spin-prefix}-wrapper {
position: relative;
display: inline-block;
width: 100%;
}
.#{$spin-prefix} {
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
&-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: $text-color-base;
}
display: inline-block;
&-icon {
color: $primary-color;
font-size: 16px;
}
&-tip {
margin-top: 4px;
font-size: 12px;
color: $text-color-base;
color: $primary-color;
}
&.rounded {
border-radius: inherit;
&.#{$spin-prefix}-has-tip {
text-align: center;
}
&.#{$spin-prefix}-loading {
position: relative;
user-select: none;
.#{$spin-prefix}-mask {
position: absolute;
inset: 0;
z-index: 11;
text-align: center;
background-color: $bg-color-mask-spin;
transition: opacity 0.1s cubic-bezier(0, 0, 1, 1);
user-select: none;
.#{$spin-prefix}-mask-inner {
position: absolute;
top: 50%;
left: 50%;
z-index: 12;
transform: translate(-50%, -50%);
}
}
}
}
@@ -1,4 +1,4 @@
import type { PropType } from 'vue'
import type { PropType, VNode } from 'vue'
import { defineComponent } from 'vue'
import { getPrefixCls } from '../_utils/getPrefixCls'
import Icon from '../icon'
@@ -29,30 +29,50 @@ export default defineComponent({
}
return () => {
if (!props.loading && !slots.default)
return null
const tipContent = renderTip()
if (!props.loading || !slots.default) {
// loading 为 false 时的结构
return (
<div
class={[
prefixCls,
tipContent ? `${prefixCls}-has-tip` : '',
]}
>
<Icon
icon={props.icon}
size={props.size}
class={`${prefixCls}-icon`}
spin
/>
{tipContent && <div class={`${prefixCls}-tip`}>{tipContent}</div>}
</div>
)
}
// loading 为 true 时的结构
return (
<div class={`${prefixCls}-wrapper`}>
{slots.default?.()}
{props.loading && (
<div
class={`${prefixCls}`}
style={{
background: slots.default ? `rgba(255,255,255,${props.opacity})` : 'transparent',
}}
>
<div class={`${prefixCls}-content`}>
<Icon
icon={props.icon}
size={props.size}
spin
class={`${prefixCls}-icon`}
/>
{renderTip() && <span class={`${prefixCls}-tip`}>{renderTip()}</span>}
</div>
<div
class={[
prefixCls,
`${prefixCls}-loading`,
]}
>
{slots.default()}
<div class={`${prefixCls}-mask`}>
<div class={`${prefixCls}-mask-inner`}>
<Icon
icon={props.icon}
size={props.size}
spin
class={`${prefixCls}-icon`}
/>
{tipContent && <div class={`${prefixCls}-tip`}>{tipContent}</div>}
</div>
)}
</div>
</div>
)
}
+40 -23
View File
@@ -1,31 +1,48 @@
<script setup>
<script setup lang="ts">
import { ref } from 'vue'
const visible1 = ref(false)
const visible2 = ref(false)
const dragHandle = ref<HTMLElement | null>(null)
const loading = ref(false)
function toggleLoading() {
loading.value = !loading.value
}
</script>
<template>
<!-- 默认 header 拖拽 -->
<AModal v-model:visible="visible1" :draggable="true">
<template #title>
Header 拖拽示例
</template>
<p>这是 header 拖拽</p>
</AModal>
<AButton @click="visible1 = true">
打开 Header 拖拽
</AButton>
<div class="main">
<!-- 卡片 -->
<ACard
:loading="loading"
title="标题"
sub-title="副标题"
style="width: 400px;"
hover-shadow
cover="https://tdesign.gtimg.com/site/source/card-demo.png"
>
<template #actions>
actions
</template>
<AModal v-model:visible="visible2" :draggable="true" draggable-selector=".drag-area">
<div class="drag-area" style="padding:10px; background:#ffdede; cursor:move">
拖拽这里
这里是内容
<template #footer>
这里是footer
</template>
</ACard>
<!-- 按钮 -->
<button style="margin-top: 16px;" @click="toggleLoading">
{{ loading ? '关闭 Loading' : '开启 Loading' }}
</button>
</div>
<p>Modal 内容</p>
</AModal>
<AButton @click="visible2 = true">
打开 Ref 拖拽
</AButton>
</template>
<style lang="scss" scoped>
.main {
height: 800px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
</style>
+2
View File
@@ -8,7 +8,9 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ABadge: typeof import('@ao/ao-design')['Badge']
AButton: typeof import('@ao/ao-design')['Button']
ACard: typeof import('@ao/ao-design')['Card']
ACheckbox: typeof import('@ao/ao-design')['Checkbox']
ACheckboxGroup: typeof import('@ao/ao-design')['CheckboxGroup']
AConfigProvider: typeof import('@ao/ao-design')['ConfigProvider']