diff --git a/code/base-project/base-vue/packages/ao-design/src/_styles/_variables.scss b/code/base-project/base-vue/packages/ao-design/src/_styles/_variables.scss index eb6dd992..5a98d9e7 100644 --- a/code/base-project/base-vue/packages/ao-design/src/_styles/_variables.scss +++ b/code/base-project/base-vue/packages/ao-design/src/_styles/_variables.scss @@ -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 使用的组件包括:表格 / diff --git a/code/base-project/base-vue/packages/ao-design/src/_styles/theme/_dark.scss b/code/base-project/base-vue/packages/ao-design/src/_styles/theme/_dark.scss index b43bebaf..7e7e6e76 100644 --- a/code/base-project/base-vue/packages/ao-design/src/_styles/theme/_dark.scss +++ b/code/base-project/base-vue/packages/ao-design/src/_styles/theme/_dark.scss @@ -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 使用的组件包括:表格 / diff --git a/code/base-project/base-vue/packages/ao-design/src/_styles/theme/_light.scss b/code/base-project/base-vue/packages/ao-design/src/_styles/theme/_light.scss index 273b9fc2..c7fd1d57 100644 --- a/code/base-project/base-vue/packages/ao-design/src/_styles/theme/_light.scss +++ b/code/base-project/base-vue/packages/ao-design/src/_styles/theme/_light.scss @@ -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 使用的组件包括:表格 / diff --git a/code/base-project/base-vue/packages/ao-design/src/badge/demo.vue b/code/base-project/base-vue/packages/ao-design/src/badge/demo.vue new file mode 100644 index 00000000..8aa879ed --- /dev/null +++ b/code/base-project/base-vue/packages/ao-design/src/badge/demo.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/code/base-project/base-vue/packages/ao-design/src/badge/index.scss b/code/base-project/base-vue/packages/ao-design/src/badge/index.scss new file mode 100644 index 00000000..3abf9a30 --- /dev/null +++ b/code/base-project/base-vue/packages/ao-design/src/badge/index.scss @@ -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; + } + } +} diff --git a/code/base-project/base-vue/packages/ao-design/src/badge/index.tsx b/code/base-project/base-vue/packages/ao-design/src/badge/index.tsx new file mode 100644 index 00000000..2eaff419 --- /dev/null +++ b/code/base-project/base-vue/packages/ao-design/src/badge/index.tsx @@ -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, + }, + minCount: { + type: [Number, String] as PropType, + default: undefined, + }, + maxCount: { + type: [Number, String] as PropType, + }, + showZero: { + type: Boolean, + default: false, + }, + ribbonText: { + type: [String, Object, Function] as PropType 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 = {} + + 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 = {} + + // 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 = + } + else if (props.type === 'circle' && displayCount.value !== undefined) { + supNode = ( + + {displayCount.value} + + ) + } + else if (props.type === 'custom') { + supNode = ( + + {slots.content?.()} + + ) + } + 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 = ( + +
+ {renderRibbonText()} +
+
+
+ ) + } + + return ( +
+ {slots.default?.()} + {supNode} +
+ ) + } + }, +}) diff --git a/code/base-project/base-vue/packages/ao-design/src/card/demo.vue b/code/base-project/base-vue/packages/ao-design/src/card/demo.vue new file mode 100644 index 00000000..a2004fc1 --- /dev/null +++ b/code/base-project/base-vue/packages/ao-design/src/card/demo.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/code/base-project/base-vue/packages/ao-design/src/card/index.scss b/code/base-project/base-vue/packages/ao-design/src/card/index.scss new file mode 100644 index 00000000..a45ac29d --- /dev/null +++ b/code/base-project/base-vue/packages/ao-design/src/card/index.scss @@ -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%; + } + } +} diff --git a/code/base-project/base-vue/packages/ao-design/src/card/index.tsx b/code/base-project/base-vue/packages/ao-design/src/card/index.tsx new file mode 100644 index 00000000..9ca1b1bf --- /dev/null +++ b/code/base-project/base-vue/packages/ao-design/src/card/index.tsx @@ -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 VNode)>, + default: '', + }, + subTitle: { + type: [String, Object, Function] as PropType 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>, + default: () => ({}), + }, + class: { + type: [String, Array, Object], + default: '', + }, + style: { + type: Object, + default: () => ({}), + }, + }, + setup(props, { slots }) { + const prefixCls = getPrefixCls('card') + + const renderHeader = () => { + if (slots.header) { + return
{slots.header()}
+ } + + 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 ( +
+
+
+ {titleContent &&
{titleContent}
} + {subTitleContent &&
{subTitleContent}
} +
+
+ {actionsContent &&
{actionsContent}
} +
+ ) + } + + const renderBody = () => { + if (!slots.default) + return null + return
{slots.default()}
+ } + + const renderFooter = () => { + if (!slots.footer) + return null + return ( +
+ {slots.footer()} +
+ ) + } + const renderCover = () => { + if (slots.cover) { + return
{slots.cover()}
+ } + + if (props.cover) { + return ( +
+ +
+ ) + } + + return null + } + + return () => { + const cardNode = ( +
+ {renderHeader()} + {renderBody()} + {renderCover()} + {renderFooter()} +
+ ) + + // 如果 loading 就包一层 Spin + if (props.loading) { + return ( + + {cardNode} + + ) + } + + return cardNode + } + }, +}) diff --git a/code/base-project/base-vue/packages/ao-design/src/collapse/index.scss b/code/base-project/base-vue/packages/ao-design/src/collapse/index.scss new file mode 100644 index 00000000..bb4a8249 --- /dev/null +++ b/code/base-project/base-vue/packages/ao-design/src/collapse/index.scss @@ -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 { + } +} diff --git a/code/base-project/base-vue/packages/ao-design/src/collapse/index.tsx b/code/base-project/base-vue/packages/ao-design/src/collapse/index.tsx new file mode 100644 index 00000000..0fc2ec9e --- /dev/null +++ b/code/base-project/base-vue/packages/ao-design/src/collapse/index.tsx @@ -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 () => ( +
+ {slots.default?.()} +
+ ) + }, +}) diff --git a/code/base-project/base-vue/packages/ao-design/src/components.ts b/code/base-project/base-vue/packages/ao-design/src/components.ts index 4aa64b43..c410abd0 100644 --- a/code/base-project/base-vue/packages/ao-design/src/components.ts +++ b/code/base-project/base-vue/packages/ao-design/src/components.ts @@ -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' diff --git a/code/base-project/base-vue/packages/ao-design/src/index.scss b/code/base-project/base-vue/packages/ao-design/src/index.scss index 6e9b9d97..fdedf74f 100644 --- a/code/base-project/base-vue/packages/ao-design/src/index.scss +++ b/code/base-project/base-vue/packages/ao-design/src/index.scss @@ -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; diff --git a/code/base-project/base-vue/packages/ao-design/src/index.ts b/code/base-project/base-vue/packages/ao-design/src/index.ts index 5c3baf20..4219e6b0 100644 --- a/code/base-project/base-vue/packages/ao-design/src/index.ts +++ b/code/base-project/base-vue/packages/ao-design/src/index.ts @@ -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 } diff --git a/code/base-project/base-vue/packages/ao-design/src/modal/demo.vue b/code/base-project/base-vue/packages/ao-design/src/modal/demo.vue new file mode 100644 index 00000000..0f52a307 --- /dev/null +++ b/code/base-project/base-vue/packages/ao-design/src/modal/demo.vue @@ -0,0 +1,109 @@ + + + diff --git a/code/base-project/base-vue/packages/ao-design/src/modal/index.scss b/code/base-project/base-vue/packages/ao-design/src/modal/index.scss index 2b1b762e..68ca569c 100644 --- a/code/base-project/base-vue/packages/ao-design/src/modal/index.scss +++ b/code/base-project/base-vue/packages/ao-design/src/modal/index.scss @@ -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; diff --git a/code/base-project/base-vue/packages/ao-design/src/modal/index.tsx b/code/base-project/base-vue/packages/ao-design/src/modal/index.tsx index 2b11f8ad..902ecaa0 100644 --- a/code/base-project/base-vue/packages/ao-design/src/modal/index.tsx +++ b/code/base-project/base-vue/packages/ao-design/src/modal/index.tsx @@ -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 VNode)>, + default: '', + }, + content: { + type: [String, Object, Function] as PropType 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, 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 + >, + }, + onBeforeCancel: { + type: Function as PropType< + (done: (ok: boolean) => void) => void | boolean | Promise + >, + }, 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((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((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 = { 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 = {} + 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 () => ( {isMounted.value && ( -
+
{props.mask && ( -
+
)} - +
e.stopPropagation()} >
-
- {props.type !== 'default' && } - {slots.title?.()} - {props.showClose && } -
- -
{slots.default?.()}
-
- {slots.footer?.() ?? ( - <> - - - - )} -
+ {slots.layout + ? ( + // 👉 如果有 layout 插槽,用户完全接管布局 + slots.layout() + ) + : ( + <> +
+ {slots.header + ? slots.header() + : ( + <> + {props.type !== 'default' && ( + + )} + {renderTitle.value} + {props.showFullscreen && ( + + )} + {props.showClose && ( + + )} + + )} +
+
{renderContent.value}
+
+ {slots.footer?.() ?? ( + <> + + + + )} +
+ + )}
+
)} diff --git a/code/base-project/base-vue/packages/ao-design/src/modal/modalManager.ts b/code/base-project/base-vue/packages/ao-design/src/modal/modalManager.ts index 4c9e0b7c..fd063f89 100644 --- a/code/base-project/base-vue/packages/ao-design/src/modal/modalManager.ts +++ b/code/base-project/base-vue/packages/ao-design/src/modal/modalManager.ts @@ -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 } diff --git a/code/base-project/base-vue/packages/ao-design/src/modal/old.tsx b/code/base-project/base-vue/packages/ao-design/src/modal/old.tsx deleted file mode 100644 index 6719e138..00000000 --- a/code/base-project/base-vue/packages/ao-design/src/modal/old.tsx +++ /dev/null @@ -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>, - }, - onBeforeCancel: { - type: Function as PropType<(done: (ok: boolean) => void) => void | boolean | Promise>, - }, - destroyOnClose: { - type: Boolean, - default: false, - }, - appendTo: { - type: [String, Object] as PropType, - 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(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((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((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 = { - 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 ( - - {shouldRenderPopup && ( -
- {props.mask && ( - -
- - )} - -
-
-
- {props.type !== 'default' && } - {slots.title?.()} - {props.showClose && } -
-
{slots.default?.()}
-
- {slots.footer?.() ?? ( - <> - - - - )} -
-
-
-
-
- )} - - ) - } - }, -}) diff --git a/code/base-project/base-vue/packages/ao-design/src/modal/useModal.ts b/code/base-project/base-vue/packages/ao-design/src/modal/useModal.ts new file mode 100644 index 00000000..daae9f01 --- /dev/null +++ b/code/base-project/base-vue/packages/ao-design/src/modal/useModal.ts @@ -0,0 +1,158 @@ +import type { AppContext, VNode } from 'vue' +import { createVNode, getCurrentInstance, nextTick, render } from 'vue' +import Modal from './index' + +function omit, K extends keyof T>( + object: T, + keys: K[], +): Omit { + 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 + cancelBtnProps?: Record + 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) => { + 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, + } +} diff --git a/code/base-project/base-vue/packages/ao-design/src/popconfirm/index.tsx b/code/base-project/base-vue/packages/ao-design/src/popconfirm/index.tsx index ed9c21df..efb99afb 100644 --- a/code/base-project/base-vue/packages/ao-design/src/popconfirm/index.tsx +++ b/code/base-project/base-vue/packages/ao-design/src/popconfirm/index.tsx @@ -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 >, @@ -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((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 = () => { diff --git a/code/base-project/base-vue/packages/ao-design/src/spin/index.scss b/code/base-project/base-vue/packages/ao-design/src/spin/index.scss index 745202fb..ffd39ed5 100644 --- a/code/base-project/base-vue/packages/ao-design/src/spin/index.scss +++ b/code/base-project/base-vue/packages/ao-design/src/spin/index.scss @@ -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%); + } + } } } diff --git a/code/base-project/base-vue/packages/ao-design/src/spin/index.tsx b/code/base-project/base-vue/packages/ao-design/src/spin/index.tsx index 00dc4281..58d4408b 100644 --- a/code/base-project/base-vue/packages/ao-design/src/spin/index.tsx +++ b/code/base-project/base-vue/packages/ao-design/src/spin/index.tsx @@ -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 ( +
+ + {tipContent &&
{tipContent}
} +
+ ) + } + + // loading 为 true 时的结构 return ( -
- {slots.default?.()} - {props.loading && ( -
-
- - {renderTip() && {renderTip()}} -
+
+ {slots.default()} + +
+
+ + {tipContent &&
{tipContent}
}
- )} +
+
) } diff --git a/code/base-project/base-vue/src/App.vue b/code/base-project/base-vue/src/App.vue index 4b2bec87..a2004fc1 100644 --- a/code/base-project/base-vue/src/App.vue +++ b/code/base-project/base-vue/src/App.vue @@ -1,31 +1,48 @@ - + + diff --git a/code/base-project/base-vue/src/components.d.ts b/code/base-project/base-vue/src/components.d.ts index a14b26fc..404b8a35 100644 --- a/code/base-project/base-vue/src/components.d.ts +++ b/code/base-project/base-vue/src/components.d.ts @@ -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']