Added toast notification function and optimized related component styles

This commit is contained in:
2026-01-02 01:27:51 +08:00
parent 009274e4ad
commit 533f732c53
14 changed files with 909 additions and 199 deletions

View File

@@ -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']

View File

@@ -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>

View 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>

View 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>

View 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;

View 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,
};
});

View 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;
}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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
};
});

View File

@@ -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,

View File

@@ -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,

View File

@@ -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>

View File

@@ -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 {