20260816220132
This commit is contained in:
1 parent
b6f9a4c912
commit
72993ecdcd
179 files changed
+20050
-633
No files matched your search
@@ -0,0 +1,87 @@
|
||||
.fms-notification {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
width: 360px;
|
||||
max-width: calc(100vw - var(--fms-feedback-offset) * 2);
|
||||
padding: 14px 16px;
|
||||
background: var(--fms-card);
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-popover-shadow);
|
||||
color: var(--fms-text);
|
||||
font-size: var(--fms-font-size);
|
||||
line-height: var(--fms-line-height);
|
||||
/* 子项恢复交互,覆盖容器的 pointer-events: none */
|
||||
pointer-events: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fms-notification-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
width: var(--fms-feedback-icon-size);
|
||||
height: var(--fms-feedback-icon-size);
|
||||
}
|
||||
|
||||
.fms-notification-success .fms-notification-icon {
|
||||
color: var(--fms-success);
|
||||
}
|
||||
.fms-notification-warning .fms-notification-icon {
|
||||
color: var(--fms-warning);
|
||||
}
|
||||
.fms-notification-error .fms-notification-icon {
|
||||
color: var(--fms-danger);
|
||||
}
|
||||
.fms-notification-info .fms-notification-icon {
|
||||
color: var(--fms-primary);
|
||||
}
|
||||
|
||||
.fms-notification-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fms-notification-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
.fms-notification-desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--fms-text-secondary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.fms-notification-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--fms-text-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
color 150ms ease;
|
||||
}
|
||||
|
||||
.fms-notification-close:hover {
|
||||
background: var(--fms-secondary-hover);
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
.fms-notification-close:focus-visible {
|
||||
outline: 2px solid var(--fms-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import NotificationItem from './notification.vue'
|
||||
import { notificationState } from './notification-store.js'
|
||||
|
||||
/* 9 个方位:分组容器始终存在,仅通知项进出,保证 enter/leave 动画稳定触发 */
|
||||
const PLACEMENTS = [
|
||||
'top-left', 'top-center', 'top-right',
|
||||
'center-left', 'center', 'center-right',
|
||||
'bottom-left', 'bottom-center', 'bottom-right',
|
||||
]
|
||||
|
||||
/* ---------- 关闭某条 ---------- */
|
||||
function close(id) {
|
||||
const idx = notificationState.items.findIndex((i) => i.id === id)
|
||||
if (idx !== -1) notificationState.items.splice(idx, 1)
|
||||
}
|
||||
|
||||
/* ---------- 方位 → flex 对齐类 ---------- */
|
||||
function placementClasses(p) {
|
||||
const v = p.split('-')[0]
|
||||
const h = p.split('-')[1] || 'center'
|
||||
return [
|
||||
`fms-placement-${v === 'center' ? 'center-v' : v}`,
|
||||
`fms-placement-${h === 'center' ? 'center-h' : h}`,
|
||||
]
|
||||
}
|
||||
|
||||
/* 某个方位下当前存活的通知 */
|
||||
function itemsOf(p) {
|
||||
return notificationState.items.filter(
|
||||
(i) => (i.placement || notificationState.placement || 'top-right') === p,
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fms-notification-container">
|
||||
<TransitionGroup
|
||||
v-for="p in PLACEMENTS"
|
||||
:key="p"
|
||||
name="fms-notification"
|
||||
tag="div"
|
||||
:class="['fms-notification-list', ...placementClasses(p)]"
|
||||
>
|
||||
<NotificationItem
|
||||
v-for="item in itemsOf(p)"
|
||||
:key="item.id"
|
||||
:id="item.id"
|
||||
:type="item.type"
|
||||
:title="item.title"
|
||||
:description="item.description"
|
||||
:duration="item.duration"
|
||||
:closable="item.closable"
|
||||
@close="close(item.id)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fms-notification-container {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--fms-z-notification);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fms-notification-list {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: var(--fms-feedback-offset);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 垂直方位(主轴):上 / 中 / 下 */
|
||||
.fms-placement-top {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.fms-placement-center-v {
|
||||
justify-content: center;
|
||||
}
|
||||
.fms-placement-bottom {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* 水平方位(交叉轴):左 / 中 / 右 */
|
||||
.fms-placement-left {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.fms-placement-center-h {
|
||||
align-items: center;
|
||||
}
|
||||
.fms-placement-right {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* 入场/退场:基础淡入淡出 + 位移,方向随方位 */
|
||||
.fms-notification-enter-active,
|
||||
.fms-notification-leave-active {
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.fms-notification-enter-from,
|
||||
.fms-notification-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 左侧锚定:从左侧滑入、向左侧滑出 */
|
||||
.fms-placement-left .fms-notification-enter-from,
|
||||
.fms-placement-left .fms-notification-leave-to {
|
||||
transform: translateX(-24px);
|
||||
}
|
||||
/* 右侧锚定:从右侧滑入、向右侧滑出 */
|
||||
.fms-placement-right .fms-notification-enter-from,
|
||||
.fms-placement-right .fms-notification-leave-to {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
/* top-center:从顶部滑入/滑出 */
|
||||
.fms-placement-top.fms-placement-center-h .fms-notification-enter-from,
|
||||
.fms-placement-top.fms-placement-center-h .fms-notification-leave-to {
|
||||
transform: translateY(-16px);
|
||||
}
|
||||
/* bottom-center:从底部滑入/滑出 */
|
||||
.fms-placement-bottom.fms-placement-center-h .fms-notification-enter-from,
|
||||
.fms-placement-bottom.fms-placement-center-h .fms-notification-leave-to {
|
||||
transform: translateY(16px);
|
||||
}
|
||||
/* center 正中央:仅淡入淡出 */
|
||||
|
||||
.fms-notification-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.fms-notification-move {
|
||||
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fms-notification-enter-active,
|
||||
.fms-notification-leave-active,
|
||||
.fms-notification-move {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createVNode, render } from 'vue'
|
||||
import NotificationContainer from './notification-container.vue'
|
||||
import { notificationState } from './notification-store.js'
|
||||
|
||||
/* ---------- 单例容器 ---------- */
|
||||
let containerEl = null
|
||||
let seed = 0
|
||||
|
||||
function ensureContainer() {
|
||||
if (containerEl) return
|
||||
containerEl = document.createElement('div')
|
||||
document.body.appendChild(containerEl)
|
||||
render(createVNode(NotificationContainer), containerEl)
|
||||
}
|
||||
|
||||
/* ---------- 增删 ---------- */
|
||||
function open(options) {
|
||||
ensureContainer()
|
||||
const id = `fms-notification-${++seed}`
|
||||
notificationState.items.push({
|
||||
id,
|
||||
type: 'info',
|
||||
title: '',
|
||||
description: '',
|
||||
duration: 4500,
|
||||
closable: true,
|
||||
...options,
|
||||
// 每条通知记住自己的方位:优先用本次调用传入的,否则用全局默认(由 config 设置)
|
||||
placement: (options && options.placement) || notificationState.placement || 'top-right',
|
||||
})
|
||||
return { close: () => remove(id) }
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
const idx = notificationState.items.findIndex((i) => i.id === id)
|
||||
if (idx !== -1) notificationState.items.splice(idx, 1)
|
||||
}
|
||||
|
||||
function closeAll() {
|
||||
notificationState.items.splice(0, notificationState.items.length)
|
||||
}
|
||||
|
||||
/* ---------- 命令式 API ---------- */
|
||||
export const Notification = {
|
||||
open: (options) => open({ ...options }),
|
||||
close: (id) => remove(id),
|
||||
closeAll: () => closeAll(),
|
||||
/** 全局配置:设置默认方位 */
|
||||
config: (opts) => {
|
||||
if (opts && opts.placement) notificationState.placement = opts.placement
|
||||
},
|
||||
}
|
||||
|
||||
export default Notification
|
||||
@@ -0,0 +1,8 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
/* 全局共享的 Notification 活跃列表:管理器写入、容器读取,保证响应式联动 */
|
||||
export const notificationState = reactive({
|
||||
items: [],
|
||||
/* 方位:top-left / top-center / top-right / center-left / center / center-right / bottom-left / bottom-center / bottom-right */
|
||||
placement: 'top-right',
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { CheckCircle2, XCircle, AlertTriangle, Info, X } from '@lucide/vue'
|
||||
|
||||
/* ---------- props / emits ---------- */
|
||||
const props = defineProps({
|
||||
/** 唯一标识,由管理器分配 */
|
||||
id: { type: String, default: '' },
|
||||
/** 类型:info / success / warning / error */
|
||||
type: {
|
||||
type: String,
|
||||
default: 'info',
|
||||
validator: (v) => ['info', 'success', 'warning', 'error'].includes(v),
|
||||
},
|
||||
/** 标题 */
|
||||
title: { type: String, default: '' },
|
||||
/** 描述内容 */
|
||||
description: { type: String, default: '' },
|
||||
/** 自动关闭时长(ms),<=0 时不自动关闭 */
|
||||
duration: { type: Number, default: 4500 },
|
||||
/** 是否显示关闭按钮 */
|
||||
closable: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
/* ---------- 图标映射 ---------- */
|
||||
const iconMap = {
|
||||
info: Info,
|
||||
success: CheckCircle2,
|
||||
warning: AlertTriangle,
|
||||
error: XCircle,
|
||||
}
|
||||
const iconComp = computed(() => iconMap[props.type] || Info)
|
||||
|
||||
/* ---------- 自动关闭计时 ---------- */
|
||||
let timer = null
|
||||
|
||||
function clearTimer() {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
if (props.duration > 0) {
|
||||
timer = setTimeout(() => emit('close'), props.duration)
|
||||
}
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onMounted(startTimer)
|
||||
onBeforeUnmount(clearTimer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fms-notification" :class="`fms-notification-${type}`" role="alert">
|
||||
<component :is="iconComp" class="fms-notification-icon" />
|
||||
<div class="fms-notification-body">
|
||||
<p v-if="title" class="fms-notification-title">{{ title }}</p>
|
||||
<p v-if="description" class="fms-notification-desc">{{ description }}</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="closable"
|
||||
type="button"
|
||||
class="fms-notification-close"
|
||||
aria-label="关闭"
|
||||
@click="onClose"
|
||||
>
|
||||
<X :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './index.scss';
|
||||
</style>
|
||||
Reference in new issue
Block a user