20260816220132

This commit is contained in:
oneao committed 2026-08-16 22:01:32 +08:00
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>