feat: 添加全局alert,并替换原先是toast

This commit is contained in:
SikongJueluo 2025-07-09 19:06:41 +08:00
parent 53027470fe
commit cbb3543c4a
No known key found for this signature in database
5 changed files with 363 additions and 128 deletions

1
components.d.ts vendored
View File

@ -9,6 +9,7 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
Alert: typeof import('./src/components/Alert/Alert.vue')['default']
AlertDemo: typeof import('./src/components/AlertDemo.vue')['default']
BaseBoard: typeof import('./src/components/equipments/BaseBoard.vue')['default']
BaseInputField: typeof import('./src/components/InputField/BaseInputField.vue')['default']
Canvas: typeof import('./src/components/Canvas.vue')['default']

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import Navbar from "./components/Navbar.vue";
import Dialog from "./components/Dialog.vue";
import { Alert, useAlertProvider } from "./components/Alert";
import { ref, provide, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
@ -49,19 +50,26 @@ provide("theme", {
const currentRoutePath = computed(() => {
return router.currentRoute.value.path;
});
useAlertProvider();
</script>
<template>
<div>
<header class="relative">
<Navbar></Navbar>
<Dialog></Dialog>
<Navbar />
<Dialog />
<Alert />
</header>
<main>
<RouterView />
</main>
<footer v-if="currentRoutePath != '/project'" class="footer footer-center p-4 bg-base-300 text-base-content">
<footer
v-if="currentRoutePath != '/project'"
class="footer footer-center p-4 bg-base-300 text-base-content"
>
<div>
<p>Copyright © 2023 - All right reserved by OurEDA</p>
</div>

View File

@ -0,0 +1,102 @@
<template>
<div class="fixed left-1/2 top-30 z-50 -translate-x-1/2">
<transition
name="alert"
enter-active-class="alert-enter-active"
leave-active-class="alert-leave-active"
enter-from-class="alert-enter-from"
enter-to-class="alert-enter-to"
leave-from-class="alert-leave-from"
leave-to-class="alert-leave-to"
>
<div
v-if="alertStore?.alertState.value.visible"
:class="alertClasses"
class="alert"
>
<div class="flex items-center gap-2">
<!-- Icons for different alert types -->
<CheckCircle
v-if="alertStore?.alertState.value.type === 'success'"
class="h-6 w-6 shrink-0 stroke-current"
/>
<XCircle
v-else-if="alertStore?.alertState.value.type === 'error'"
class="h-6 w-6 shrink-0 stroke-current"
/>
<AlertTriangle
v-else-if="alertStore?.alertState.value.type === 'warning'"
class="h-6 w-6 shrink-0 stroke-current"
/>
<Info v-else class="h-6 w-6 shrink-0 stroke-current" />
<span>{{ alertStore?.alertState.value.message }}</span>
</div>
<div class="flex-none">
<button
class="btn btn-sm btn-circle btn-ghost"
@click="alertStore?.hide"
>
<X class="h-4 w-4" />
</button>
</div>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { CheckCircle, XCircle, AlertTriangle, Info, X } from "lucide-vue-next";
import { useAlertStore } from ".";
const alertStore = useAlertStore();
// Computed classes for different alert types
const alertClasses = computed(() => {
const baseClasses = "shadow-lg max-w-sm";
switch (alertStore?.alertState.value.type) {
case "success":
return `${baseClasses} alert-success`;
case "error":
return `${baseClasses} alert-error`;
case "warning":
return `${baseClasses} alert-warning`;
case "info":
default:
return `${baseClasses} alert-info`;
}
});
</script>
<style scoped>
/* 进入和离开的过渡动画持续时间 */
.alert-enter-active,
.alert-leave-active {
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
/* 进入的起始状态 */
.alert-enter-from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
/* 进入的结束状态 */
.alert-enter-to {
opacity: 1;
transform: translateY(0) scale(1);
}
/* 离开的起始状态 */
.alert-leave-from {
opacity: 1;
transform: translateY(0) scale(1);
}
/* 离开的结束状态 */
.alert-leave-to {
opacity: 0;
transform: translateY(-10px) scale(0.98);
}
</style>

View File

@ -0,0 +1,61 @@
import { ref, computed } from "vue";
import Alert from "./Alert.vue";
import { createInjectionState } from "@vueuse/core";
export interface AlertState {
visible: boolean;
message: string;
type: "success" | "error" | "warning" | "info";
}
// create injectivon state using vueuse
const [useAlertProvider, useAlertStore] = createInjectionState(() => {
const alertState = ref<AlertState>({
visible: false,
message: "",
type: "info",
});
let timeoutId: number | null = null;
function show(
message: string,
type: AlertState["type"] = "info",
duration = 2000,
) {
// Clear existing timeout
if (timeoutId) {
window.clearTimeout(timeoutId);
}
alertState.value = {
visible: true,
message,
type,
};
// Auto hide after duration
if (duration > 0) {
timeoutId = window.setTimeout(() => {
hide();
}, duration);
}
}
function hide() {
alertState.value.visible = false;
if (timeoutId) {
window.clearTimeout(timeoutId);
timeoutId = null;
}
}
return {
alertState,
show,
hide,
};
});
export { Alert, useAlertProvider, useAlertStore };

View File

@ -1,70 +1,148 @@
<template>
<div class="flex-1 h-full w-full bg-base-200 relative overflow-hidden diagram-container" ref="canvasContainer"
@mousedown="handleCanvasMouseDown" @mousedown.middle.prevent="startMiddleDrag" @wheel.prevent="onZoom"
@contextmenu.prevent="handleContextMenu">
<div
class="flex-1 h-full w-full bg-base-200 relative overflow-hidden diagram-container"
ref="canvasContainer"
@mousedown="handleCanvasMouseDown"
@mousedown.middle.prevent="startMiddleDrag"
@wheel.prevent="onZoom"
@contextmenu.prevent="handleContextMenu"
>
<!-- 工具栏 -->
<div class="absolute top-2 right-2 flex gap-2 z-30">
<button class="btn btn-sm btn-primary" @click="openDiagramFileSelector">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z"
/>
</svg>
导入
</button>
<button class="btn btn-sm btn-primary" @click="exportDiagram">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
导出
</button>
<button class="btn btn-sm btn-primary" @click="emit('open-components')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
添加组件
</button>
<button class="btn btn-sm btn-primary" @click="emit('toggle-doc-panel')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
{{ props.showDocPanel ? "属性面板" : "文档" }}
</button>
</div>
<!-- 隐藏的文件输入 -->
<input type="file" ref="fileInput" class="hidden" accept=".json" @change="handleFileSelected" />
<input
type="file"
ref="fileInput"
class="hidden"
accept=".json"
@change="handleFileSelected"
/>
<div ref="canvas" class="diagram-canvas" :style="{
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
}">
<div
ref="canvas"
class="diagram-canvas"
:style="{
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
}"
>
<!-- 渲染连线 -->
<svg class="wires-layer" width="10000" height="10000">
<!-- 已完成的连线 -->
<WireComponent v-for="(wire, index) in wireItems" :key="wire.id" :id="wire.id" :start-x="wire.startX"
:start-y="wire.startY" :end-x="wire.endX" :end-y="wire.endY" :stroke-color="wire.color || '#4a5568'"
:stroke-width="wire.strokeWidth" :is-active="false" :start-component-id="wire.startComponentId"
:start-pin-id="wire.startPinId" :end-component-id="wire.endComponentId" :end-pin-id="wire.endPinId"
:routing-mode="wire.routingMode" :path-commands="wire.pathCommands" />
<WireComponent
v-for="(wire, index) in wireItems"
:key="wire.id"
:id="wire.id"
:start-x="wire.startX"
:start-y="wire.startY"
:end-x="wire.endX"
:end-y="wire.endY"
:stroke-color="wire.color || '#4a5568'"
:stroke-width="wire.strokeWidth"
:is-active="false"
:start-component-id="wire.startComponentId"
:start-pin-id="wire.startPinId"
:end-component-id="wire.endComponentId"
:end-pin-id="wire.endPinId"
:routing-mode="wire.routingMode"
:path-commands="wire.pathCommands"
/>
<!-- 正在创建的连线 -->
<WireComponent v-if="isCreatingWire" id="temp-wire" :start-x="creatingWireStart.x"
:start-y="creatingWireStart.y" :end-x="mousePosition.x" :end-y="mousePosition.y" stroke-color="#3182ce"
:stroke-width="2" :is-active="true" />
<WireComponent
v-if="isCreatingWire"
id="temp-wire"
:start-x="creatingWireStart.x"
:start-y="creatingWireStart.y"
:end-x="mousePosition.x"
:end-y="mousePosition.y"
stroke-color="#3182ce"
:stroke-width="2"
:is-active="true"
/>
</svg>
<!-- 渲染画布上的组件 -->
<div v-for="component in diagramParts" :key="component.id" class="component-wrapper" :class="{
'component-hover': hoveredComponent === component.id,
'component-selected': selectedComponentId === component.id,
'component-disabled': !component.isOn,
'component-hidepins': component.hidepins,
}" :style="{
<div
v-for="component in diagramParts"
:key="component.id"
class="component-wrapper"
:class="{
'component-hover': hoveredComponent === component.id,
'component-selected': selectedComponentId === component.id,
'component-disabled': !component.isOn,
'component-hidepins': component.hidepins,
}"
:style="{
top: component.y + 'px',
left: component.x + 'px',
zIndex: component.index ?? 0,
@ -73,56 +151,74 @@
: 'none',
opacity: component.isOn ? 1 : 0.6,
display: 'block',
}" @mousedown.left.stop="startComponentDrag($event, component)" @mouseover="
}"
@mousedown.left.stop="startComponentDrag($event, component)"
@mouseover="
(event) => {
hoveredComponent = component.id;
}
" @mouseleave="
"
@mouseleave="
(event) => {
hoveredComponent = null;
}
">
"
>
<!-- 动态渲染组件 -->
<component :is="componentManager.getComponentDefinition(component.type)"
v-if="componentManager.componentModules.value[component.type] && componentManager.getComponentDefinition(component.type)"
v-bind="componentManager.prepareComponentProps(component.attrs || {}, component.id)" @update:bindKey="
<component
:is="componentManager.getComponentDefinition(component.type)"
v-if="
componentManager.componentModules.value[component.type] &&
componentManager.getComponentDefinition(component.type)
"
v-bind="
componentManager.prepareComponentProps(
component.attrs || {},
component.id,
)
"
@update:bindKey="
(value: string) =>
updateComponentProp(component.id, 'bindKey', value)
" @pin-click="
"
@pin-click="
(pinInfo: any) =>
handlePinClick(component.id, pinInfo, pinInfo.originalEvent)
" :ref="(el: any) => componentManager.setComponentRef(component.id, el)" />
"
:ref="(el: any) => componentManager.setComponentRef(component.id, el)"
/>
<!-- Fallback if component module not loaded yet -->
<div v-else
class="p-2 text-xs text-gray-400 border border-dashed border-gray-400 flex items-center justify-center">
<div
v-else
class="p-2 text-xs text-gray-400 border border-dashed border-gray-400 flex items-center justify-center"
>
<div class="flex flex-col items-center">
<div class="loading loading-spinner loading-xs mb-1"></div>
<span>Loading {{ component.type }}...</span>
<small class="mt-1 text-xs">{{ componentManager.componentModules.value[component.type] ? 'Module loaded but invalid' : 'Module not found' }}</small>
<small class="mt-1 text-xs">{{
componentManager.componentModules.value[component.type]
? "Module loaded but invalid"
: "Module not found"
}}</small>
</div>
</div>
</div>
</div>
<!-- 通知组件 -->
<div v-if="showNotification" class="toast toast-top toast-center z-50 w-fit-content">
<div :class="`alert ${notificationType === 'success'
? 'alert-success'
: notificationType === 'error'
? 'alert-error'
: 'alert-info'
}`">
<span>{{ notificationMessage }}</span>
</div>
</div>
<!-- 加载指示器 -->
<div v-if="isLoading" class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center z-50">
<div
v-if="isLoading"
class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center z-50"
>
<div class="loading loading-spinner loading-lg text-primary"></div>
</div>
<!-- 缩放指示器 -->
<div class="absolute bottom-4 right-4 bg-base-100 px-3 py-1.5 rounded-md shadow-md z-20" style="opacity: 0.9">
<div
class="absolute bottom-4 right-4 bg-base-100 px-3 py-1.5 rounded-md shadow-md z-20"
style="opacity: 0.9"
>
<span class="text-sm font-medium">{{ Math.round(scale * 100) }}%</span>
</div>
</div>
@ -140,6 +236,7 @@ import {
} from "vue";
import { useEventListener } from "@vueuse/core";
import WireComponent from "@/components/equipments/Wire.vue";
import { useAlertStore } from "@/components/Alert";
// diagram
import {
@ -186,9 +283,14 @@ const props = defineProps<{
// componentManager
const componentManager = useComponentManager();
if (!componentManager) {
throw new Error("DiagramCanvas must be used within a component manager provider");
throw new Error(
"DiagramCanvas must be used within a component manager provider",
);
}
// Alert store
const alertStore = useAlertStore();
// --- ---
const canvasContainer = ref<HTMLElement | null>(null);
const canvas = ref<HTMLElement | null>(null);
@ -215,7 +317,9 @@ const diagramData = ref<DiagramData>({
});
// 便
const componentRefs = computed(() => componentManager?.componentRefs.value || {});
const componentRefs = computed(
() => componentManager?.componentRefs.value || {},
);
// diagramData index
const diagramParts = computed<DiagramPart[]>(() => {
@ -320,13 +424,6 @@ const mousePosition = reactive({ x: 0, y: 0 });
//
const isLoading = ref(false);
//
const showNotification = ref(false);
const notificationMessage = ref("");
const notificationType = ref<"success" | "error" | "info">("info");
// toastID
const toastTimers: number[] = [];
//
const fileInput = ref<HTMLInputElement | null>(null);
@ -337,7 +434,7 @@ const isWireCreationEventActive = ref(false);
// 使VueUse
//
useEventListener(document, 'mousemove', (e: MouseEvent) => {
useEventListener(document, "mousemove", (e: MouseEvent) => {
if (isDragEventActive.value) {
onDrag(e);
}
@ -349,7 +446,7 @@ useEventListener(document, 'mousemove', (e: MouseEvent) => {
}
});
useEventListener(document, 'mouseup', () => {
useEventListener(document, "mouseup", () => {
if (isDragEventActive.value) {
stopDrag();
}
@ -359,7 +456,7 @@ useEventListener(document, 'mouseup', () => {
});
//
useEventListener(window, 'keydown', handleKeyDown);
useEventListener(window, "keydown", handleKeyDown);
// --- ---
const MIN_SCALE = 0.2;
@ -893,7 +990,7 @@ function handleFileSelected(event: Event) {
const validation = validateDiagramData(parsed);
if (!validation.isValid) {
showToast(
alertStore?.show(
`不是有效的diagram.json格式: ${validation.errors.join("; ")}`,
"error",
);
@ -910,11 +1007,11 @@ function handleFileSelected(event: Event) {
//
emit("diagram-updated", diagramData.value);
showToast(`成功导入diagram文件`, "success");
alertStore?.show(`成功导入diagram文件`, "success");
} catch (error) {
console.error("解析JSON文件出错:", error);
if (document.body.contains(canvasContainer.value)) {
showToast("解析文件出错请确认是有效的JSON格式", "error");
alertStore?.show("解析文件出错请确认是有效的JSON格式", "error");
}
} finally {
//
@ -930,7 +1027,7 @@ function handleFileSelected(event: Event) {
reader.onerror = () => {
//
if (document.body.contains(canvasContainer.value)) {
showToast("读取文件时出错", "error");
alertStore?.show("读取文件时出错", "error");
isLoading.value = false;
}
//
@ -956,46 +1053,21 @@ function exportDiagram() {
a.download = "diagram.json";
a.click();
// URL
const timerId = setTimeout(() => {
setTimeout(() => {
URL.revokeObjectURL(url);
//
if (document.body.contains(canvasContainer.value)) {
isLoading.value = false;
showToast("成功导出diagram文件", "success");
alertStore?.show("成功导出diagram文件", "success");
}
}, 100);
// ID便
toastTimers.push(timerId);
} catch (error) {
console.error("导出diagram文件时出错:", error);
showToast("导出diagram文件时出错", "error");
alertStore?.show("导出diagram文件时出错", "error");
isLoading.value = false;
}
}
//
function showToast(
message: string,
type: "success" | "error" | "info" = "info",
duration = 3000,
) {
notificationMessage.value = message;
notificationType.value = type;
showNotification.value = true;
// ID便
const timerId = setTimeout(() => {
//
if (document.body.contains(canvasContainer.value)) {
showNotification.value = false;
}
}, duration);
// ID便
toastTimers.push(timerId);
}
// --- ---
onMounted(async () => {
// componentManager
@ -1011,7 +1083,6 @@ onMounted(async () => {
getCanvasPosition: () => ({ x: position.x, y: position.y }),
getScale: () => scale.value,
$el: canvasContainer.value,
showToast
};
componentManager.setCanvasRef(canvasAPI);
}
@ -1033,7 +1104,9 @@ onMounted(async () => {
// componentManager
if (componentManager) {
await componentManager.preloadComponentModules(Array.from(componentTypes));
await componentManager.preloadComponentModules(
Array.from(componentTypes),
);
}
} catch (error) {
console.error("加载图表数据失败:", error);
@ -1061,16 +1134,6 @@ function handleKeyDown(e: KeyboardEvent) {
}
}
onUnmounted(() => {
// toast
toastTimers.forEach((timerId) => clearTimeout(timerId));
//
isDragEventActive.value = false;
isComponentDragEventActive.value = false;
isWireCreationEventActive.value = false;
});
//
function updateDiagramDataDirectly(data: DiagramData) {
//
@ -1085,7 +1148,7 @@ function updateDiagramDataDirectly(data: DiagramData) {
emit("diagram-updated", data);
}
// - 访
//
defineExpose({
//
getDiagramData: () => diagramData.value,
@ -1112,24 +1175,18 @@ defineExpose({
emit("diagram-updated", data);
// 便UI
const timerId = setTimeout(() => {
setTimeout(() => {
//
if (document.body.contains(canvasContainer.value)) {
isLoading.value = false;
}
}, 200);
// ID便
toastTimers.push(timerId);
});
},
//
getCanvasPosition: () => ({ x: position.x, y: position.y }),
getScale: () => scale.value,
//
showToast,
});
// -
@ -1254,7 +1311,13 @@ watch(
-ms-user-select: none;
}
.component-wrapper :deep(svg *:not([class*="interactive"]):not(rect.glow):not(circle[fill-opacity]):not([fill-opacity])) {
.component-wrapper
:deep(
svg
*:not([class*="interactive"]):not(rect.glow):not(
circle[fill-opacity]
):not([fill-opacity])
) {
pointer-events: none;
/* 非交互元素不接收鼠标事件 */
}