✨ 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']
|
||||
TabContextMenu: typeof import('./src/components/tabs/TabContextMenu.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']
|
||||
WindowsTitleBar: typeof import('./src/components/titlebar/WindowsTitleBar.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 {useUpdateStore} from '@/stores/updateStore';
|
||||
import WindowTitleBar from '@/components/titlebar/WindowTitleBar.vue';
|
||||
import ToastContainer from '@/components/toast/ToastContainer.vue';
|
||||
import {useTranslationStore} from "@/stores/translationStore";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {LanguageType} from "../bindings/voidraft/internal/models";
|
||||
@@ -41,6 +42,7 @@ onBeforeMount(async () => {
|
||||
<div class="app-content">
|
||||
<router-view/>
|
||||
</div>
|
||||
<ToastContainer/>
|
||||
</div>
|
||||
</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,9 +294,11 @@ const scrollToCurrentLanguage = () => {
|
||||
<span class="arrow" :class="{ 'open': showLanguageMenu }">▲</span>
|
||||
</button>
|
||||
|
||||
<div class="language-menu" v-if="showLanguageMenu">
|
||||
<!-- 搜索框 -->
|
||||
<div class="search-container">
|
||||
<!-- 菜单 -->
|
||||
<Transition name="slide-up">
|
||||
<div class="language-menu" v-if="showLanguageMenu">
|
||||
<!-- 搜索框 -->
|
||||
<div class="search-container">
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
@@ -330,11 +332,23 @@ const scrollToCurrentLanguage = () => {
|
||||
{{ t('toolbar.noLanguageFound') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
position: relative;
|
||||
|
||||
@@ -386,15 +400,17 @@ const scrollToCurrentLanguage = () => {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
margin-bottom: 4px;
|
||||
width: 220px;
|
||||
max-height: 280px;
|
||||
width: 280px;
|
||||
max-height: 400px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.search-container {
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
.search-input {
|
||||
@@ -403,11 +419,11 @@ const scrollToCurrentLanguage = () => {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 2px;
|
||||
padding: 5px 8px 5px 26px;
|
||||
font-size: 11px;
|
||||
padding: 6px 10px 6px 30px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
line-height: 1.2;
|
||||
line-height: 1.4;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--text-muted);
|
||||
@@ -420,7 +436,7 @@ const scrollToCurrentLanguage = () => {
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
left: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
@@ -429,20 +445,21 @@ const scrollToCurrentLanguage = () => {
|
||||
}
|
||||
|
||||
.language-list {
|
||||
max-height: 200px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
|
||||
.language-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--border-color);
|
||||
opacity: 0.8;
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.active {
|
||||
@@ -460,17 +477,17 @@ const scrollToCurrentLanguage = () => {
|
||||
}
|
||||
|
||||
.language-alias {
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 12px 8px;
|
||||
padding: 14px 10px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -478,7 +495,7 @@ const scrollToCurrentLanguage = () => {
|
||||
|
||||
/* 自定义滚动条 */
|
||||
.language-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.language-list::-webkit-scrollbar-track {
|
||||
@@ -487,7 +504,7 @@ const scrollToCurrentLanguage = () => {
|
||||
|
||||
.language-list::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 2px;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--text-muted);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {useConfirm} from '@/composables';
|
||||
import {validateDocumentTitle} from '@/common/utils/validation';
|
||||
import {formatDateTime, truncateString} from '@/common/utils/formatter';
|
||||
import {Document} from '@/../bindings/voidraft/internal/models/ent/models';
|
||||
import toast from '@/components/toast';
|
||||
|
||||
// 类型定义
|
||||
interface DocumentItem extends Document {
|
||||
@@ -96,7 +97,7 @@ const closeMenu = () => {
|
||||
const selectDoc = async (doc: DocumentItem) => {
|
||||
if (doc.id === undefined) return;
|
||||
|
||||
// 如果选择的就是当前文档,直接关闭菜单
|
||||
// 如果选择的就是当前文档,直接关闭菜单
|
||||
if (documentStore.currentDocument?.id === doc.id) {
|
||||
closeMenu();
|
||||
return;
|
||||
@@ -104,7 +105,7 @@ const selectDoc = async (doc: DocumentItem) => {
|
||||
|
||||
const hasOpen = await windowStore.isDocumentWindowOpen(doc.id);
|
||||
if (hasOpen) {
|
||||
documentStore.setError(doc.id, t('toolbar.alreadyOpenInNewWindow'));
|
||||
toast.warning(t('toolbar.alreadyOpenInNewWindow'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -116,7 +117,7 @@ const selectDoc = async (doc: DocumentItem) => {
|
||||
editorStateStore.saveCursorPosition(oldDocId, cursorPos);
|
||||
}
|
||||
|
||||
// 如果旧文档有未保存修改,保存它
|
||||
// 如果旧文档有未保存修改,保存它
|
||||
if (oldDocId && editorStore.hasUnsavedChanges(oldDocId)) {
|
||||
|
||||
const content = editorStore.getCurrentContent();
|
||||
@@ -238,7 +239,7 @@ const handleDelete = async (doc: DocumentItem, event: Event) => {
|
||||
// 确认删除前检查文档是否在其他窗口打开
|
||||
const hasOpen = await windowStore.isDocumentWindowOpen(doc.id);
|
||||
if (hasOpen) {
|
||||
documentStore.setError(doc.id, t('toolbar.alreadyOpenInNewWindow'));
|
||||
toast.warning(t('toolbar.alreadyOpenInNewWindow'));
|
||||
resetDeleteConfirm();
|
||||
return;
|
||||
}
|
||||
@@ -246,7 +247,7 @@ const handleDelete = async (doc: DocumentItem, event: Event) => {
|
||||
const deleteSuccess = await documentStore.deleteDocument(doc.id);
|
||||
if (deleteSuccess) {
|
||||
state.documentList = await documentStore.getDocumentList();
|
||||
// 如果删除的是当前文档,切换到第一个文档
|
||||
// 如果删除的是当前文档,切换到第一个文档
|
||||
if (documentStore.currentDocument?.id === doc.id && state.documentList.length > 0) {
|
||||
const firstDoc = state.documentList[0];
|
||||
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 class="doc-title">{{ item.title }}</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 class="doc-date">{{ formatDateTime(item.updated_at) }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑状态 -->
|
||||
@@ -387,7 +384,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="state.documentList.length > 1 && item.id !== 1"
|
||||
v-if="state.documentList.length > 1"
|
||||
class="action-btn delete-btn"
|
||||
:class="{ 'delete-confirm': isDeleting(item.id!) }"
|
||||
@click="handleDelete(item, $event)"
|
||||
@@ -478,7 +475,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
margin-bottom: 4px;
|
||||
width: 300px;
|
||||
width: 340px;
|
||||
max-height: calc(100vh - 40px);
|
||||
z-index: 1000;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
@@ -488,7 +485,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
|
||||
.input-box {
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
.main-input {
|
||||
@@ -497,8 +494,8 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 2px;
|
||||
padding: 5px 8px 5px 26px;
|
||||
font-size: 11px;
|
||||
padding: 6px 10px 6px 30px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
|
||||
@@ -513,7 +510,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
left: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
@@ -534,7 +531,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.active {
|
||||
&.active {
|
||||
background-color: var(--selection-bg);
|
||||
|
||||
.doc-item-content .doc-info {
|
||||
@@ -542,7 +539,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
color: var(--selection-text);
|
||||
}
|
||||
|
||||
.doc-date, .doc-error {
|
||||
.doc-date {
|
||||
color: var(--selection-text);
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -554,8 +551,8 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 8px;
|
||||
font-size: 11px;
|
||||
padding: 10px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
|
||||
svg {
|
||||
@@ -569,15 +566,15 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 8px;
|
||||
padding: 10px 10px;
|
||||
|
||||
.doc-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.doc-title {
|
||||
font-size: 12px;
|
||||
margin-bottom: 2px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 3px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -585,17 +582,10 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
}
|
||||
|
||||
.doc-date {
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.doc-error {
|
||||
font-size: 10px;
|
||||
color: var(--text-danger);
|
||||
font-weight: 500;
|
||||
animation: fadeInOut 3s forwards;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-edit {
|
||||
@@ -607,8 +597,8 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 2px;
|
||||
padding: 4px 6px;
|
||||
font-size: 11px;
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
|
||||
@@ -620,7 +610,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
|
||||
.doc-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
@@ -629,7 +619,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
padding: 5px;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -650,7 +640,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
color: white;
|
||||
|
||||
.confirm-text {
|
||||
font-size: 9px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
@@ -665,27 +655,12 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px 8px;
|
||||
padding: 18px 10px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInOut {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: 1;
|
||||
}
|
||||
90% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { BackupService } from '@/../bindings/voidraft/internal/services';
|
||||
|
||||
export const useBackupStore = defineStore('backup', () => {
|
||||
const isSyncing = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const sync = async (): Promise<void> => {
|
||||
if (isSyncing.value) {
|
||||
@@ -12,12 +11,11 @@ export const useBackupStore = defineStore('backup', () => {
|
||||
}
|
||||
|
||||
isSyncing.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
await BackupService.Sync();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e);
|
||||
throw e;
|
||||
} finally {
|
||||
isSyncing.value = false;
|
||||
}
|
||||
@@ -25,7 +23,6 @@ export const useBackupStore = defineStore('backup', () => {
|
||||
|
||||
return {
|
||||
isSyncing,
|
||||
error,
|
||||
sync
|
||||
};
|
||||
});
|
||||
@@ -16,33 +16,15 @@ export const useDocumentStore = defineStore('document', () => {
|
||||
|
||||
// === UI状态 ===
|
||||
const showDocumentSelector = ref(false);
|
||||
const selectorError = ref<{ docId: number; message: string } | null>(null);
|
||||
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控制方法 ===
|
||||
const openDocumentSelector = () => {
|
||||
showDocumentSelector.value = true;
|
||||
clearError();
|
||||
};
|
||||
|
||||
const closeDocumentSelector = () => {
|
||||
showDocumentSelector.value = false;
|
||||
clearError();
|
||||
};
|
||||
|
||||
|
||||
@@ -217,7 +199,6 @@ export const useDocumentStore = defineStore('document', () => {
|
||||
currentDocumentId,
|
||||
currentDocument,
|
||||
showDocumentSelector,
|
||||
selectorError,
|
||||
isLoading,
|
||||
|
||||
getDocumentList,
|
||||
@@ -236,8 +217,6 @@ export const useDocumentStore = defineStore('document', () => {
|
||||
// UI 控制
|
||||
openDocumentSelector,
|
||||
closeDocumentSelector,
|
||||
setError,
|
||||
clearError,
|
||||
|
||||
// 初始化
|
||||
initDocument,
|
||||
|
||||
@@ -2,48 +2,18 @@
|
||||
import {useConfigStore} from '@/stores/configStore';
|
||||
import {useBackupStore} from '@/stores/backupStore';
|
||||
import {useI18n} from 'vue-i18n';
|
||||
import {computed, ref, watch, onUnmounted} from 'vue';
|
||||
import {computed} from 'vue';
|
||||
import SettingSection from '../components/SettingSection.vue';
|
||||
import SettingItem from '../components/SettingItem.vue';
|
||||
import ToggleSwitch from '../components/ToggleSwitch.vue';
|
||||
import {AuthMethod} from '@/../bindings/voidraft/internal/models/models';
|
||||
import {DialogService} from '@/../bindings/voidraft/internal/services';
|
||||
import toast from '@/components/toast';
|
||||
|
||||
const {t} = useI18n();
|
||||
const configStore = useConfigStore();
|
||||
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(() => [
|
||||
{value: AuthMethod.Token, label: t('settings.backup.authMethods.token')},
|
||||
{value: AuthMethod.SSHKey, label: t('settings.backup.authMethods.sshKey')},
|
||||
@@ -64,6 +34,15 @@ const selectSshKeyFile = async () => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -202,14 +181,10 @@ const selectSshKeyFile = async () => {
|
||||
|
||||
<!-- 备份操作 -->
|
||||
<SettingSection :title="t('settings.backup.backupOperations')">
|
||||
<SettingItem
|
||||
:title="t('settings.backup.syncToRemote')"
|
||||
:description="message || undefined"
|
||||
:descriptionType="message ? (isError ? 'error' : 'success') : 'default'"
|
||||
>
|
||||
<SettingItem :title="t('settings.backup.syncToRemote')">
|
||||
<button
|
||||
class="sync-button"
|
||||
@click="backupStore.sync"
|
||||
@click="handleSync"
|
||||
:disabled="!configStore.config.backup.enabled || !configStore.config.backup.repo_url || backupStore.isSyncing"
|
||||
:class="{ 'syncing': backupStore.isSyncing }"
|
||||
>
|
||||
@@ -222,10 +197,6 @@ const selectSshKeyFile = async () => {
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-page {
|
||||
//max-width: 800px;
|
||||
}
|
||||
|
||||
// 统一的输入控件样式
|
||||
.repo-url-input,
|
||||
.branch-input,
|
||||
|
||||
@@ -9,6 +9,7 @@ import ToggleSwitch from '../components/ToggleSwitch.vue';
|
||||
import {DialogService, HotkeyService, MigrationService} from '@/../bindings/voidraft/internal/services';
|
||||
import {useSystemStore} from "@/stores/systemStore";
|
||||
import {useConfirm, usePolling} from '@/composables';
|
||||
import toast from '@/components/toast';
|
||||
|
||||
const {t} = useI18n();
|
||||
const {
|
||||
@@ -29,7 +30,6 @@ const tabStore = useTabStore();
|
||||
|
||||
// 进度条显示控制
|
||||
const showBar = ref(false);
|
||||
const manualError = ref(''); // 用于捕获 MigrateDirectory 抛出的错误
|
||||
let hideTimer = 0;
|
||||
|
||||
// 轮询迁移进度
|
||||
@@ -39,15 +39,20 @@ const {data: progress, error: pollError, isActive: migrating, start, stop, reset
|
||||
interval: 300,
|
||||
shouldStop: ({progress, error}) => !!error || progress >= 100,
|
||||
onStop: () => {
|
||||
const hasError = pollError.value || progress.value?.error;
|
||||
hideTimer = window.setTimeout(hideAll, hasError ? 5000 : 3000);
|
||||
const error = pollError.value || progress.value?.error;
|
||||
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 migrationError = computed(() => pollError.value || progress.value?.error || '');
|
||||
|
||||
const barClass = computed(() => {
|
||||
if (!showBar.value) return '';
|
||||
@@ -64,8 +69,7 @@ const hideAll = () => {
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = 0;
|
||||
showBar.value = false;
|
||||
manualError.value = '';
|
||||
reset(); // 清除轮询状态
|
||||
reset();
|
||||
};
|
||||
|
||||
// 重置设置确认
|
||||
@@ -193,10 +197,8 @@ const selectDataDirectory = async () => {
|
||||
|
||||
const [oldPath, newPath] = [currentDataPath.value, selectedPath.trim()];
|
||||
|
||||
// 清除之前的状态并开始轮询
|
||||
hideAll();
|
||||
showBar.value = true;
|
||||
manualError.value = '';
|
||||
start();
|
||||
|
||||
try {
|
||||
@@ -204,10 +206,9 @@ const selectDataDirectory = async () => {
|
||||
await setDataPath(newPath);
|
||||
} catch (e) {
|
||||
stop();
|
||||
// 设置手动捕获的错误(当轮询还没获取到错误时)
|
||||
manualError.value = String(e).replace(/^Error:\s*/i, '') || 'Migration failed';
|
||||
toast.error(String(e).replace(/^Error:\s*/i, '') || 'Migration failed');
|
||||
showBar.value = true;
|
||||
hideTimer = window.setTimeout(hideAll, 5000);
|
||||
hideTimer = window.setTimeout(hideAll, 3000);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -300,11 +301,6 @@ const selectDataDirectory = async () => {
|
||||
<!-- 进度条 -->
|
||||
<div class="progress-bar" :class="[{'active': showBar}, barClass]" :style="{width: barWidth}"/>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<Transition name="error-fade">
|
||||
<div v-if="migrationError" class="progress-error">{{ migrationError }}</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</SettingSection>
|
||||
@@ -537,13 +533,6 @@ const selectDataDirectory = async () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.progress-error {
|
||||
font-size: 12px;
|
||||
color: #ef4444;
|
||||
opacity: 1;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.reset-button {
|
||||
@@ -602,35 +591,4 @@ const selectDataDirectory = async () => {
|
||||
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>
|
||||
@@ -72,6 +72,72 @@
|
||||
</div>
|
||||
</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">
|
||||
<SettingItem title="Clear All">
|
||||
@@ -91,6 +157,8 @@ import { ref } from 'vue';
|
||||
import * as TestService from '@/../bindings/voidraft/internal/services/testservice';
|
||||
import SettingSection from '../components/SettingSection.vue';
|
||||
import SettingItem from '../components/SettingItem.vue';
|
||||
import toast from '@/components/toast';
|
||||
import type { ToastPosition, ToastType } from '@/components/toast/types';
|
||||
|
||||
// Badge测试状态
|
||||
const badgeText = ref('');
|
||||
@@ -102,6 +170,12 @@ const notificationSubtitle = ref('');
|
||||
const notificationBody = ref('');
|
||||
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);
|
||||
|
||||
@@ -172,13 +246,57 @@ const clearAll = async () => {
|
||||
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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-page {
|
||||
//padding: 20px 0 20px 0;
|
||||
}
|
||||
|
||||
.dev-description {
|
||||
color: var(--settings-text-secondary);
|
||||
font-size: 12px;
|
||||
@@ -249,6 +367,50 @@ const clearAll = async () => {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user