✨ Added toast notification function and optimized related component styles
This commit is contained in:
2
frontend/components.d.ts
vendored
2
frontend/components.d.ts
vendored
@@ -22,6 +22,8 @@ declare module 'vue' {
|
|||||||
TabContainer: typeof import('./src/components/tabs/TabContainer.vue')['default']
|
TabContainer: typeof import('./src/components/tabs/TabContainer.vue')['default']
|
||||||
TabContextMenu: typeof import('./src/components/tabs/TabContextMenu.vue')['default']
|
TabContextMenu: typeof import('./src/components/tabs/TabContextMenu.vue')['default']
|
||||||
TabItem: typeof import('./src/components/tabs/TabItem.vue')['default']
|
TabItem: typeof import('./src/components/tabs/TabItem.vue')['default']
|
||||||
|
Toast: typeof import('./src/components/toast/Toast.vue')['default']
|
||||||
|
ToastContainer: typeof import('./src/components/toast/ToastContainer.vue')['default']
|
||||||
Toolbar: typeof import('./src/components/toolbar/Toolbar.vue')['default']
|
Toolbar: typeof import('./src/components/toolbar/Toolbar.vue')['default']
|
||||||
WindowsTitleBar: typeof import('./src/components/titlebar/WindowsTitleBar.vue')['default']
|
WindowsTitleBar: typeof import('./src/components/titlebar/WindowsTitleBar.vue')['default']
|
||||||
WindowTitleBar: typeof import('./src/components/titlebar/WindowTitleBar.vue')['default']
|
WindowTitleBar: typeof import('./src/components/titlebar/WindowTitleBar.vue')['default']
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {useKeybindingStore} from '@/stores/keybindingStore';
|
|||||||
import {useThemeStore} from '@/stores/themeStore';
|
import {useThemeStore} from '@/stores/themeStore';
|
||||||
import {useUpdateStore} from '@/stores/updateStore';
|
import {useUpdateStore} from '@/stores/updateStore';
|
||||||
import WindowTitleBar from '@/components/titlebar/WindowTitleBar.vue';
|
import WindowTitleBar from '@/components/titlebar/WindowTitleBar.vue';
|
||||||
|
import ToastContainer from '@/components/toast/ToastContainer.vue';
|
||||||
import {useTranslationStore} from "@/stores/translationStore";
|
import {useTranslationStore} from "@/stores/translationStore";
|
||||||
import {useI18n} from "vue-i18n";
|
import {useI18n} from "vue-i18n";
|
||||||
import {LanguageType} from "../bindings/voidraft/internal/models";
|
import {LanguageType} from "../bindings/voidraft/internal/models";
|
||||||
@@ -41,6 +42,7 @@ onBeforeMount(async () => {
|
|||||||
<div class="app-content">
|
<div class="app-content">
|
||||||
<router-view/>
|
<router-view/>
|
||||||
</div>
|
</div>
|
||||||
|
<ToastContainer/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
292
frontend/src/components/toast/Toast.vue
Normal file
292
frontend/src/components/toast/Toast.vue
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="['toast-item', `toast-${type}`]"
|
||||||
|
@mouseenter="pauseTimer"
|
||||||
|
@mouseleave="resumeTimer"
|
||||||
|
>
|
||||||
|
<!-- 图标 -->
|
||||||
|
<div class="toast-icon">
|
||||||
|
<svg v-if="type === 'success'" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<path d="M10 0C4.48 0 0 4.48 0 10s4.48 10 10 10 10-4.48 10-10S15.52 0 10 0zm-2 15l-5-5 1.41-1.41L8 12.17l7.59-7.59L17 6l-9 9z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else-if="type === 'error'" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<path d="M10 0C4.48 0 0 4.48 0 10s4.48 10 10 10 10-4.48 10-10S15.52 0 10 0zm1 15H9v-2h2v2zm0-4H9V5h2v6z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else-if="type === 'warning'" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<path d="M1 19h18L10 1 1 19zm10-3H9v-2h2v2zm0-4H9v-4h2v4z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else-if="type === 'info'" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<path d="M10 0C4.48 0 0 4.48 0 10s4.48 10 10 10 10-4.48 10-10S15.52 0 10 0zm1 15H9V9h2v6zm0-8H9V5h2v2z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容 -->
|
||||||
|
<div class="toast-content">
|
||||||
|
<div v-if="title" class="toast-title">{{ title }}</div>
|
||||||
|
<div class="toast-message">{{ message }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 关闭按钮 -->
|
||||||
|
<button
|
||||||
|
v-if="closable"
|
||||||
|
class="toast-close"
|
||||||
|
@click="close"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M12 4L4 12M4 4L12 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
|
import type { Toast } from './types';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
toast: Toast;
|
||||||
|
}>(),
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: [id: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const timer = ref<number | null>(null);
|
||||||
|
const remainingTime = ref(props.toast.duration);
|
||||||
|
const pausedAt = ref<number | null>(null);
|
||||||
|
|
||||||
|
const { id, message, title, type, duration, closable } = props.toast;
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
emit('close', id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTimer = () => {
|
||||||
|
if (duration > 0) {
|
||||||
|
timer.value = window.setTimeout(() => {
|
||||||
|
close();
|
||||||
|
}, remainingTime.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearTimer = () => {
|
||||||
|
if (timer.value) {
|
||||||
|
clearTimeout(timer.value);
|
||||||
|
timer.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pauseTimer = () => {
|
||||||
|
if (timer.value && duration > 0) {
|
||||||
|
clearTimer();
|
||||||
|
pausedAt.value = Date.now();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resumeTimer = () => {
|
||||||
|
if (pausedAt.value && duration > 0) {
|
||||||
|
const elapsed = Date.now() - pausedAt.value;
|
||||||
|
remainingTime.value = Math.max(0, remainingTime.value - elapsed);
|
||||||
|
pausedAt.value = null;
|
||||||
|
startTimer();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
startTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearTimer();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.toast-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
transform-origin: center center;
|
||||||
|
|
||||||
|
// 毛玻璃效果
|
||||||
|
// 亮色主题
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||||
|
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.08),
|
||||||
|
0 1px 3px rgba(0, 0, 0, 0.06),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||||
|
|
||||||
|
cursor: default;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow:
|
||||||
|
0 6px 16px rgba(0, 0, 0, 0.1),
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.08),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 深色主题适配 - 使用应用的 data-theme 属性
|
||||||
|
:root[data-theme="dark"] .toast-item,
|
||||||
|
:root[data-theme="auto"] .toast-item {
|
||||||
|
background: rgba(45, 45, 45, 0.9);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.3),
|
||||||
|
0 1px 3px rgba(0, 0, 0, 0.2),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow:
|
||||||
|
0 6px 16px rgba(0, 0, 0, 0.4),
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.3),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跟随系统主题时的浅色偏好
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root[data-theme="auto"] .toast-item {
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.08),
|
||||||
|
0 1px 3px rgba(0, 0, 0, 0.06),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow:
|
||||||
|
0 6px 16px rgba(0, 0, 0, 0.1),
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.08),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-top: 2px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有标题时,图标与标题对齐(不需要 margin-top)
|
||||||
|
.toast-item:has(.toast-title) .toast-icon {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-success .toast-icon {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-error .toast-icon {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-warning .toast-icon {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-info .toast-icon {
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--settings-text);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-message {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--settings-text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
margin-top: 0px;
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--settings-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
color: var(--settings-text);
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: rotate(90deg) scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] .toast-close,
|
||||||
|
:root[data-theme="auto"] .toast-close {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root[data-theme="auto"] .toast-close {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
168
frontend/src/components/toast/ToastContainer.vue
Normal file
168
frontend/src/components/toast/ToastContainer.vue
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<TransitionGroup
|
||||||
|
v-for="position in positions"
|
||||||
|
:key="position"
|
||||||
|
:class="['toast-container', `toast-container-${position}`]"
|
||||||
|
name="toast-list"
|
||||||
|
tag="div"
|
||||||
|
>
|
||||||
|
<ToastItem
|
||||||
|
v-for="toast in getToastsByPosition(position)"
|
||||||
|
:key="toast.id"
|
||||||
|
:toast="toast"
|
||||||
|
@close="removeToast"
|
||||||
|
/>
|
||||||
|
</TransitionGroup>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ToastItem from './Toast.vue';
|
||||||
|
import { useToastStore } from './toastStore';
|
||||||
|
import type { ToastPosition } from './types';
|
||||||
|
|
||||||
|
const toastStore = useToastStore();
|
||||||
|
|
||||||
|
const positions: ToastPosition[] = [
|
||||||
|
'top-left',
|
||||||
|
'top-center',
|
||||||
|
'top-right',
|
||||||
|
'bottom-left',
|
||||||
|
'bottom-center',
|
||||||
|
'bottom-right',
|
||||||
|
];
|
||||||
|
|
||||||
|
const getToastsByPosition = (position: ToastPosition) => {
|
||||||
|
return toastStore.toasts.filter(toast => toast.position === position);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeToast = (id: string) => {
|
||||||
|
toastStore.remove(id);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
pointer-events: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顶部位置 - 增加间距避免覆盖标题栏
|
||||||
|
.toast-container-top-left {
|
||||||
|
top: 35px;
|
||||||
|
left: 20px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container-top-center {
|
||||||
|
top: 35px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container-top-right {
|
||||||
|
top: 35px;
|
||||||
|
right: 20px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部位置
|
||||||
|
.toast-container-bottom-left {
|
||||||
|
bottom: 20px;
|
||||||
|
left: 20px;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container-bottom-center {
|
||||||
|
bottom: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container-bottom-right {
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransitionGroup 列表动画 - 从哪来回哪去,收缩滑出
|
||||||
|
.toast-list-move {
|
||||||
|
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-list-enter-active {
|
||||||
|
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-list-leave-active {
|
||||||
|
transition: all 0.3s cubic-bezier(0.6, 0, 0.8, 0.4);
|
||||||
|
position: absolute !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右侧位置:从右滑入,收缩向右滑出
|
||||||
|
.toast-container-top-right,
|
||||||
|
.toast-container-bottom-right {
|
||||||
|
.toast-list-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%) scale(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-list-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%) scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 左侧位置:从左滑入,收缩向左滑出
|
||||||
|
.toast-container-top-left,
|
||||||
|
.toast-container-bottom-left {
|
||||||
|
.toast-list-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-100%) scale(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-list-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-100%) scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 居中位置:从上/下滑入,收缩向上/下滑出
|
||||||
|
.toast-container-top-center {
|
||||||
|
.toast-list-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-100%) scale(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-list-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-100%) scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container-bottom-center {
|
||||||
|
.toast-list-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(100%) scale(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-list-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(100%) scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
80
frontend/src/components/toast/index.ts
Normal file
80
frontend/src/components/toast/index.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useToastStore } from './toastStore';
|
||||||
|
import type { ToastOptions } from './types';
|
||||||
|
|
||||||
|
class ToastService {
|
||||||
|
private getStore() {
|
||||||
|
return useToastStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示一个通知
|
||||||
|
*/
|
||||||
|
show(options: ToastOptions): string {
|
||||||
|
return this.getStore().add(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示成功通知
|
||||||
|
*/
|
||||||
|
success(message: string, title?: string, options?: Partial<ToastOptions>): string {
|
||||||
|
return this.show({
|
||||||
|
message,
|
||||||
|
title,
|
||||||
|
type: 'success',
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示错误通知
|
||||||
|
*/
|
||||||
|
error(message: string, title?: string, options?: Partial<ToastOptions>): string {
|
||||||
|
return this.show({
|
||||||
|
message,
|
||||||
|
title,
|
||||||
|
type: 'error',
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示警告通知
|
||||||
|
*/
|
||||||
|
warning(message: string, title?: string, options?: Partial<ToastOptions>): string {
|
||||||
|
return this.show({
|
||||||
|
message,
|
||||||
|
title,
|
||||||
|
type: 'warning',
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示信息通知
|
||||||
|
*/
|
||||||
|
info(message: string, title?: string, options?: Partial<ToastOptions>): string {
|
||||||
|
return this.show({
|
||||||
|
message,
|
||||||
|
title,
|
||||||
|
type: 'info',
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭指定的通知
|
||||||
|
*/
|
||||||
|
close(id: string): void {
|
||||||
|
this.getStore().remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空所有通知
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.getStore().clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const toast = new ToastService();
|
||||||
|
export default toast;
|
||||||
|
|
||||||
55
frontend/src/components/toast/toastStore.ts
Normal file
55
frontend/src/components/toast/toastStore.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import type { Toast, ToastOptions } from './types';
|
||||||
|
|
||||||
|
export const useToastStore = defineStore('toast', () => {
|
||||||
|
const toasts = ref<Toast[]>([]);
|
||||||
|
let idCounter = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加一个 Toast
|
||||||
|
*/
|
||||||
|
const add = (options: ToastOptions): string => {
|
||||||
|
const id = `toast-${Date.now()}-${idCounter++}`;
|
||||||
|
|
||||||
|
const toast: Toast = {
|
||||||
|
id,
|
||||||
|
message: options.message,
|
||||||
|
type: options.type || 'info',
|
||||||
|
title: options.title,
|
||||||
|
duration: options.duration ?? 4000,
|
||||||
|
position: options.position || 'top-right',
|
||||||
|
closable: options.closable ?? true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
toasts.value.push(toast);
|
||||||
|
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除指定 Toast
|
||||||
|
*/
|
||||||
|
const remove = (id: string) => {
|
||||||
|
const index = toasts.value.findIndex(t => t.id === id);
|
||||||
|
if (index > -1) {
|
||||||
|
toasts.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空所有 Toast
|
||||||
|
*/
|
||||||
|
const clear = () => {
|
||||||
|
toasts.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
toasts,
|
||||||
|
add,
|
||||||
|
remove,
|
||||||
|
clear,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
52
frontend/src/components/toast/types.ts
Normal file
52
frontend/src/components/toast/types.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Toast 通知类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||||
|
|
||||||
|
export type ToastPosition =
|
||||||
|
| 'top-right'
|
||||||
|
| 'top-left'
|
||||||
|
| 'bottom-right'
|
||||||
|
| 'bottom-left'
|
||||||
|
| 'top-center'
|
||||||
|
| 'bottom-center';
|
||||||
|
|
||||||
|
export interface ToastOptions {
|
||||||
|
/**
|
||||||
|
* Toast 消息内容
|
||||||
|
*/
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast 类型
|
||||||
|
*/
|
||||||
|
type?: ToastType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标题(可选)
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 持续时间(毫秒),0 表示不自动关闭
|
||||||
|
*/
|
||||||
|
duration?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示位置
|
||||||
|
*/
|
||||||
|
position?: ToastPosition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否可关闭
|
||||||
|
*/
|
||||||
|
closable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Toast extends Required<Omit<ToastOptions, 'title'>> {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -294,6 +294,8 @@ const scrollToCurrentLanguage = () => {
|
|||||||
<span class="arrow" :class="{ 'open': showLanguageMenu }">▲</span>
|
<span class="arrow" :class="{ 'open': showLanguageMenu }">▲</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- 菜单 -->
|
||||||
|
<Transition name="slide-up">
|
||||||
<div class="language-menu" v-if="showLanguageMenu">
|
<div class="language-menu" v-if="showLanguageMenu">
|
||||||
<!-- 搜索框 -->
|
<!-- 搜索框 -->
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
@@ -331,10 +333,22 @@ const scrollToCurrentLanguage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
.slide-up-enter-active,
|
||||||
|
.slide-up-leave-active {
|
||||||
|
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up-enter-from,
|
||||||
|
.slide-up-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
.block-language-selector {
|
.block-language-selector {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
@@ -386,15 +400,17 @@ const scrollToCurrentLanguage = () => {
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
width: 220px;
|
width: 280px;
|
||||||
max-height: 280px;
|
max-height: 400px;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
.search-container {
|
.search-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 8px;
|
padding: 10px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
@@ -403,11 +419,11 @@ const scrollToCurrentLanguage = () => {
|
|||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
padding: 5px 8px 5px 26px;
|
padding: 6px 10px 6px 30px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
outline: none;
|
outline: none;
|
||||||
line-height: 1.2;
|
line-height: 1.4;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
border-color: var(--text-muted);
|
border-color: var(--text-muted);
|
||||||
@@ -420,7 +436,7 @@ const scrollToCurrentLanguage = () => {
|
|||||||
|
|
||||||
.search-icon {
|
.search-icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 14px;
|
left: 16px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -429,20 +445,21 @@ const scrollToCurrentLanguage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.language-list {
|
.language-list {
|
||||||
max-height: 200px;
|
max-height: 320px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
.language-option {
|
.language-option {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 6px 8px;
|
padding: 8px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--border-color);
|
background-color: var(--bg-hover);
|
||||||
opacity: 0.8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
@@ -460,17 +477,17 @@ const scrollToCurrentLanguage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.language-alias {
|
.language-alias {
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-results {
|
.no-results {
|
||||||
padding: 12px 8px;
|
padding: 14px 10px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -478,7 +495,7 @@ const scrollToCurrentLanguage = () => {
|
|||||||
|
|
||||||
/* 自定义滚动条 */
|
/* 自定义滚动条 */
|
||||||
.language-list::-webkit-scrollbar {
|
.language-list::-webkit-scrollbar {
|
||||||
width: 4px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.language-list::-webkit-scrollbar-track {
|
.language-list::-webkit-scrollbar-track {
|
||||||
@@ -487,7 +504,7 @@ const scrollToCurrentLanguage = () => {
|
|||||||
|
|
||||||
.language-list::-webkit-scrollbar-thumb {
|
.language-list::-webkit-scrollbar-thumb {
|
||||||
background-color: var(--border-color);
|
background-color: var(--border-color);
|
||||||
border-radius: 2px;
|
border-radius: 3px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--text-muted);
|
background-color: var(--text-muted);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {useConfirm} from '@/composables';
|
|||||||
import {validateDocumentTitle} from '@/common/utils/validation';
|
import {validateDocumentTitle} from '@/common/utils/validation';
|
||||||
import {formatDateTime, truncateString} from '@/common/utils/formatter';
|
import {formatDateTime, truncateString} from '@/common/utils/formatter';
|
||||||
import {Document} from '@/../bindings/voidraft/internal/models/ent/models';
|
import {Document} from '@/../bindings/voidraft/internal/models/ent/models';
|
||||||
|
import toast from '@/components/toast';
|
||||||
|
|
||||||
// 类型定义
|
// 类型定义
|
||||||
interface DocumentItem extends Document {
|
interface DocumentItem extends Document {
|
||||||
@@ -96,7 +97,7 @@ const closeMenu = () => {
|
|||||||
const selectDoc = async (doc: DocumentItem) => {
|
const selectDoc = async (doc: DocumentItem) => {
|
||||||
if (doc.id === undefined) return;
|
if (doc.id === undefined) return;
|
||||||
|
|
||||||
// 如果选择的就是当前文档,直接关闭菜单
|
// 如果选择的就是当前文档,直接关闭菜单
|
||||||
if (documentStore.currentDocument?.id === doc.id) {
|
if (documentStore.currentDocument?.id === doc.id) {
|
||||||
closeMenu();
|
closeMenu();
|
||||||
return;
|
return;
|
||||||
@@ -104,7 +105,7 @@ const selectDoc = async (doc: DocumentItem) => {
|
|||||||
|
|
||||||
const hasOpen = await windowStore.isDocumentWindowOpen(doc.id);
|
const hasOpen = await windowStore.isDocumentWindowOpen(doc.id);
|
||||||
if (hasOpen) {
|
if (hasOpen) {
|
||||||
documentStore.setError(doc.id, t('toolbar.alreadyOpenInNewWindow'));
|
toast.warning(t('toolbar.alreadyOpenInNewWindow'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +117,7 @@ const selectDoc = async (doc: DocumentItem) => {
|
|||||||
editorStateStore.saveCursorPosition(oldDocId, cursorPos);
|
editorStateStore.saveCursorPosition(oldDocId, cursorPos);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果旧文档有未保存修改,保存它
|
// 如果旧文档有未保存修改,保存它
|
||||||
if (oldDocId && editorStore.hasUnsavedChanges(oldDocId)) {
|
if (oldDocId && editorStore.hasUnsavedChanges(oldDocId)) {
|
||||||
|
|
||||||
const content = editorStore.getCurrentContent();
|
const content = editorStore.getCurrentContent();
|
||||||
@@ -238,7 +239,7 @@ const handleDelete = async (doc: DocumentItem, event: Event) => {
|
|||||||
// 确认删除前检查文档是否在其他窗口打开
|
// 确认删除前检查文档是否在其他窗口打开
|
||||||
const hasOpen = await windowStore.isDocumentWindowOpen(doc.id);
|
const hasOpen = await windowStore.isDocumentWindowOpen(doc.id);
|
||||||
if (hasOpen) {
|
if (hasOpen) {
|
||||||
documentStore.setError(doc.id, t('toolbar.alreadyOpenInNewWindow'));
|
toast.warning(t('toolbar.alreadyOpenInNewWindow'));
|
||||||
resetDeleteConfirm();
|
resetDeleteConfirm();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -246,7 +247,7 @@ const handleDelete = async (doc: DocumentItem, event: Event) => {
|
|||||||
const deleteSuccess = await documentStore.deleteDocument(doc.id);
|
const deleteSuccess = await documentStore.deleteDocument(doc.id);
|
||||||
if (deleteSuccess) {
|
if (deleteSuccess) {
|
||||||
state.documentList = await documentStore.getDocumentList();
|
state.documentList = await documentStore.getDocumentList();
|
||||||
// 如果删除的是当前文档,切换到第一个文档
|
// 如果删除的是当前文档,切换到第一个文档
|
||||||
if (documentStore.currentDocument?.id === doc.id && state.documentList.length > 0) {
|
if (documentStore.currentDocument?.id === doc.id && state.documentList.length > 0) {
|
||||||
const firstDoc = state.documentList[0];
|
const firstDoc = state.documentList[0];
|
||||||
if (firstDoc) await selectDoc(firstDoc);
|
if (firstDoc) await selectDoc(firstDoc);
|
||||||
@@ -341,11 +342,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
|||||||
<!-- 普通显示 -->
|
<!-- 普通显示 -->
|
||||||
<div v-if="state.editing.id !== item.id" class="doc-info">
|
<div v-if="state.editing.id !== item.id" class="doc-info">
|
||||||
<div class="doc-title">{{ item.title }}</div>
|
<div class="doc-title">{{ item.title }}</div>
|
||||||
<!-- 根据状态显示错误信息或时间 -->
|
<div class="doc-date">{{ formatDateTime(item.updated_at) }}</div>
|
||||||
<div v-if="documentStore.selectorError?.docId === item.id" class="doc-error">
|
|
||||||
{{ documentStore.selectorError?.message }}
|
|
||||||
</div>
|
|
||||||
<div v-else class="doc-date">{{ formatDateTime(item.updated_at) }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 编辑状态 -->
|
<!-- 编辑状态 -->
|
||||||
@@ -387,7 +384,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="state.documentList.length > 1 && item.id !== 1"
|
v-if="state.documentList.length > 1"
|
||||||
class="action-btn delete-btn"
|
class="action-btn delete-btn"
|
||||||
:class="{ 'delete-confirm': isDeleting(item.id!) }"
|
:class="{ 'delete-confirm': isDeleting(item.id!) }"
|
||||||
@click="handleDelete(item, $event)"
|
@click="handleDelete(item, $event)"
|
||||||
@@ -478,7 +475,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
width: 300px;
|
width: 340px;
|
||||||
max-height: calc(100vh - 40px);
|
max-height: calc(100vh - 40px);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
@@ -488,7 +485,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
|||||||
|
|
||||||
.input-box {
|
.input-box {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 8px;
|
padding: 10px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
.main-input {
|
.main-input {
|
||||||
@@ -497,8 +494,8 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
|||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
padding: 5px 8px 5px 26px;
|
padding: 6px 10px 6px 30px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
@@ -513,7 +510,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
|||||||
|
|
||||||
.input-icon {
|
.input-icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 14px;
|
left: 16px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -542,7 +539,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
|||||||
color: var(--selection-text);
|
color: var(--selection-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.doc-date, .doc-error {
|
.doc-date {
|
||||||
color: var(--selection-text);
|
color: var(--selection-text);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
@@ -554,8 +551,8 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 8px;
|
padding: 10px 10px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
@@ -569,15 +566,15 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 8px 8px;
|
padding: 10px 10px;
|
||||||
|
|
||||||
.doc-info {
|
.doc-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
.doc-title {
|
.doc-title {
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 3px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -585,17 +582,10 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.doc-date {
|
.doc-date {
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.doc-error {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-danger);
|
|
||||||
font-weight: 500;
|
|
||||||
animation: fadeInOut 3s forwards;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.doc-edit {
|
.doc-edit {
|
||||||
@@ -607,8 +597,8 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
|||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
padding: 4px 6px;
|
padding: 5px 8px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
@@ -620,7 +610,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
|||||||
|
|
||||||
.doc-actions {
|
.doc-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
|
|
||||||
@@ -629,7 +619,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
|||||||
border: none;
|
border: none;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 4px;
|
padding: 5px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -650,7 +640,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
|||||||
color: white;
|
color: white;
|
||||||
|
|
||||||
.confirm-text {
|
.confirm-text {
|
||||||
font-size: 9px;
|
font-size: 10px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -665,27 +655,12 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
padding: 16px 8px;
|
padding: 18px 10px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeInOut {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
10% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
90% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { BackupService } from '@/../bindings/voidraft/internal/services';
|
|||||||
|
|
||||||
export const useBackupStore = defineStore('backup', () => {
|
export const useBackupStore = defineStore('backup', () => {
|
||||||
const isSyncing = ref(false);
|
const isSyncing = ref(false);
|
||||||
const error = ref<string | null>(null);
|
|
||||||
|
|
||||||
const sync = async (): Promise<void> => {
|
const sync = async (): Promise<void> => {
|
||||||
if (isSyncing.value) {
|
if (isSyncing.value) {
|
||||||
@@ -12,12 +11,11 @@ export const useBackupStore = defineStore('backup', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isSyncing.value = true;
|
isSyncing.value = true;
|
||||||
error.value = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await BackupService.Sync();
|
await BackupService.Sync();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : String(e);
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
isSyncing.value = false;
|
isSyncing.value = false;
|
||||||
}
|
}
|
||||||
@@ -25,7 +23,6 @@ export const useBackupStore = defineStore('backup', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
isSyncing,
|
isSyncing,
|
||||||
error,
|
|
||||||
sync
|
sync
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -16,33 +16,15 @@ export const useDocumentStore = defineStore('document', () => {
|
|||||||
|
|
||||||
// === UI状态 ===
|
// === UI状态 ===
|
||||||
const showDocumentSelector = ref(false);
|
const showDocumentSelector = ref(false);
|
||||||
const selectorError = ref<{ docId: number; message: string } | null>(null);
|
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
|
|
||||||
// === 错误处理 ===
|
|
||||||
const setError = (docId: number, message: string) => {
|
|
||||||
selectorError.value = {docId, message};
|
|
||||||
// 3秒后自动清除错误状态
|
|
||||||
setTimeout(() => {
|
|
||||||
if (selectorError.value?.docId === docId) {
|
|
||||||
selectorError.value = null;
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearError = () => {
|
|
||||||
selectorError.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// === UI控制方法 ===
|
// === UI控制方法 ===
|
||||||
const openDocumentSelector = () => {
|
const openDocumentSelector = () => {
|
||||||
showDocumentSelector.value = true;
|
showDocumentSelector.value = true;
|
||||||
clearError();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeDocumentSelector = () => {
|
const closeDocumentSelector = () => {
|
||||||
showDocumentSelector.value = false;
|
showDocumentSelector.value = false;
|
||||||
clearError();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -217,7 +199,6 @@ export const useDocumentStore = defineStore('document', () => {
|
|||||||
currentDocumentId,
|
currentDocumentId,
|
||||||
currentDocument,
|
currentDocument,
|
||||||
showDocumentSelector,
|
showDocumentSelector,
|
||||||
selectorError,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|
||||||
getDocumentList,
|
getDocumentList,
|
||||||
@@ -236,8 +217,6 @@ export const useDocumentStore = defineStore('document', () => {
|
|||||||
// UI 控制
|
// UI 控制
|
||||||
openDocumentSelector,
|
openDocumentSelector,
|
||||||
closeDocumentSelector,
|
closeDocumentSelector,
|
||||||
setError,
|
|
||||||
clearError,
|
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
initDocument,
|
initDocument,
|
||||||
|
|||||||
@@ -2,48 +2,18 @@
|
|||||||
import {useConfigStore} from '@/stores/configStore';
|
import {useConfigStore} from '@/stores/configStore';
|
||||||
import {useBackupStore} from '@/stores/backupStore';
|
import {useBackupStore} from '@/stores/backupStore';
|
||||||
import {useI18n} from 'vue-i18n';
|
import {useI18n} from 'vue-i18n';
|
||||||
import {computed, ref, watch, onUnmounted} from 'vue';
|
import {computed} from 'vue';
|
||||||
import SettingSection from '../components/SettingSection.vue';
|
import SettingSection from '../components/SettingSection.vue';
|
||||||
import SettingItem from '../components/SettingItem.vue';
|
import SettingItem from '../components/SettingItem.vue';
|
||||||
import ToggleSwitch from '../components/ToggleSwitch.vue';
|
import ToggleSwitch from '../components/ToggleSwitch.vue';
|
||||||
import {AuthMethod} from '@/../bindings/voidraft/internal/models/models';
|
import {AuthMethod} from '@/../bindings/voidraft/internal/models/models';
|
||||||
import {DialogService} from '@/../bindings/voidraft/internal/services';
|
import {DialogService} from '@/../bindings/voidraft/internal/services';
|
||||||
|
import toast from '@/components/toast';
|
||||||
|
|
||||||
const {t} = useI18n();
|
const {t} = useI18n();
|
||||||
const configStore = useConfigStore();
|
const configStore = useConfigStore();
|
||||||
const backupStore = useBackupStore();
|
const backupStore = useBackupStore();
|
||||||
|
|
||||||
// 消息显示状态
|
|
||||||
const message = ref<string | null>(null);
|
|
||||||
const isError = ref(false);
|
|
||||||
let messageTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
const clearMessage = () => {
|
|
||||||
if (messageTimer) {
|
|
||||||
clearTimeout(messageTimer);
|
|
||||||
messageTimer = null;
|
|
||||||
}
|
|
||||||
message.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 监听同步完成,显示消息并自动消失
|
|
||||||
watch(() => backupStore.isSyncing, (syncing, wasSyncing) => {
|
|
||||||
if (wasSyncing && !syncing) {
|
|
||||||
clearMessage();
|
|
||||||
if (backupStore.error) {
|
|
||||||
message.value = backupStore.error;
|
|
||||||
isError.value = true;
|
|
||||||
messageTimer = setTimeout(clearMessage, 5000);
|
|
||||||
} else {
|
|
||||||
message.value = 'Sync successful';
|
|
||||||
isError.value = false;
|
|
||||||
messageTimer = setTimeout(clearMessage, 3000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(clearMessage);
|
|
||||||
|
|
||||||
const authMethodOptions = computed(() => [
|
const authMethodOptions = computed(() => [
|
||||||
{value: AuthMethod.Token, label: t('settings.backup.authMethods.token')},
|
{value: AuthMethod.Token, label: t('settings.backup.authMethods.token')},
|
||||||
{value: AuthMethod.SSHKey, label: t('settings.backup.authMethods.sshKey')},
|
{value: AuthMethod.SSHKey, label: t('settings.backup.authMethods.sshKey')},
|
||||||
@@ -64,6 +34,15 @@ const selectSshKeyFile = async () => {
|
|||||||
configStore.setSshKeyPath(selectedPath.trim());
|
configStore.setSshKeyPath(selectedPath.trim());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
try {
|
||||||
|
await backupStore.sync();
|
||||||
|
toast.success('Sync successful');
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -202,14 +181,10 @@ const selectSshKeyFile = async () => {
|
|||||||
|
|
||||||
<!-- 备份操作 -->
|
<!-- 备份操作 -->
|
||||||
<SettingSection :title="t('settings.backup.backupOperations')">
|
<SettingSection :title="t('settings.backup.backupOperations')">
|
||||||
<SettingItem
|
<SettingItem :title="t('settings.backup.syncToRemote')">
|
||||||
:title="t('settings.backup.syncToRemote')"
|
|
||||||
:description="message || undefined"
|
|
||||||
:descriptionType="message ? (isError ? 'error' : 'success') : 'default'"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
class="sync-button"
|
class="sync-button"
|
||||||
@click="backupStore.sync"
|
@click="handleSync"
|
||||||
:disabled="!configStore.config.backup.enabled || !configStore.config.backup.repo_url || backupStore.isSyncing"
|
:disabled="!configStore.config.backup.enabled || !configStore.config.backup.repo_url || backupStore.isSyncing"
|
||||||
:class="{ 'syncing': backupStore.isSyncing }"
|
:class="{ 'syncing': backupStore.isSyncing }"
|
||||||
>
|
>
|
||||||
@@ -222,10 +197,6 @@ const selectSshKeyFile = async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.settings-page {
|
|
||||||
//max-width: 800px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统一的输入控件样式
|
// 统一的输入控件样式
|
||||||
.repo-url-input,
|
.repo-url-input,
|
||||||
.branch-input,
|
.branch-input,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import ToggleSwitch from '../components/ToggleSwitch.vue';
|
|||||||
import {DialogService, HotkeyService, MigrationService} from '@/../bindings/voidraft/internal/services';
|
import {DialogService, HotkeyService, MigrationService} from '@/../bindings/voidraft/internal/services';
|
||||||
import {useSystemStore} from "@/stores/systemStore";
|
import {useSystemStore} from "@/stores/systemStore";
|
||||||
import {useConfirm, usePolling} from '@/composables';
|
import {useConfirm, usePolling} from '@/composables';
|
||||||
|
import toast from '@/components/toast';
|
||||||
|
|
||||||
const {t} = useI18n();
|
const {t} = useI18n();
|
||||||
const {
|
const {
|
||||||
@@ -29,7 +30,6 @@ const tabStore = useTabStore();
|
|||||||
|
|
||||||
// 进度条显示控制
|
// 进度条显示控制
|
||||||
const showBar = ref(false);
|
const showBar = ref(false);
|
||||||
const manualError = ref(''); // 用于捕获 MigrateDirectory 抛出的错误
|
|
||||||
let hideTimer = 0;
|
let hideTimer = 0;
|
||||||
|
|
||||||
// 轮询迁移进度
|
// 轮询迁移进度
|
||||||
@@ -39,15 +39,20 @@ const {data: progress, error: pollError, isActive: migrating, start, stop, reset
|
|||||||
interval: 300,
|
interval: 300,
|
||||||
shouldStop: ({progress, error}) => !!error || progress >= 100,
|
shouldStop: ({progress, error}) => !!error || progress >= 100,
|
||||||
onStop: () => {
|
onStop: () => {
|
||||||
const hasError = pollError.value || progress.value?.error;
|
const error = pollError.value || progress.value?.error;
|
||||||
hideTimer = window.setTimeout(hideAll, hasError ? 5000 : 3000);
|
if (error) {
|
||||||
|
toast.error(error);
|
||||||
|
} else if ((progress.value?.progress ?? 0) >= 100) {
|
||||||
|
toast.success('Migration successful');
|
||||||
|
}
|
||||||
|
hideTimer = window.setTimeout(hideAll, 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// 派生状态
|
// 派生状态
|
||||||
const migrationError = computed(() => manualError.value || pollError.value || progress.value?.error || '');
|
|
||||||
const currentProgress = computed(() => progress.value?.progress ?? 0);
|
const currentProgress = computed(() => progress.value?.progress ?? 0);
|
||||||
|
const migrationError = computed(() => pollError.value || progress.value?.error || '');
|
||||||
|
|
||||||
const barClass = computed(() => {
|
const barClass = computed(() => {
|
||||||
if (!showBar.value) return '';
|
if (!showBar.value) return '';
|
||||||
@@ -64,8 +69,7 @@ const hideAll = () => {
|
|||||||
clearTimeout(hideTimer);
|
clearTimeout(hideTimer);
|
||||||
hideTimer = 0;
|
hideTimer = 0;
|
||||||
showBar.value = false;
|
showBar.value = false;
|
||||||
manualError.value = '';
|
reset();
|
||||||
reset(); // 清除轮询状态
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 重置设置确认
|
// 重置设置确认
|
||||||
@@ -193,10 +197,8 @@ const selectDataDirectory = async () => {
|
|||||||
|
|
||||||
const [oldPath, newPath] = [currentDataPath.value, selectedPath.trim()];
|
const [oldPath, newPath] = [currentDataPath.value, selectedPath.trim()];
|
||||||
|
|
||||||
// 清除之前的状态并开始轮询
|
|
||||||
hideAll();
|
hideAll();
|
||||||
showBar.value = true;
|
showBar.value = true;
|
||||||
manualError.value = '';
|
|
||||||
start();
|
start();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -204,10 +206,9 @@ const selectDataDirectory = async () => {
|
|||||||
await setDataPath(newPath);
|
await setDataPath(newPath);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
stop();
|
stop();
|
||||||
// 设置手动捕获的错误(当轮询还没获取到错误时)
|
toast.error(String(e).replace(/^Error:\s*/i, '') || 'Migration failed');
|
||||||
manualError.value = String(e).replace(/^Error:\s*/i, '') || 'Migration failed';
|
|
||||||
showBar.value = true;
|
showBar.value = true;
|
||||||
hideTimer = window.setTimeout(hideAll, 5000);
|
hideTimer = window.setTimeout(hideAll, 3000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -300,11 +301,6 @@ const selectDataDirectory = async () => {
|
|||||||
<!-- 进度条 -->
|
<!-- 进度条 -->
|
||||||
<div class="progress-bar" :class="[{'active': showBar}, barClass]" :style="{width: barWidth}"/>
|
<div class="progress-bar" :class="[{'active': showBar}, barClass]" :style="{width: barWidth}"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 错误提示 -->
|
|
||||||
<Transition name="error-fade">
|
|
||||||
<div v-if="migrationError" class="progress-error">{{ migrationError }}</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SettingSection>
|
</SettingSection>
|
||||||
@@ -537,13 +533,6 @@ const selectDataDirectory = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-error {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #ef4444;
|
|
||||||
opacity: 1;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.reset-button {
|
.reset-button {
|
||||||
@@ -602,35 +591,4 @@ const selectDataDirectory = async () => {
|
|||||||
box-shadow: 0 0 0 0 rgba(255, 71, 87, 0);
|
box-shadow: 0 0 0 0 rgba(255, 71, 87, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息点脉冲动画
|
|
||||||
@keyframes pulse-dot {
|
|
||||||
0%, 100% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.5;
|
|
||||||
transform: scale(1.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 错误提示动画
|
|
||||||
.error-fade-enter-active {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-fade-leave-active {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-fade-enter-from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-fade-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-4px);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -72,6 +72,72 @@
|
|||||||
</div>
|
</div>
|
||||||
</SettingSection>
|
</SettingSection>
|
||||||
|
|
||||||
|
<!-- Toast 通知测试区域 -->
|
||||||
|
<SettingSection title="Toast Notification Test">
|
||||||
|
<SettingItem title="Toast Message">
|
||||||
|
<input
|
||||||
|
v-model="toastMessage"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter toast message"
|
||||||
|
class="select-input"
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem title="Toast Title (Optional)">
|
||||||
|
<input
|
||||||
|
v-model="toastTitle"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter toast title"
|
||||||
|
class="select-input"
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem title="Position">
|
||||||
|
<select v-model="toastPosition" class="select-input">
|
||||||
|
<option value="top-right">Top Right</option>
|
||||||
|
<option value="top-left">Top Left</option>
|
||||||
|
<option value="top-center">Top Center</option>
|
||||||
|
<option value="bottom-right">Bottom Right</option>
|
||||||
|
<option value="bottom-left">Bottom Left</option>
|
||||||
|
<option value="bottom-center">Bottom Center</option>
|
||||||
|
</select>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem title="Duration (ms)">
|
||||||
|
<input
|
||||||
|
v-model.number="toastDuration"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="500"
|
||||||
|
placeholder="4000"
|
||||||
|
class="select-input"
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem title="Toast Types">
|
||||||
|
<div class="button-group">
|
||||||
|
<button @click="showToast('success')" class="test-button toast-success-btn">
|
||||||
|
Success
|
||||||
|
</button>
|
||||||
|
<button @click="showToast('error')" class="test-button toast-error-btn">
|
||||||
|
Error
|
||||||
|
</button>
|
||||||
|
<button @click="showToast('warning')" class="test-button toast-warning-btn">
|
||||||
|
Warning
|
||||||
|
</button>
|
||||||
|
<button @click="showToast('info')" class="test-button toast-info-btn">
|
||||||
|
Info
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem title="Quick Tests">
|
||||||
|
<div class="button-group">
|
||||||
|
<button @click="showMultipleToasts" class="test-button">
|
||||||
|
Show Multiple Toasts
|
||||||
|
</button>
|
||||||
|
<button @click="clearAllToasts" class="test-button">
|
||||||
|
Clear All Toasts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</SettingItem>
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
<!-- 清除所有测试状态 -->
|
<!-- 清除所有测试状态 -->
|
||||||
<SettingSection title="Cleanup">
|
<SettingSection title="Cleanup">
|
||||||
<SettingItem title="Clear All">
|
<SettingItem title="Clear All">
|
||||||
@@ -91,6 +157,8 @@ import { ref } from 'vue';
|
|||||||
import * as TestService from '@/../bindings/voidraft/internal/services/testservice';
|
import * as TestService from '@/../bindings/voidraft/internal/services/testservice';
|
||||||
import SettingSection from '../components/SettingSection.vue';
|
import SettingSection from '../components/SettingSection.vue';
|
||||||
import SettingItem from '../components/SettingItem.vue';
|
import SettingItem from '../components/SettingItem.vue';
|
||||||
|
import toast from '@/components/toast';
|
||||||
|
import type { ToastPosition, ToastType } from '@/components/toast/types';
|
||||||
|
|
||||||
// Badge测试状态
|
// Badge测试状态
|
||||||
const badgeText = ref('');
|
const badgeText = ref('');
|
||||||
@@ -102,6 +170,12 @@ const notificationSubtitle = ref('');
|
|||||||
const notificationBody = ref('');
|
const notificationBody = ref('');
|
||||||
const notificationStatus = ref<{ type: string; message: string } | null>(null);
|
const notificationStatus = ref<{ type: string; message: string } | null>(null);
|
||||||
|
|
||||||
|
// Toast 测试状态
|
||||||
|
const toastMessage = ref('This is a test toast notification!');
|
||||||
|
const toastTitle = ref('');
|
||||||
|
const toastPosition = ref<ToastPosition>('top-right');
|
||||||
|
const toastDuration = ref(4000);
|
||||||
|
|
||||||
// 清除状态
|
// 清除状态
|
||||||
const clearStatus = ref<{ type: string; message: string } | null>(null);
|
const clearStatus = ref<{ type: string; message: string } | null>(null);
|
||||||
|
|
||||||
@@ -172,13 +246,57 @@ const clearAll = async () => {
|
|||||||
showStatus(clearStatus, 'error', `Failed to clear test states: ${error.message || error}`);
|
showStatus(clearStatus, 'error', `Failed to clear test states: ${error.message || error}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Toast 相关函数
|
||||||
|
const showToast = (type: ToastType) => {
|
||||||
|
const message = toastMessage.value || `This is a ${type} toast notification!`;
|
||||||
|
const title = toastTitle.value || undefined;
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
position: toastPosition.value,
|
||||||
|
duration: toastDuration.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'success':
|
||||||
|
toast.success(message, title, options);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
toast.error(message, title, options);
|
||||||
|
break;
|
||||||
|
case 'warning':
|
||||||
|
toast.warning(message, title, options);
|
||||||
|
break;
|
||||||
|
case 'info':
|
||||||
|
toast.info(message, title, options);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showMultipleToasts = () => {
|
||||||
|
const positions: ToastPosition[] = ['top-right', 'top-left', 'bottom-right', 'bottom-left'];
|
||||||
|
const types: ToastType[] = ['success', 'error', 'warning', 'info'];
|
||||||
|
|
||||||
|
positions.forEach((position, index) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const type = types[index % types.length];
|
||||||
|
toast.show({
|
||||||
|
type,
|
||||||
|
message: `Toast from ${position}`,
|
||||||
|
title: `${type.charAt(0).toUpperCase() + type.slice(1)} Toast`,
|
||||||
|
position,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
}, index * 200);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAllToasts = () => {
|
||||||
|
toast.clear();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.settings-page {
|
|
||||||
//padding: 20px 0 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dev-description {
|
.dev-description {
|
||||||
color: var(--settings-text-secondary);
|
color: var(--settings-text-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -249,6 +367,50 @@ const clearAll = async () => {
|
|||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.toast-success-btn {
|
||||||
|
background-color: #16a34a;
|
||||||
|
color: white;
|
||||||
|
border-color: #16a34a;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #15803d;
|
||||||
|
border-color: #15803d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.toast-error-btn {
|
||||||
|
background-color: #dc2626;
|
||||||
|
color: white;
|
||||||
|
border-color: #dc2626;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #b91c1c;
|
||||||
|
border-color: #b91c1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.toast-warning-btn {
|
||||||
|
background-color: #f59e0b;
|
||||||
|
color: white;
|
||||||
|
border-color: #f59e0b;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #d97706;
|
||||||
|
border-color: #d97706;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.toast-info-btn {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #2563eb;
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.test-status {
|
.test-status {
|
||||||
|
|||||||
Reference in New Issue
Block a user