diff --git a/code/base-project/base-vue/.vscode/vue3.code-snippets b/code/base-project/base-vue/.vscode/vue3.code-snippets index 2e706203..f9b1f88b 100644 --- a/code/base-project/base-vue/.vscode/vue3.code-snippets +++ b/code/base-project/base-vue/.vscode/vue3.code-snippets @@ -49,9 +49,9 @@ "@use '../_styles/_variables.scss' as *;", "@use 'sass:color';", "", - "$${1:component}-prefix: #{$prefix}-${1:component};", + "$${1:component}-prefix: '#{$$prefix}-${1:component}';", "", - ".#{${1:component}-prefix} {", + ".#{$${1:component}-prefix} {", "}" ] } diff --git a/code/base-project/base-vue/packages/ao-design/src/button/index.tsx b/code/base-project/base-vue/packages/ao-design/src/button/index.tsx index 6614d149..65217a80 100644 --- a/code/base-project/base-vue/packages/ao-design/src/button/index.tsx +++ b/code/base-project/base-vue/packages/ao-design/src/button/index.tsx @@ -1,3 +1,4 @@ +import type { PropType } from 'vue' import { computed, defineComponent } from 'vue' import { getPrefixCls } from '../_utils/getPrefixCls' import Icon from '../icon' @@ -19,6 +20,7 @@ export default defineComponent({ iconPosition: { type: String as () => 'left' | 'right', default: 'left' }, size: { type: String as () => 'small' | 'medium' | 'large', default: 'medium' }, block: { type: Boolean, default: false }, + onClick: { type: Function as PropType<(event: MouseEvent) => void> }, // 注意这里不要加 ? }, setup(props, { slots }) { const prefixCls = getPrefixCls('btn') @@ -39,7 +41,11 @@ export default defineComponent({ return () => { return ( - + + + )} + + + + + + ) + }, +}) 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 2e48b5dd..a9831b14 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 @@ -5,6 +5,8 @@ @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 *; diff --git a/code/base-project/base-vue/packages/ao-design/src/message/index.scss b/code/base-project/base-vue/packages/ao-design/src/message/index.scss index e0f12477..94fe26c6 100644 --- a/code/base-project/base-vue/packages/ao-design/src/message/index.scss +++ b/code/base-project/base-vue/packages/ao-design/src/message/index.scss @@ -3,29 +3,28 @@ $message-prefix: #{$prefix}-message; +.#{$message-prefix}-wrapper { + position: fixed; + z-index: 6000; + display: flex; + align-items: center; + gap: 8px; +} + .#{$message-prefix} { - &-wrapper { - position: fixed; - z-index: 6000; - display: flex; - align-items: center; - gap: 8px; - } - &-item { - display: inline-flex; - align-items: center; - outline: 0; - width: fit-content; - vertical-align: middle; - border-radius: $border-radius; - padding: 6px 12px; - background-color: $bg-color-container; - color: $text-color-base; - box-shadow: $shadow-overlay; - box-sizing: border-box; - font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; - } - &-content { + display: inline-flex; + align-items: center; + outline: 0; + width: fit-content; + vertical-align: middle; + border-radius: $border-radius; + padding: 6px 12px; + background-color: $bg-color-container; + color: $text-color-base; + box-shadow: $shadow-overlay; + box-sizing: border-box; + font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; + &-message { font-size: 14px; line-height: 1; } @@ -40,8 +39,8 @@ $message-prefix: #{$prefix}-message; color: $text-color-assist; cursor: pointer; border-radius: 50%; - width: 16px; - height: 16px; + width: 18px; + height: 18px; &:hover { background-color: $default-color-hover; } diff --git a/code/base-project/base-vue/packages/ao-design/src/message/index.ts b/code/base-project/base-vue/packages/ao-design/src/message/index.ts index d7a0c483..f13e60f5 100644 --- a/code/base-project/base-vue/packages/ao-design/src/message/index.ts +++ b/code/base-project/base-vue/packages/ao-design/src/message/index.ts @@ -1,3 +1,4 @@ +import type { VNode } from 'vue' import { createApp, h } from 'vue' import { getPrefixCls } from '../_utils/getPrefixCls' import MessageItem from './item' @@ -16,7 +17,7 @@ type Placement type MessageType = 'info' | 'success' | 'warning' | 'error' | 'loading' | 'question' interface MessageOptions { - message: string + message: string | VNode | (() => VNode) type?: MessageType placement?: Placement duration?: number diff --git a/code/base-project/base-vue/packages/ao-design/src/message/item.tsx b/code/base-project/base-vue/packages/ao-design/src/message/item.tsx index 89ce512d..497414fb 100644 --- a/code/base-project/base-vue/packages/ao-design/src/message/item.tsx +++ b/code/base-project/base-vue/packages/ao-design/src/message/item.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/index' @@ -9,7 +9,10 @@ export default defineComponent({ name: 'MessageItem', props: { type: { type: String, default: 'info' }, - message: { type: String, required: true }, + message: { + type: [String, Object, Function] as unknown as () => string | VNode | (() => VNode), + required: true, + }, placement: { type: String, required: true }, showClose: { type: Boolean, default: false }, onClose: { @@ -26,10 +29,14 @@ export default defineComponent({ question: 'ix:question-filled', loading: 'mingcute:loading-line', } - + const renderMessage = (message: string | VNode | (() => VNode)) => { + if (typeof message === 'function') + return message() + return message + } return () => (
- {props.message} + + {renderMessage(props.message)} + {props.showClose ? ( +import { useNotification } from '@ao/ao-design' +import { h } from 'vue' + +const notification = useNotification() + +function showCustomMessage() { + notification.success({ + title: '自定义消息', + message: () => + h('div', { style: { color: 'red', position: 'relative' } }, [ + h('p', {}, '这里是自定义内容,可以是任意 VNode'), + h('strong', {}, '加粗文本'), + // 右下角按钮容器 + h( + 'div', + { + style: { + position: 'absolute', + bottom: '4px', + right: '4px', + display: 'flex', + gap: '4px', + }, + }, + [ + h( + 'button', + { + style: { fontSize: '12px', padding: '2px 6px' }, + onClick: () => alert('Button A clicked'), + }, + 'A', + ), + h( + 'button', + { + style: { fontSize: '12px', padding: '2px 6px' }, + onClick: () => alert('Button B clicked'), + }, + 'B', + ), + ], + ), + ]), + type: 'success', + showClose: true, + }) +} + + + + + diff --git a/code/base-project/base-vue/packages/ao-design/src/notification/index.scss b/code/base-project/base-vue/packages/ao-design/src/notification/index.scss index d0c93b55..7ba52c38 100644 --- a/code/base-project/base-vue/packages/ao-design/src/notification/index.scss +++ b/code/base-project/base-vue/packages/ao-design/src/notification/index.scss @@ -8,7 +8,7 @@ $notification-prefix: #{$prefix}-notification; z-index: 6000; display: flex; align-items: center; - gap: 8px; + gap: 16px; } .#{$notification-prefix} { @@ -54,7 +54,7 @@ $notification-prefix: #{$prefix}-notification; margin-left: 6px; color: $text-color-assist; cursor: pointer; - border-radius: 50%; + border-radius: $border-radius; width: 20px; height: 20px; &:hover { @@ -62,7 +62,7 @@ $notification-prefix: #{$prefix}-notification; } } - &-content { + &-message { margin-top: 8px; font-size: 14px; color: $text-color-secondary; diff --git a/code/base-project/base-vue/packages/ao-design/src/notification/index.ts b/code/base-project/base-vue/packages/ao-design/src/notification/index.ts index 31731919..ca855229 100644 --- a/code/base-project/base-vue/packages/ao-design/src/notification/index.ts +++ b/code/base-project/base-vue/packages/ao-design/src/notification/index.ts @@ -12,7 +12,7 @@ type Placement type NotificationType = 'info' | 'success' | 'warning' | 'error' | 'loading' | 'question' interface NotificationOptions { - content: string | VNode | (() => VNode) + message: string | VNode | (() => VNode) title: string subTitle?: string @@ -63,7 +63,7 @@ export function useNotification(): MessageFn { opts: NotificationOptions, defaultType: NotificationType = 'info', ): NotificationHandle => { - const { content, title, subTitle, type = defaultType, placement = 'top-end', duration = 3000, showClose } = opts + const { message, title, subTitle, type = defaultType, placement = 'top-end', duration = 3000, showClose } = opts const container = getContainer(placement) let timer: number | undefined @@ -75,7 +75,7 @@ export function useNotification(): MessageFn { type, title, subTitle, - content, + message, placement, showClose, style: { diff --git a/code/base-project/base-vue/packages/ao-design/src/notification/item.tsx b/code/base-project/base-vue/packages/ao-design/src/notification/item.tsx index 9da54a96..a58247d8 100644 --- a/code/base-project/base-vue/packages/ao-design/src/notification/item.tsx +++ b/code/base-project/base-vue/packages/ao-design/src/notification/item.tsx @@ -1,5 +1,5 @@ import type { PropType, VNode } from 'vue' -import { defineComponent, h } from 'vue' +import { defineComponent } from 'vue' import { getPrefixCls } from '../_utils/getPrefixCls' import Icon from '../icon/index' @@ -10,7 +10,7 @@ export default defineComponent({ props: { type: { type: String, default: 'info' }, title: { type: String, required: true }, - content: { + message: { type: [String, Object, Function] as unknown as () => string | VNode | (() => VNode), required: true, }, @@ -31,10 +31,10 @@ export default defineComponent({ question: 'ix:question-filled', loading: 'mingcute:loading-line', } - const renderContent = (content: string | VNode | (() => VNode)) => { - if (typeof content === 'function') - return content() - return content + const renderMessage = (message: string | VNode | (() => VNode)) => { + if (typeof message === 'function') + return message() + return message } return () => (
-
- {renderContent(props.content)} +
+ {renderMessage(props.message)}
diff --git a/code/base-project/base-vue/packages/ao-design/src/popconfirm/index.scss b/code/base-project/base-vue/packages/ao-design/src/popconfirm/index.scss new file mode 100644 index 00000000..c9cbb37d --- /dev/null +++ b/code/base-project/base-vue/packages/ao-design/src/popconfirm/index.scss @@ -0,0 +1,64 @@ +@use '../_styles/_variables.scss' as *; +@use 'sass:color'; + +$popconfirm-prefix: '#{$prefix}-popconfirm'; + +.#{$popconfirm-prefix}-wrapper { + padding: 12px 12px; +} + +.#{$popconfirm-prefix} { + &-main { + display: flex; + align-items: flex-start; + text-align: left; + } + &-icon { + display: inline-block; + margin-right: 8px; + } + + &-title-wrapper { + display: inline-block; + vertical-align: top; + max-width: 320px; + color: $text-color-base; + font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; + word-break: break-all; + } + + &-title { + font-weight: 500; + font-size: 14px; + } + &-description { + margin-top: 8px; + font-size: 12px; + color: $text-color-secondary; + } + &-footer { + text-align: right; + margin-top: 16px; + > * + * { + margin-left: 8px; + } + } + &-info { + color: $primary-color; + } + &-success { + color: $success-color; + } + &-warning { + color: $warning-color; + } + &-error { + color: $danger-color; + } + &-question { + color: $primary-color; + } + &-loading { + color: $primary-color; + } +} 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 new file mode 100644 index 00000000..d2167942 --- /dev/null +++ b/code/base-project/base-vue/packages/ao-design/src/popconfirm/index.tsx @@ -0,0 +1,159 @@ +import type { PropType, VNode } from 'vue' +import { defineComponent, ref, watch } from 'vue' +import { getPrefixCls } from '../_utils/getPrefixCls' +import Button from '../button/index' +import Icon from '../icon/index' +import Popup from '../popup/index' + +export default defineComponent({ + name: 'Popconfirm', + props: { + type: { + type: String as PropType<'info' | 'success' | 'error' | 'warning' | 'question' | 'loading'>, + default: 'info', + }, + title: { + type: [String, Object, Function] as unknown as () => string | VNode | (() => VNode), + required: true, + }, + description: { + type: [String, Object, Function] as unknown as () => string | VNode | (() => VNode), + required: false, + }, + asyncConfirm: { + type: Boolean, + default: false, // 默认同步关闭,不显示 loading + }, + confirmBtn: { + type: null, + default: () => ({ label: '确认', size: 'small', type: 'solid', status: 'primary' }), + }, + cancelBtn: { + type: null, + default: () => ({ label: '取消', size: 'small', type: 'text', status: 'default' }), + }, + placement: { + type: String as PropType< + 'top' | 'bottom' | 'left' | 'right' + | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' + | 'left-start' | 'left-end' | 'right-start' | 'right-end' + >, + default: 'top', + }, + showArrow: { + type: Boolean, + default: true, + }, + }, + emits: ['confirm', 'cancel'], + setup(props, { slots, emit }) { + const prefixCls = getPrefixCls('popconfirm') + + const popVisible = ref(false) + + const confirmLoading = ref(false) + + const iconMap: Record = { + success: 'ix:success-filled', + error: 'ix:error-filled', + warning: 'ix:warning-filled', + info: 'ix:info-filled', + question: 'ix:question-filled', + loading: 'mingcute:loading-line', + } + + const renderNode = (val: any) => { + if (typeof val === 'function') + return val() + return val + } + + const handleConfirm = () => { + if (props.asyncConfirm) { + confirmLoading.value = true + + emit('confirm', () => { // resolve + popVisible.value = false + confirmLoading.value = false + }, () => { // reject + confirmLoading.value = false + }) + } + else { + popVisible.value = false + emit('confirm') + } + } + + const handleCancel = () => { + popVisible.value = false + emit('cancel') + } + + // 渲染内容块 + const renderContent = () => { + return ( +
+
+ {slots.icon + ? ( + {slots.icon()} + ) + : ( + + )} +
+ + {slots.title ? slots.title() : renderNode(props.title)} + + + {slots.description + ? ( +
{slots.description()}
+ ) + : ( + props.description &&
{renderNode(props.description)}
+ )} +
+
+ +
+ {slots.footer + ? ( + slots.footer() + ) + : ( +
+ + + +
+ )} +
+ ) + } + + return () => ( + + {{ + default: () => slots.default?.(), + }} + + ) + }, +}) diff --git a/code/base-project/base-vue/packages/ao-design/src/popup/index.scss b/code/base-project/base-vue/packages/ao-design/src/popup/index.scss index bd90277c..c52dc0f9 100644 --- a/code/base-project/base-vue/packages/ao-design/src/popup/index.scss +++ b/code/base-project/base-vue/packages/ao-design/src/popup/index.scss @@ -7,7 +7,6 @@ $popup-prefix: '#{$prefix}-popup'; position: absolute; background-color: $bg-color-container; color: $text-color-base; - padding: 4px 8px; font-size: 13px; border-radius: $border-radius; z-index: 1000; @@ -29,7 +28,9 @@ $popup-prefix: '#{$prefix}-popup'; transform-origin: center; } } - + &-content { + padding: 4px 8px; + } // 上方弹出,箭头在底部 &[data-popup-placement^='top'] > &-arrow { bottom: -4px; diff --git a/code/base-project/base-vue/packages/ao-design/src/popup/index.tsx b/code/base-project/base-vue/packages/ao-design/src/popup/index.tsx index d0e5ed09..24cea996 100644 --- a/code/base-project/base-vue/packages/ao-design/src/popup/index.tsx +++ b/code/base-project/base-vue/packages/ao-design/src/popup/index.tsx @@ -1,4 +1,4 @@ -import type { PropType } from 'vue' +import type { PropType, VNode } from 'vue' import { arrow, autoUpdate, flip, offset, useFloating } from '@floating-ui/vue' import { @@ -17,6 +17,8 @@ import { getPrefixCls } from '../_utils/getPrefixCls' export default defineComponent({ name: 'APopup', props: { + visible: { type: Boolean, default: false }, + trigger: { type: [String, Array] as PropType< 'hover' | 'click' | 'focus' | 'contextMenu' | Array<'hover' | 'click' | 'focus' | 'contextMenu'> @@ -33,7 +35,7 @@ export default defineComponent({ }, showArrow: { type: Boolean, - default: true, + default: false, }, destroyOnClose: { type: Boolean, @@ -60,18 +62,19 @@ export default defineComponent({ default: () => ({}), }, content: { - type: String, + type: [String, Object, Function] as unknown as () => string | VNode | (() => VNode), default: '', }, }, - setup(props, { slots }) { + emits: ['update:visible'], + setup(props, { slots, emit }) { const prefixCls = getPrefixCls('popup') const triggerRef = ref(null) // 触发 引用 const floatingRef = ref(null) // float 引用 const arrowRef = ref(null) // 箭头引用 - const visible = ref(false) - const hasRendered = ref(false) + const localVisible = ref(props.visible) + const hasRendered = ref(props.visible) let cleanup: (() => void) | null = null const { floatingStyles, middlewareData, placement, update } = useFloating( @@ -132,25 +135,43 @@ export default defineComponent({ floatEl.style.width = `${triggerEl.offsetWidth}px` } - // 准备浮动框 - const prepareFloating = async () => { + const showPopup = async () => { if (!hasRendered.value) hasRendered.value = true + await nextTick() + fitFloatingWidth() + localVisible.value = true + + setupAutoUpdate() + emit('update:visible', true) } - const showPopup = async () => { - await prepareFloating() - visible.value = true - } + const hidePopup = async () => { + localVisible.value = false - const hidePopup = () => { - visible.value = false + clearAutoUpdate() + emit('update:visible', false) } const togglePopup = async () => { - await prepareFloating() - visible.value = !visible.value + if (!hasRendered.value) + hasRendered.value = true + await nextTick() + + fitFloatingWidth() + + localVisible.value = !localVisible.value + await nextTick() + + if (localVisible.value) { + setupAutoUpdate() + } + else { + clearAutoUpdate() + } + + emit('update:visible', localVisible.value) } // 事件绑定 @@ -181,29 +202,57 @@ export default defineComponent({ const triggerEl = getDOM(triggerRef.value) const floatingEl = getDOM(floatingRef.value) if (!triggerEl?.contains(target) && !floatingEl?.contains(target)) { - visible.value = false + localVisible.value = false } } // 获取 Teleport 挂载目标 - const getTeleportTarget = () => { - if (props.appendTo instanceof HTMLElement) - return props.appendTo - if (typeof props.appendTo === 'string') { + const targetEl = ref(document.body) + + onMounted(() => { + if (props.appendTo instanceof HTMLElement) { + targetEl.value = props.appendTo + } + else if (typeof props.appendTo === 'string') { const el = document.querySelector(props.appendTo) if (el) - return el + targetEl.value = el as HTMLElement } - return 'body' - } + }) // 渲染 trigger slot const renderTriggerSlot = () => { const slotContent = slots.reference?.() || slots.default?.() || [] - if (slotContent.length === 1) - return cloneVNode(slotContent[0], { ref: triggerRef, ...eventProps }) + // 只有一个子节点时 + if (slotContent.length === 1) { + const vnode = slotContent[0] + return cloneVNode(vnode, { + ...eventProps, + ref: (el: any) => { + // 使用 getDOM 解析真实 DOM + triggerRef.value = getDOM(el) + + // 保留用户传入的 ref + const originalRef = (vnode as any).ref + if (typeof originalRef === 'function') { + originalRef(el) + } + else if (originalRef && typeof originalRef === 'object') { + ;(originalRef as any).value = el + } + }, + }) + } + + // 多个子节点时,包一层 span return ( - + { + triggerRef.value = getDOM(el) + }} + > {slotContent.map(v => cloneVNode(v))} ) @@ -218,24 +267,29 @@ export default defineComponent({ clearAutoUpdate() }) - watch(visible, async (val) => { - if (val) { - setupAutoUpdate() - } - else { - clearAutoUpdate() - } - }) + watch(() => props.visible, (val) => { + localVisible.value = val + }, { immediate: true }) + const renderContent = (content: string | VNode | (() => VNode)) => { + if (typeof content === 'function') + return content() + if (typeof content === 'string') { + return
{content}
+ } + return content + } return () => { // 懒加载 - const shouldRenderPopup = props.destroyOnClose ? visible.value : hasRendered.value + const shouldRenderPopup = props.destroyOnClose + ? localVisible.value // 需要销毁时,只在显示时渲染 + : localVisible.value || hasRendered.value // 不销毁时保持已渲染 return ( <> {renderTriggerSlot()} - + {shouldRenderPopup && (
- {slots.content ? slots.content() : props.content} + {slots.content ? slots.content() : renderContent(props.content)} {props.showArrow && ( + message: () => h('div', { style: { color: 'red', position: 'relative' } }, [ h('p', {}, '这里是自定义内容,可以是任意 VNode'), h('strong', {}, '加粗文本'), @@ -23,29 +23,11 @@ function showCustomMessage() { gap: '4px', }, }, - [ - h( - 'button', - { - style: { fontSize: '12px', padding: '2px 6px' }, - onClick: () => alert('Button A clicked'), - }, - 'A', - ), - h( - 'button', - { - style: { fontSize: '12px', padding: '2px 6px' }, - onClick: () => alert('Button B clicked'), - }, - 'B', - ), - ], ), ]), type: 'success', - duration: 0, showClose: true, + duration: 0, }) } diff --git a/code/base-project/base-vue/src/components.d.ts b/code/base-project/base-vue/src/components.d.ts index bb59f05f..7f5af0d3 100644 --- a/code/base-project/base-vue/src/components.d.ts +++ b/code/base-project/base-vue/src/components.d.ts @@ -10,11 +10,13 @@ declare module 'vue' { export interface GlobalComponents { AButton: typeof import('@ao/ao-design')['Button'] AConfigProvider: typeof import('@ao/ao-design')['ConfigProvider'] + ADrawer: typeof import('@ao/ao-design')['Drawer'] AGroup: typeof import('@ao/ao-design')['Group'] AIcon: typeof import('@ao/ao-design')['Icon'] AInput: typeof import('@ao/ao-design')['Input'] ALoading: typeof import('@ao/ao-design')['Loading'] AMessage: typeof import('@ao/ao-design')['Message'] + APopconfirm: typeof import('@ao/ao-design')['Popconfirm'] APopup: typeof import('@ao/ao-design')['Popup'] ASpin: typeof import('@ao/ao-design')['Spin'] ATextarea: typeof import('@ao/ao-design')['Textarea']