diff --git a/code/base-project/base-vue/packages/ao-design/src/icon/index.tsx b/code/base-project/base-vue/packages/ao-design/src/icon/index.tsx index dca629d8..adb3445f 100644 --- a/code/base-project/base-vue/packages/ao-design/src/icon/index.tsx +++ b/code/base-project/base-vue/packages/ao-design/src/icon/index.tsx @@ -11,7 +11,8 @@ export default defineComponent({ color: { type: String, default: 'currentColor' }, spin: { type: Boolean, default: false }, }, - setup(props, { slots }) { + emits: ['click'], + setup(props, { slots, emit }) { const prefixCls = getPrefixCls('icon') const fontStyle = { ...(props.size ? { fontSize: `${props.size}px` } : {}), // size 存在才设置 @@ -21,6 +22,7 @@ export default defineComponent({ emit('click', e)} > {slots.default ? ( 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 5c9248fc..2e48b5dd 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 @@ -7,3 +7,4 @@ @use './popup/index.scss' as *; @use './icon/index.scss' as *; @use './message/index.scss' as *; +@use './notification/index.scss' as *; 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 2c20afb3..5c3baf20 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,7 +4,7 @@ import type { App, Component } from 'vue' import * as comps from './components' import { useMessage } from './message' - +import { useNotification } from './notification' // 按需导出组件(可单独 import) export * from './components' @@ -40,4 +40,4 @@ const AoDesign = { }, } -export { AoDesign, useMessage } +export { AoDesign, useMessage, useNotification } diff --git a/code/base-project/base-vue/packages/ao-design/src/message/demo.vue b/code/base-project/base-vue/packages/ao-design/src/message/demo.vue new file mode 100644 index 00000000..a5d1712d --- /dev/null +++ b/code/base-project/base-vue/packages/ao-design/src/message/demo.vue @@ -0,0 +1,114 @@ + + + + + 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 6fea7059..e0f12477 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 @@ -59,6 +59,9 @@ $message-prefix: #{$prefix}-message; &-error { color: $danger-color; } + &-question { + color: $primary-color; + } &-loading { color: $primary-color; } 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 64b3c4bf..d7a0c483 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,6 +1,6 @@ import { createApp, h } from 'vue' import { getPrefixCls } from '../_utils/getPrefixCls' -import Icon from '../icon/index' +import MessageItem from './item' type Placement = | 'top' @@ -13,9 +13,11 @@ type Placement | 'bottom-end' | 'center' +type MessageType = 'info' | 'success' | 'warning' | 'error' | 'loading' | 'question' + interface MessageOptions { message: string - type?: 'info' | 'success' | 'warning' | 'error' | 'loading' + type?: MessageType placement?: Placement duration?: number showClose?: boolean @@ -65,57 +67,31 @@ function getTransform(placement: Placement, hidden: boolean) { export function useMessage(): MessageFn { const createMessage = ( msg: string | MessageOptions, - defaultType: 'info' | 'success' | 'warning' | 'error' | 'loading' = 'info', + defaultType: MessageType = 'info', ): MessageHandle => { const opts: MessageOptions = typeof msg === 'string' ? { message: msg, type: defaultType } : { ...msg, type: msg.type || defaultType } - const { message, type, placement = 'top', duration = 3000 } = opts + const { message, type, placement = 'top', duration = 3000, showClose } = opts const container = getContainer(placement) - let timer: number | undefined - - const iconMap: Record = { - success: 'ix:success-filled', - error: 'ix:error-filled', - warning: 'ix:warning-filled', - info: 'ix:info-filled', - loading: 'mingcute:loading-line', - } - let handle: MessageHandle const app = createApp({ render() { - return h( - 'div', - { - class: [`${prefixCls}-item`], - style: { - opacity: 0, - transform: getTransform(placement, true), - transition: 'all 0.3s ease-in-out', - }, + return h(MessageItem, { + type, + message, + placement, + showClose, + style: { + opacity: 0, + transform: getTransform(placement, true), + transition: 'all 0.3s ease-in-out', }, - [ - h(Icon, { - icon: iconMap[type ?? 'info'], - size: 20, - class: `${prefixCls}-icon ${prefixCls}-${type ?? 'info'}`, - spin: type === 'loading', - }), - h('span', { class: `${prefixCls}-content` }, message), - opts.showClose - ? h(Icon, { - icon: 'ix:close', - size: 14, - class: `${prefixCls}-close`, - onClick: () => handle?.close?.(), - }) - : null, - ], - ) + onClose: () => handle?.close?.(), + }) }, }) @@ -153,7 +129,6 @@ export function useMessage(): MessageFn { instances.push(handle) - // 自动关闭 if (duration > 0) { timer = window.setTimeout(() => { handle.close() 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 new file mode 100644 index 00000000..89ce512d --- /dev/null +++ b/code/base-project/base-vue/packages/ao-design/src/message/item.tsx @@ -0,0 +1,54 @@ +import type { PropType } from 'vue' +import { defineComponent } from 'vue' +import { getPrefixCls } from '../_utils/getPrefixCls' +import Icon from '../icon/index' + +const prefixCls = getPrefixCls('message') + +export default defineComponent({ + name: 'MessageItem', + props: { + type: { type: String, default: 'info' }, + message: { type: String, required: true }, + placement: { type: String, required: true }, + showClose: { type: Boolean, default: false }, + onClose: { + type: Function as PropType<() => void>, + required: false, + }, + }, + setup(props) { + 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', + } + + return () => ( +
+ + {props.message} + {props.showClose + ? ( + props.onClose?.()} + /> + ) + : null} +
+ ) + }, +}) diff --git a/code/base-project/base-vue/packages/ao-design/src/message/old.ts b/code/base-project/base-vue/packages/ao-design/src/message/old.ts new file mode 100644 index 00000000..70130ca1 --- /dev/null +++ b/code/base-project/base-vue/packages/ao-design/src/message/old.ts @@ -0,0 +1,177 @@ +import { createApp, h } from 'vue' +import { getPrefixCls } from '../_utils/getPrefixCls' +import Icon from '../icon/index' + +type Placement + = | 'top' + | 'bottom' + | 'left' + | 'right' + | 'top-start' + | 'top-end' + | 'bottom-start' + | 'bottom-end' + | 'center' + +interface MessageOptions { + message: string + type?: 'info' | 'success' | 'warning' | 'error' | 'loading' | 'question' + placement?: Placement + duration?: number + showClose?: boolean +} + +interface MessageHandle { + close: () => void +} + +interface MessageFn { + (msg: string | MessageOptions): MessageHandle + info: (msg: string | MessageOptions) => MessageHandle + success: (msg: string | MessageOptions) => MessageHandle + warning: (msg: string | MessageOptions) => MessageHandle + error: (msg: string | MessageOptions) => MessageHandle + closeAll: () => void +} + +const prefixCls = getPrefixCls('message') +const containers: Partial> = {} +const instances: MessageHandle[] = [] + +function getContainer(placement: Placement = 'top') { + if (!containers[placement]) { + const wrapper = document.createElement('div') + wrapper.className = `${prefixCls}-wrapper ${prefixCls}-placement-${placement}` + document.body.appendChild(wrapper) + containers[placement] = wrapper + } + return containers[placement]! +} + +function getTransform(placement: Placement, hidden: boolean) { + if (placement.startsWith('top')) + return hidden ? 'translateY(-10px)' : 'translateY(0)' + if (placement.startsWith('bottom')) + return hidden ? 'translateY(10px)' : 'translateY(0)' + if (placement.startsWith('left')) + return hidden ? 'translateX(-10px)' : 'translateX(0)' + if (placement.startsWith('right')) + return hidden ? 'translateX(10px)' : 'translateX(0)' + if (placement === 'center') + return hidden ? 'scale(0.8)' : 'scale(1)' + return hidden ? 'translateY(-10px)' : 'translateY(0)' +} + +export function useMessage(): MessageFn { + const createMessage = ( + msg: string | MessageOptions, + defaultType: 'info' | 'success' | 'warning' | 'error' | 'question' | 'loading' = 'info', + ): MessageHandle => { + const opts: MessageOptions = typeof msg === 'string' + ? { message: msg, type: defaultType } + : { ...msg, type: msg.type || defaultType } + + const { message, type, placement = 'top', duration = 3000 } = opts + const container = getContainer(placement) + + let timer: number | undefined + + 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', + } + + let handle: MessageHandle + + const app = createApp({ + render() { + return h( + 'div', + { + class: [`${prefixCls}-item`], + style: { + opacity: 0, + transform: getTransform(placement, true), + transition: 'all 0.3s ease-in-out', + }, + }, + [ + h(Icon, { + icon: iconMap[type ?? 'info'], + size: 20, + class: `${prefixCls}-icon ${prefixCls}-${type ?? 'info'}`, + spin: type === 'loading', + }), + h('span', { class: `${prefixCls}-content` }, message), + opts.showClose + ? h(Icon, { + icon: 'ix:close', + size: 14, + class: `${prefixCls}-close`, + onClick: () => handle?.close?.(), + }) + : null, + ], + ) + }, + }) + + const vm = app.mount(document.createElement('div')) + container.appendChild(vm.$el) + + // 出现动画 + requestAnimationFrame(() => { + Object.assign(vm.$el.style, { + opacity: '1', + transform: getTransform(placement, false), + }) + }) + + handle = { + close: () => { + if (timer) + clearTimeout(timer) + + Object.assign(vm.$el.style, { + opacity: '0', + transform: getTransform(placement, true), + }) + + setTimeout(() => { + if (container.contains(vm.$el)) + container.removeChild(vm.$el) + app.unmount() + const index = instances.indexOf(handle) + if (index !== -1) + instances.splice(index, 1) + }, 300) + }, + } + + instances.push(handle) + + // 自动关闭 + if (duration > 0) { + timer = window.setTimeout(() => { + handle.close() + }, duration) + } + + return handle + } + + const api = ((msg: string | MessageOptions) => createMessage(msg)) as MessageFn + api.info = msg => createMessage(msg, 'info') + api.success = msg => createMessage(msg, 'success') + api.warning = msg => createMessage(msg, 'warning') + api.error = msg => createMessage(msg, 'error') + api.closeAll = () => { + instances.slice().forEach(instance => instance.close()) + } + + return api +} 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 new file mode 100644 index 00000000..d0c93b55 --- /dev/null +++ b/code/base-project/base-vue/packages/ao-design/src/notification/index.scss @@ -0,0 +1,121 @@ +@use '../_styles/_variables.scss' as *; +@use 'sass:color'; + +$notification-prefix: #{$prefix}-notification; + +.#{$notification-prefix}-wrapper { + position: fixed; + z-index: 6000; + display: flex; + align-items: center; + gap: 8px; +} + +.#{$notification-prefix} { + width: 360px; + padding: 12px 16px; + border-radius: $border-radius; + 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; + outline: 0; + border: 0; + margin: 0; + display: flex; + + &-icon { + text-align: center; + flex-shrink: 0; + margin-right: 8px; + } + + &-main { + flex: 1; + } + + &-title-wrapper { + display: flex; + justify-content: space-between; + align-items: center; + } + + &-title { + font-size: 16px; + font-weight: bold; + small { + margin-left: 4px; + } + } + + &-close { + flex-shrink: 0; + margin-left: 6px; + color: $text-color-assist; + cursor: pointer; + border-radius: 50%; + width: 20px; + height: 20px; + &:hover { + background-color: $default-color-hover; + } + } + + &-content { + margin-top: 8px; + font-size: 14px; + color: $text-color-secondary; + text-overflow: ellipsis; + word-break: break-all; + } + + &-footer { + } + + &-info { + color: $primary-color; + } + &-success { + color: $success-color; + } + &-warning { + color: $warning-color; + } + &-error { + color: $danger-color; + } + &-question { + color: $primary-color; + } + &-loading { + color: $primary-color; + } + + &-placement { + &-top-start { + top: 20px; + left: 20px; + flex-direction: column; + align-items: flex-start; + } + &-top-end { + top: 20px; + right: 20px; + flex-direction: column; + align-items: flex-end; + } + &-bottom-start { + bottom: 20px; + left: 20px; + flex-direction: column-reverse; + align-items: flex-start; + } + &-bottom-end { + bottom: 20px; + right: 20px; + flex-direction: column-reverse; + align-items: flex-end; + } + } +} 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 new file mode 100644 index 00000000..31731919 --- /dev/null +++ b/code/base-project/base-vue/packages/ao-design/src/notification/index.ts @@ -0,0 +1,144 @@ +import type { VNode } from 'vue' +import { createApp, h } from 'vue' +import { getPrefixCls } from '../_utils/getPrefixCls' +import MessageItem from './item' + +type Placement + = | 'top-start' + | 'top-end' + | 'bottom-start' + | 'bottom-end' + +type NotificationType = 'info' | 'success' | 'warning' | 'error' | 'loading' | 'question' + +interface NotificationOptions { + content: string | VNode | (() => VNode) + + title: string + subTitle?: string + type?: NotificationType + placement?: Placement + duration?: number + showClose?: boolean +} + +interface NotificationHandle { + close: () => void +} + +interface MessageFn { + (msg: NotificationOptions): NotificationHandle + info: (msg: NotificationOptions) => NotificationHandle + success: (msg: NotificationOptions) => NotificationHandle + warning: (msg: NotificationOptions) => NotificationHandle + error: (msg: NotificationOptions) => NotificationHandle + closeAll: () => void +} + +const prefixCls = getPrefixCls('notification') +const containers: Partial> = {} +const instances: NotificationHandle[] = [] + +function getContainer(placement: Placement = 'top-end') { + if (!containers[placement]) { + const wrapper = document.createElement('div') + wrapper.className = `${prefixCls}-wrapper ${prefixCls}-placement-${placement}` + document.body.appendChild(wrapper) + containers[placement] = wrapper + } + return containers[placement]! +} + +function getTransform(placement: Placement, hidden: boolean) { + if (placement.startsWith('top')) + return hidden ? 'translateY(-10px)' : 'translateY(0)' + if (placement.startsWith('bottom')) + return hidden ? 'translateY(10px)' : 'translateY(0)' + + return hidden ? 'translateY(-10px)' : 'translateY(0)' +} + +export function useNotification(): MessageFn { + const createNotification = ( + opts: NotificationOptions, + defaultType: NotificationType = 'info', + ): NotificationHandle => { + const { content, title, subTitle, type = defaultType, placement = 'top-end', duration = 3000, showClose } = opts + + const container = getContainer(placement) + let timer: number | undefined + let handle: NotificationHandle + + const app = createApp({ + render() { + return h(MessageItem, { + type, + title, + subTitle, + content, + placement, + showClose, + style: { + opacity: 0, + transform: getTransform(placement, true), + transition: 'all 0.3s ease-in-out', + }, + onClose: () => handle?.close?.(), + }) + }, + }) + + const vm = app.mount(document.createElement('div')) + container.appendChild(vm.$el) + + // 出现动画 + requestAnimationFrame(() => { + Object.assign(vm.$el.style, { + opacity: '1', + transform: getTransform(placement, false), + }) + }) + + handle = { + close: () => { + if (timer) + clearTimeout(timer) + + Object.assign(vm.$el.style, { + opacity: '0', + transform: getTransform(placement, true), + }) + + setTimeout(() => { + if (container.contains(vm.$el)) + container.removeChild(vm.$el) + app.unmount() + const index = instances.indexOf(handle) + if (index !== -1) + instances.splice(index, 1) + }, 300) + }, + } + + instances.push(handle) + + if (duration > 0) { + timer = window.setTimeout(() => { + handle.close() + }, duration) + } + + return handle + } + + const api = ((msg: NotificationOptions) => createNotification(msg)) as MessageFn + api.info = msg => createNotification(msg, 'info') + api.success = msg => createNotification(msg, 'success') + api.warning = msg => createNotification(msg, 'warning') + api.error = msg => createNotification(msg, 'error') + api.closeAll = () => { + instances.slice().forEach(instance => instance.close()) + } + + return api +} 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 new file mode 100644 index 00000000..9da54a96 --- /dev/null +++ b/code/base-project/base-vue/packages/ao-design/src/notification/item.tsx @@ -0,0 +1,78 @@ +import type { PropType, VNode } from 'vue' +import { defineComponent, h } from 'vue' +import { getPrefixCls } from '../_utils/getPrefixCls' +import Icon from '../icon/index' + +const prefixCls = getPrefixCls('notification') + +export default defineComponent({ + name: 'NotificationItem', + props: { + type: { type: String, default: 'info' }, + title: { type: String, required: true }, + content: { + type: [String, Object, Function] as unknown as () => string | VNode | (() => VNode), + required: true, + }, + subTitle: { type: String, required: false }, + placement: { type: String, required: true }, + showClose: { type: Boolean, default: false }, + onClose: { + type: Function as PropType<() => void>, + required: false, + }, + }, + setup(props) { + 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 renderContent = (content: string | VNode | (() => VNode)) => { + if (typeof content === 'function') + return content() + return content + } + return () => ( +
+ + +
+
+ +
+ {props.title} + {props.subTitle} +
+
+ {props.showClose + ? ( + props.onClose?.()} + /> + ) + : null} +
+ +
+ {renderContent(props.content)} +
+
+ +
+ ) + }, +}) diff --git a/code/base-project/base-vue/src/App.vue b/code/base-project/base-vue/src/App.vue index 79b14f18..a7b4b51b 100644 --- a/code/base-project/base-vue/src/App.vue +++ b/code/base-project/base-vue/src/App.vue @@ -1,97 +1,60 @@ @@ -103,9 +66,4 @@ function showMessageType(type: any) { gap: 16px; margin-top: 40px; } - -.row { - display: flex; - gap: 12px; -}