Improved settings

This commit is contained in:
2025-06-07 21:12:07 +08:00
parent 31addd5a20
commit 61f293ce6f
18 changed files with 1480 additions and 1098 deletions

View File

@@ -5,42 +5,6 @@
// @ts-ignore: Unused imports
import {Create as $Create} from "@wailsio/runtime";
/**
* A Duration represents the elapsed time between two instants
* as an int64 nanosecond count. The representation limits the
* largest representable duration to approximately 290 years.
*/
export enum Duration {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = 0,
minDuration = -9223372036854775808,
maxDuration = 9223372036854775807,
/**
* Common durations. There is no definition for units of Day or larger
* to avoid confusion across daylight savings time zone transitions.
*
* To count the number of units in a [Duration], divide:
*
* second := time.Second
* fmt.Print(int64(second/time.Millisecond)) // prints 1000
*
* To convert an integer number of units to a Duration, multiply:
*
* seconds := 10
* fmt.Print(time.Duration(seconds)*time.Second) // prints 10s
*/
Nanosecond = 1,
Microsecond = 1000,
Millisecond = 1000000,
Second = 1000000000,
Minute = 60000000000,
Hour = 3600000000000,
};
/**
* A Time represents an instant in time with nanosecond precision.
*

View File

@@ -35,7 +35,7 @@ export function GetConfig(): Promise<models$0.AppConfig | null> & { cancel(): vo
}
/**
* ResetConfig 置为默认配置
* ResetConfig 强制重置所有配置为默认
*/
export function ResetConfig(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3593047389) as any;

View File

@@ -51,10 +51,10 @@ export function ServiceShutdown(): Promise<void> & { cancel(): void } {
}
/**
* SetProgressCallback 设置进度回调
* SetProgressBroadcaster 设置进度广播函数
*/
export function SetProgressCallback(callback: $models.MigrationProgressCallback): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(75752256, callback) as any;
export function SetProgressBroadcaster(broadcaster: any): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3244071921, broadcaster) as any;
return $resultPromise;
}

View File

@@ -5,10 +5,6 @@
// @ts-ignore: Unused imports
import {Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as time$0 from "../../../time/models.js";
/**
* MemoryStats 内存统计信息
*/
@@ -85,88 +81,24 @@ export class MigrationProgress {
*/
"status": MigrationStatus;
/**
* 当前处理的文件
*/
"currentFile": string;
/**
* 已处理文件数
*/
"processedFiles": number;
/**
* 总文件数
*/
"totalFiles": number;
/**
* 已处理字节数
*/
"processedBytes": number;
/**
* 总字节数
*/
"totalBytes": number;
/**
* 进度百分比 (0-100)
*/
"progress": number;
/**
* 状态消息
*/
"message": string;
/**
* 错误信息
*/
"error"?: string;
/**
* 开始时间
*/
"startTime": time$0.Time;
/**
* 估计剩余时间
*/
"estimatedTime": time$0.Duration;
/** Creates a new MigrationProgress instance. */
constructor($$source: Partial<MigrationProgress> = {}) {
if (!("status" in $$source)) {
this["status"] = ("" as MigrationStatus);
}
if (!("currentFile" in $$source)) {
this["currentFile"] = "";
}
if (!("processedFiles" in $$source)) {
this["processedFiles"] = 0;
}
if (!("totalFiles" in $$source)) {
this["totalFiles"] = 0;
}
if (!("processedBytes" in $$source)) {
this["processedBytes"] = 0;
}
if (!("totalBytes" in $$source)) {
this["totalBytes"] = 0;
}
if (!("progress" in $$source)) {
this["progress"] = 0;
}
if (!("message" in $$source)) {
this["message"] = "";
}
if (!("startTime" in $$source)) {
this["startTime"] = null;
}
if (!("estimatedTime" in $$source)) {
this["estimatedTime"] = (0 as time$0.Duration);
}
Object.assign(this, $$source);
}
@@ -180,11 +112,6 @@ export class MigrationProgress {
}
}
/**
* MigrationProgressCallback 进度回调函数类型
*/
export type MigrationProgressCallback = any;
/**
* MigrationStatus 迁移状态
*/
@@ -194,16 +121,6 @@ export enum MigrationStatus {
*/
$zero = "",
/**
* 空闲状态
*/
MigrationStatusIdle = "idle",
/**
* 准备中
*/
MigrationStatusPreparing = "preparing",
/**
* 迁移中
*/
@@ -218,9 +135,4 @@ export enum MigrationStatus {
* 失败
*/
MigrationStatusFailed = "failed",
/**
* 取消
*/
MigrationStatusCancelled = "cancelled",
};

View File

@@ -9,7 +9,6 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
MemoryMonitor: typeof import('./src/components/monitor/MemoryMonitor.vue')['default']
MigrationProgress: typeof import('./src/components/migration/MigrationProgress.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Toolbar: typeof import('./src/components/toolbar/Toolbar.vue')['default']

View File

@@ -1,540 +0,0 @@
<template>
<div v-if="visible" class="migration-overlay">
<div class="migration-modal">
<div class="migration-header">
<h3>{{ t('migration.title') }}</h3>
<div class="migration-status" :class="status">
{{ getStatusText() }}
</div>
</div>
<div class="migration-content">
<div class="progress-container">
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: `${progress.progress || 0}%` }"
:class="status"
></div>
</div>
<div class="progress-text">
{{ Math.round(progress.progress || 0) }}%
</div>
</div>
<div class="migration-details">
<div v-if="progress.message" class="message">
{{ progress.message }}
</div>
<div v-if="progress.currentFile" class="current-file">
<span class="label">{{ t('migration.currentFile') }}:</span>
<span class="file-name">{{ progress.currentFile }}</span>
</div>
<div class="stats" v-if="progress.totalFiles > 0">
<div class="stat-item">
<span class="label">{{ t('migration.files') }}:</span>
<span class="value">{{ progress.processedFiles }} / {{ progress.totalFiles }}</span>
</div>
<div class="stat-item" v-if="progress.totalBytes > 0">
<span class="label">{{ t('migration.size') }}:</span>
<span class="value">{{ formatBytes(progress.processedBytes) }} / {{ formatBytes(progress.totalBytes) }}</span>
</div>
<div class="stat-item" v-if="progress.estimatedTime && status === 'migrating'">
<span class="label">{{ t('migration.timeRemaining') }}:</span>
<span class="value">{{ formatDuration(progress.estimatedTime) }}</span>
</div>
</div>
<div v-if="progress.error" class="error-message">
<span class="error-icon"></span>
{{ progress.error }}
</div>
</div>
</div>
<div class="migration-actions">
<button
v-if="status === 'completed'"
@click="handleComplete"
class="action-button success"
>
{{ t('migration.complete') }}
</button>
<button
v-if="status === 'failed'"
@click="handleRetry"
class="action-button retry"
>
{{ t('migration.retry') }}
</button>
<button
v-if="status === 'failed'"
@click="handleClose"
class="action-button secondary"
>
{{ t('migration.close') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { MigrationService } from '../../../bindings/voidraft/internal/services';
interface MigrationProgress {
status: string;
currentFile: string;
processedFiles: number;
totalFiles: number;
processedBytes: number;
totalBytes: number;
progress: number;
message: string;
error?: string;
startTime: string;
estimatedTime: number;
}
interface Props {
visible: boolean;
}
interface Emits {
(e: 'complete'): void;
(e: 'close'): void;
(e: 'retry'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const { t } = useI18n();
// 迁移进度状态
const progress = ref<MigrationProgress>({
status: 'idle',
currentFile: '',
processedFiles: 0,
totalFiles: 0,
processedBytes: 0,
totalBytes: 0,
progress: 0,
message: '',
startTime: '',
estimatedTime: 0
});
const status = computed(() => progress.value.status);
// 定时器用于轮询进度
let progressTimer: number | null = null;
// 轮询迁移进度
const pollProgress = async () => {
try {
const currentProgress = await MigrationService.GetProgress();
progress.value = currentProgress;
// 如果迁移完成或失败,停止轮询
if (currentProgress.status === 'completed' || currentProgress.status === 'failed' || currentProgress.status === 'cancelled') {
stopPolling();
}
} catch (error) {
console.error('Failed to get migration progress:', error);
}
};
// 开始轮询
const startPolling = () => {
if (progressTimer) return;
// 立即获取一次进度
pollProgress();
// 每500ms轮询一次
progressTimer = window.setInterval(pollProgress, 500);
};
// 停止轮询
const stopPolling = () => {
if (progressTimer) {
clearInterval(progressTimer);
progressTimer = null;
}
};
// 监听可见性变化
watch(() => props.visible, (visible) => {
if (visible) {
startPolling();
} else {
stopPolling();
}
});
// 组件挂载时开始轮询(如果可见)
onMounted(() => {
if (props.visible) {
startPolling();
}
});
// 组件卸载时停止轮询
onUnmounted(() => {
stopPolling();
});
// 获取状态文本
const getStatusText = () => {
switch (status.value) {
case 'preparing':
return t('migration.preparing');
case 'migrating':
return t('migration.migrating');
case 'completed':
return t('migration.completed');
case 'failed':
return t('migration.failed');
case 'cancelled':
return t('migration.cancelled');
default:
return t('migration.idle');
}
};
// 格式化字节数
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
};
// 格式化持续时间
const formatDuration = (nanoseconds: number): string => {
const seconds = Math.floor(nanoseconds / 1000000000);
if (seconds < 60) {
return `${seconds}`;
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
return `${minutes}分钟`;
} else {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}小时${minutes}分钟`;
}
};
// 处理完成
const handleComplete = () => {
emit('complete');
};
// 处理重试
const handleRetry = () => {
emit('retry');
};
// 处理关闭
const handleClose = () => {
emit('close');
};
</script>
<style scoped lang="scss">
.migration-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
backdrop-filter: blur(4px);
}
.migration-modal {
background-color: #2a2a2a;
border-radius: 8px;
border: 1px solid #444444;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow: hidden;
animation: modalSlideIn 0.3s ease-out;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.migration-header {
padding: 20px 24px 16px;
border-bottom: 1px solid #444444;
h3 {
margin: 0 0 8px 0;
color: #e0e0e0;
font-size: 18px;
font-weight: 600;
}
.migration-status {
font-size: 14px;
font-weight: 500;
padding: 4px 8px;
border-radius: 4px;
display: inline-block;
&.preparing {
color: #ffc107;
background-color: rgba(255, 193, 7, 0.1);
}
&.migrating {
color: #17a2b8;
background-color: rgba(23, 162, 184, 0.1);
}
&.completed {
color: #28a745;
background-color: rgba(40, 167, 69, 0.1);
}
&.failed {
color: #dc3545;
background-color: rgba(220, 53, 69, 0.1);
}
&.cancelled {
color: #6c757d;
background-color: rgba(108, 117, 125, 0.1);
}
}
}
.migration-content {
padding: 20px 24px;
max-height: 60vh;
overflow-y: auto;
}
.progress-container {
margin-bottom: 20px;
.progress-bar {
width: 100%;
height: 8px;
background-color: #444444;
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
.progress-fill {
height: 100%;
transition: width 0.3s ease;
border-radius: 4px;
&.preparing {
background-color: #ffc107;
}
&.migrating {
background-color: #17a2b8;
background-image: linear-gradient(
45deg,
rgba(255, 255, 255, 0.1) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0.1) 75%,
transparent 75%,
transparent
);
background-size: 20px 20px;
animation: progressStripes 1s linear infinite;
}
&.completed {
background-color: #28a745;
}
&.failed {
background-color: #dc3545;
}
}
}
.progress-text {
text-align: center;
color: #b0b0b0;
font-size: 14px;
font-weight: 500;
}
}
@keyframes progressStripes {
0% {
background-position: 0 0;
}
100% {
background-position: 20px 0;
}
}
.migration-details {
.message {
color: #e0e0e0;
font-size: 14px;
margin-bottom: 12px;
padding: 8px 12px;
background-color: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
.current-file {
margin-bottom: 12px;
font-size: 13px;
.label {
color: #888888;
}
.file-name {
color: #e0e0e0;
font-family: 'Consolas', 'Courier New', monospace;
word-break: break-all;
}
}
.stats {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
.stat-item {
display: flex;
justify-content: space-between;
font-size: 13px;
.label {
color: #888888;
}
.value {
color: #e0e0e0;
font-weight: 500;
}
}
}
.error-message {
margin-top: 16px;
padding: 12px;
background-color: rgba(220, 53, 69, 0.1);
border: 1px solid rgba(220, 53, 69, 0.3);
border-radius: 4px;
color: #ff6b6b;
font-size: 13px;
display: flex;
align-items: flex-start;
gap: 8px;
.error-icon {
flex-shrink: 0;
}
}
}
.migration-actions {
padding: 16px 24px 20px;
border-top: 1px solid #444444;
display: flex;
justify-content: flex-end;
gap: 12px;
.action-button {
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&.success {
background-color: #28a745;
color: white;
&:hover {
background-color: #218838;
}
}
&.retry {
background-color: #17a2b8;
color: white;
&:hover {
background-color: #138496;
}
}
&.secondary {
background-color: #6c757d;
color: white;
&:hover {
background-color: #5a6268;
}
}
&:active {
transform: translateY(1px);
}
}
}
/* 自定义滚动条 */
.migration-content::-webkit-scrollbar {
width: 6px;
}
.migration-content::-webkit-scrollbar-track {
background: #333333;
border-radius: 3px;
}
.migration-content::-webkit-scrollbar-thumb {
background: #666666;
border-radius: 3px;
&:hover {
background: #777777;
}
}
</style>

View File

@@ -0,0 +1,423 @@
import { ref, onUnmounted, reactive, computed, watch, nextTick } from 'vue';
// 基础WebSocket消息接口
interface WebSocketMessage<T = any> {
type: string;
data: T;
}
// 迁移进度接口(与后端保持一致)
interface MigrationProgress {
status: 'migrating' | 'completed' | 'failed';
progress: number; // 0-100
error?: string;
}
// 连接状态枚举
enum ConnectionState {
DISCONNECTED = 'disconnected',
CONNECTING = 'connecting',
CONNECTED = 'connected',
RECONNECTING = 'reconnecting',
ERROR = 'error'
}
// WebSocket配置选项
interface WebSocketOptions {
url?: string;
reconnectInterval?: number;
maxReconnectAttempts?: number;
debug?: boolean;
autoConnect?: boolean;
protocols?: string | string[];
heartbeat?: {
enabled: boolean;
interval: number;
message: string;
};
}
// 消息处理器类型
type MessageHandler<T = any> = (data: T) => void;
// 事件处理器映射
interface EventHandlers {
[messageType: string]: MessageHandler[];
}
// 连接事件类型
type ConnectionEventType = 'connect' | 'disconnect' | 'error' | 'reconnect';
type ConnectionEventHandler = (event?: Event | CloseEvent | ErrorEvent) => void;
export function useWebSocket(options: WebSocketOptions = {}) {
const {
url = 'ws://localhost:8899/ws/migration',
reconnectInterval = 3000,
maxReconnectAttempts = 10,
debug = false,
autoConnect = true,
protocols,
heartbeat = { enabled: false, interval: 30000, message: 'ping' }
} = options;
// === 状态管理 ===
const connectionState = ref<ConnectionState>(ConnectionState.DISCONNECTED);
const connectionError = ref<string | null>(null);
const reconnectAttempts = ref(0);
const lastMessage = ref<WebSocketMessage | null>(null);
const messageHistory = ref<WebSocketMessage[]>([]);
// 迁移进度状态(保持向后兼容)
const migrationProgress = reactive<MigrationProgress>({
status: 'completed',
progress: 0
});
// === 计算属性 ===
const isConnected = computed(() => connectionState.value === ConnectionState.CONNECTED);
const isConnecting = computed(() =>
connectionState.value === ConnectionState.CONNECTING ||
connectionState.value === ConnectionState.RECONNECTING
);
const canReconnect = computed(() => reconnectAttempts.value < maxReconnectAttempts);
// === 内部状态 ===
let ws: WebSocket | null = null;
let reconnectTimer: number | null = null;
let heartbeatTimer: number | null = null;
let isManualDisconnect = false;
// 事件处理器
const eventHandlers: EventHandlers = {};
const connectionEventHandlers: Map<ConnectionEventType, ConnectionEventHandler[]> = new Map();
// === 工具函数 ===
const log = (level: 'info' | 'warn' | 'error', message: string, ...args: any[]) => {
if (debug) {
console[level](`[WebSocket] ${message}`, ...args);
}
};
const clearTimers = () => {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
};
const updateConnectionState = (newState: ConnectionState, error?: string) => {
connectionState.value = newState;
connectionError.value = error || null;
log('info', `Connection state changed to: ${newState}`, error);
};
// === 事件系统 ===
const on = <T = any>(messageType: string, handler: MessageHandler<T>) => {
if (!eventHandlers[messageType]) {
eventHandlers[messageType] = [];
}
eventHandlers[messageType].push(handler as MessageHandler);
// 返回取消订阅函数
return () => off(messageType, handler);
};
const off = <T = any>(messageType: string, handler: MessageHandler<T>) => {
if (eventHandlers[messageType]) {
const index = eventHandlers[messageType].indexOf(handler as MessageHandler);
if (index > -1) {
eventHandlers[messageType].splice(index, 1);
}
}
};
const onConnection = (eventType: ConnectionEventType, handler: ConnectionEventHandler) => {
if (!connectionEventHandlers.has(eventType)) {
connectionEventHandlers.set(eventType, []);
}
connectionEventHandlers.get(eventType)!.push(handler);
return () => offConnection(eventType, handler);
};
const offConnection = (eventType: ConnectionEventType, handler: ConnectionEventHandler) => {
const handlers = connectionEventHandlers.get(eventType);
if (handlers) {
const index = handlers.indexOf(handler);
if (index > -1) {
handlers.splice(index, 1);
}
}
};
const emit = (eventType: ConnectionEventType, event?: Event | CloseEvent | ErrorEvent) => {
const handlers = connectionEventHandlers.get(eventType);
if (handlers) {
handlers.forEach(handler => {
try {
handler(event);
} catch (error) {
log('error', `Error in ${eventType} event handler:`, error);
}
});
}
};
// === 消息处理 ===
const handleMessage = (event: MessageEvent) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
log('info', 'Received message:', message);
// 更新消息历史
lastMessage.value = message;
messageHistory.value.push(message);
// 限制历史记录长度
if (messageHistory.value.length > 100) {
messageHistory.value.shift();
}
// 特殊处理迁移进度消息(保持向后兼容)
if (message.type === 'migration_progress') {
Object.assign(migrationProgress, message.data);
}
// 触发注册的处理器
const handlers = eventHandlers[message.type];
if (handlers) {
handlers.forEach(handler => {
try {
handler(message.data);
} catch (error) {
log('error', `Error in message handler for ${message.type}:`, error);
}
});
}
} catch (error) {
log('error', 'Failed to parse message:', error, event.data);
}
};
// === 心跳机制 ===
const startHeartbeat = () => {
if (!heartbeat.enabled || heartbeatTimer) return;
heartbeatTimer = window.setInterval(() => {
if (isConnected.value) {
send(heartbeat.message);
}
}, heartbeat.interval);
};
const stopHeartbeat = () => {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
};
// === 连接管理 ===
const connect = async (): Promise<void> => {
if (isConnecting.value || isConnected.value) {
log('warn', 'Already connecting or connected');
return;
}
updateConnectionState(ConnectionState.CONNECTING);
isManualDisconnect = false;
try {
log('info', 'Connecting to:', url);
ws = new WebSocket(url, protocols);
// 连接超时处理
const connectTimeout = setTimeout(() => {
if (ws && ws.readyState === WebSocket.CONNECTING) {
ws.close();
updateConnectionState(ConnectionState.ERROR, 'Connection timeout');
}
}, 10000);
ws.onopen = () => {
clearTimeout(connectTimeout);
log('info', 'Connected successfully');
updateConnectionState(ConnectionState.CONNECTED);
reconnectAttempts.value = 0;
clearTimers();
startHeartbeat();
emit('connect');
};
ws.onmessage = handleMessage;
ws.onclose = (event) => {
clearTimeout(connectTimeout);
stopHeartbeat();
log('info', 'Connection closed:', event.code, event.reason);
const wasConnected = connectionState.value === ConnectionState.CONNECTED;
updateConnectionState(ConnectionState.DISCONNECTED);
ws = null;
emit('disconnect', event);
// 自动重连逻辑
if (!isManualDisconnect && event.code !== 1000 && canReconnect.value) {
scheduleReconnect();
} else if (reconnectAttempts.value >= maxReconnectAttempts) {
updateConnectionState(ConnectionState.ERROR, 'Max reconnection attempts reached');
}
};
ws.onerror = (event) => {
clearTimeout(connectTimeout);
log('error', 'Connection error:', event);
updateConnectionState(ConnectionState.ERROR, 'WebSocket connection error');
emit('error', event);
};
} catch (error) {
log('error', 'Failed to create WebSocket:', error);
updateConnectionState(ConnectionState.ERROR, 'Failed to create WebSocket connection');
}
};
const disconnect = (code: number = 1000, reason: string = 'Manual disconnect') => {
isManualDisconnect = true;
clearTimers();
stopHeartbeat();
if (ws) {
log('info', 'Disconnecting manually');
ws.close(code, reason);
}
updateConnectionState(ConnectionState.DISCONNECTED);
};
const scheduleReconnect = () => {
if (!canReconnect.value || isManualDisconnect) return;
clearTimers();
reconnectAttempts.value++;
updateConnectionState(ConnectionState.RECONNECTING,
`Reconnecting... (${reconnectAttempts.value}/${maxReconnectAttempts})`);
log('info', `Scheduling reconnect attempt ${reconnectAttempts.value}/${maxReconnectAttempts} in ${reconnectInterval}ms`);
reconnectTimer = window.setTimeout(() => {
connect();
}, reconnectInterval);
emit('reconnect');
};
const reconnect = () => {
disconnect();
reconnectAttempts.value = 0;
nextTick(() => {
connect();
});
};
// === 消息发送 ===
const send = (message: any): boolean => {
if (!isConnected.value || !ws) {
log('warn', 'Cannot send message: not connected');
return false;
}
try {
const data = typeof message === 'string' ? message : JSON.stringify(message);
ws.send(data);
log('info', 'Sent message:', data);
return true;
} catch (error) {
log('error', 'Failed to send message:', error);
return false;
}
};
const sendMessage = <T = any>(type: string, data?: T): boolean => {
return send({ type, data });
};
// === 状态查询 ===
const getConnectionInfo = () => ({
state: connectionState.value,
error: connectionError.value,
reconnectAttempts: reconnectAttempts.value,
maxReconnectAttempts,
canReconnect: canReconnect.value,
url,
readyState: ws?.readyState,
protocol: ws?.protocol,
extensions: ws?.extensions
});
// === 初始化 ===
if (autoConnect) {
nextTick(() => {
connect();
});
}
// === 清理 ===
onUnmounted(() => {
disconnect();
});
// 监听连接状态变化,用于调试
if (debug) {
watch(connectionState, (newState, oldState) => {
log('info', `State transition: ${oldState} -> ${newState}`);
});
}
return {
// === 状态(只读) ===
connectionState: computed(() => connectionState.value),
isConnected,
isConnecting,
connectionError: computed(() => connectionError.value),
reconnectAttempts: computed(() => reconnectAttempts.value),
canReconnect,
lastMessage: computed(() => lastMessage.value),
messageHistory: computed(() => messageHistory.value),
// === 向后兼容的状态 ===
migrationProgress,
// === 连接控制 ===
connect,
disconnect,
reconnect,
// === 消息发送 ===
send,
sendMessage,
// === 事件系统 ===
on,
off,
onConnection,
offConnection,
// === 工具方法 ===
getConnectionInfo,
clearHistory: () => {
messageHistory.value = [];
lastMessage.value = null;
}
};
}
// 导出类型
export type { WebSocketMessage, MigrationProgress, WebSocketOptions, MessageHandler, ConnectionEventType };
export { ConnectionState };

View File

@@ -46,36 +46,12 @@ export default {
saveSuccess: 'Save settings updated',
saveFailed: 'Failed to update save settings'
},
migration: {
inProgress: 'Migrating data to new path...',
success: 'Data migration completed',
failed: 'Data migration failed'
}
},
migration: {
title: 'Data Migration',
preparing: 'Preparing',
started: 'Starting data migration',
migrating: 'Migrating',
completed: 'Completed',
failed: 'Failed',
cancelled: 'Cancelled',
idle: 'Idle',
currentFile: 'Current File',
files: 'Files',
size: 'Size',
timeRemaining: 'Time Remaining',
complete: 'Complete',
retry: 'Retry',
close: 'Close',
migrationInProgress: 'Migrating data to new path...',
migrationCompleted: 'Data migration completed',
migrationFailed: 'Data migration failed',
recursiveCopyError: 'Target path cannot be a subdirectory of source path, this would cause infinite recursive copying',
targetNotDirectory: 'Target path exists but is not a directory',
targetNotEmpty: 'Target directory is not empty, cannot migrate',
compressing: 'Compressing source directory...',
extracting: 'Extracting to target location...',
cleaning: 'Cleaning up source directory...'
completed: 'Migration Completed',
failed: 'Migration Failed'
},
settings: {
title: 'Settings',
@@ -89,10 +65,11 @@ export default {
comingSoon: 'Coming Soon...',
save: 'Save',
reset: 'Reset',
cancel: 'Cancel',
dangerZone: 'Danger Zone',
resetAllSettings: 'Reset All Settings',
resetDescription: 'This will restore all settings to their default values. This action cannot be undone.',
confirmReset: 'Are you sure you want to reset all settings? This action cannot be undone.',
confirmReset: 'Click again to confirm reset',
globalHotkey: 'Global Keyboard Shortcuts',
enableGlobalHotkey: 'Enable Global Hotkeys',
window: 'Window/Application',

View File

@@ -46,36 +46,12 @@ export default {
saveSuccess: '保存设置已更新',
saveFailed: '保存设置更新失败'
},
migration: {
inProgress: '正在迁移数据到新路径...',
success: '数据迁移完成',
failed: '数据迁移失败'
}
},
migration: {
title: '数据迁移',
preparing: '准备中',
migrating: '迁移',
completed: '已完成',
failed: '失败',
cancelled: '已取消',
idle: '空闲',
currentFile: '当前文件',
files: '文件',
size: '大小',
timeRemaining: '剩余时间',
complete: '完成',
retry: '重试',
close: '关闭',
migrationInProgress: '正在迁移数据到新路径...',
migrationCompleted: '数据迁移完成',
migrationFailed: '数据迁移失败',
recursiveCopyError: '目标路径不能是源路径的子目录,这会导致无限递归复制',
targetNotDirectory: '目标路径存在但不是目录',
targetNotEmpty: '目标目录不为空,无法迁移',
compressing: '正在压缩源目录...',
extracting: '正在解压到目标位置...',
cleaning: '正在清理源目录...'
started: '开始迁移数据',
migrating: '迁移中',
completed: '迁移已完成',
failed: '迁移失败'
},
settings: {
title: '设置',
@@ -89,10 +65,11 @@ export default {
comingSoon: '即将推出...',
save: '保存',
reset: '重置',
cancel: '取消',
dangerZone: '危险操作',
resetAllSettings: '重置所有设置',
resetDescription: '这将恢复所有设置为默认值,此操作无法撤销',
confirmReset: '确定要重置所有设置吗?此操作无法撤销。',
confirmReset: '再次点击确认重置',
globalHotkey: '全局键盘快捷键',
enableGlobalHotkey: '启用全局热键',
window: '窗口/应用程序',

View File

@@ -270,8 +270,16 @@ export const useConfigStore = defineStore('config', () => {
state.isLoading = true;
try {
// 调用后端重置配置
await safeCall(() => ConfigService.ResetConfig(), 'config.resetFailed', 'config.resetSuccess');
await safeCall(() => initConfig(), 'config.loadFailed', 'config.loadSuccess');
// 立即重新加载后端配置以确保前端状态同步
await safeCall(async () => {
const appConfig = await ConfigService.GetConfig();
if (appConfig) {
state.config = JSON.parse(JSON.stringify(appConfig)) as AppConfig;
}
}, 'config.loadFailed', 'config.loadSuccess');
} finally {
state.isLoading = false;
}

View File

@@ -1,21 +1,164 @@
<script setup lang="ts">
import { useConfigStore } from '@/stores/configStore';
import { useI18n } from 'vue-i18n';
import { computed, ref, onMounted } from 'vue';
import {useConfigStore} from '@/stores/configStore';
import {useI18n} from 'vue-i18n';
import {computed, onUnmounted, ref, watch} from 'vue';
import SettingSection from '../components/SettingSection.vue';
import SettingItem from '../components/SettingItem.vue';
import ToggleSwitch from '../components/ToggleSwitch.vue';
import MigrationProgress from '@/components/migration/MigrationProgress.vue';
import { useErrorHandler } from '@/utils/errorHandler';
import { DialogService, MigrationService } from '@/../bindings/voidraft/internal/services';
import {useErrorHandler} from '@/utils/errorHandler';
import {DialogService, MigrationService} from '@/../bindings/voidraft/internal/services';
import {useWebSocket} from '@/composables/useWebSocket';
import * as runtime from '@wailsio/runtime';
const { t } = useI18n();
const {t} = useI18n();
const configStore = useConfigStore();
const { safeCall } = useErrorHandler();
const {safeCall} = useErrorHandler();
// WebSocket连接
const {migrationProgress, isConnected, connectionState, connect, disconnect, on} = useWebSocket({
debug: true,
autoConnect: false // 手动控制连接
});
// 迁移消息链
interface MigrationMessage {
id: number;
content: string;
type: 'start' | 'progress' | 'success' | 'error';
timestamp: number;
}
const migrationMessages = ref<MigrationMessage[]>([]);
const showMessages = ref(false);
let messageIdCounter = 0;
let hideMessagesTimer: any = null;
// 添加迁移消息
const addMigrationMessage = (content: string, type: MigrationMessage['type']) => {
const message: MigrationMessage = {
id: ++messageIdCounter,
content,
type,
timestamp: Date.now()
};
migrationMessages.value.push(message);
showMessages.value = true;
};
// 清除所有消息
const clearMigrationMessages = () => {
migrationMessages.value = [];
showMessages.value = false;
};
// 监听连接状态变化
const connectionWatcher = computed(() => isConnected.value);
watch(connectionWatcher, (connected) => {
if (connected && isMigrating.value) {
// 如果连接成功且正在迁移,添加连接消息
if (!migrationMessages.value.some(msg => msg.content === '实时连接中')) {
addMigrationMessage('实时连接中', 'progress');
}
}
});
// 监听迁移进度变化
on('migration_progress', (data) => {
// 清除之前的隐藏定时器
if (hideMessagesTimer) {
clearTimeout(hideMessagesTimer);
hideMessagesTimer = null;
}
if (data.status === 'migrating') {
// 如果是第一次收到迁移状态,添加开始消息
if (migrationMessages.value.length === 0) {
addMigrationMessage(t('migration.started'), 'start');
}
// 如果还没有迁移中消息,添加迁移中消息
if (!migrationMessages.value.some(msg => msg.type === 'progress' && msg.content === t('migration.migrating'))) {
addMigrationMessage(t('migration.migrating'), 'progress');
}
} else if (data.status === 'completed') {
addMigrationMessage(t('migration.completed'), 'success');
// 3秒后断开连接
setTimeout(() => {
disconnect();
}, 3000);
// 5秒后开始逐个隐藏消息
hideMessagesTimer = setTimeout(() => {
hideMessagesSequentially();
}, 5000);
} else if (data.status === 'failed') {
const errorMsg = data.error || t('migration.failed');
addMigrationMessage(errorMsg, 'error');
// 3秒后断开连接
setTimeout(() => {
disconnect();
}, 3000);
// 8秒后开始逐个隐藏消息
hideMessagesTimer = setTimeout(() => {
hideMessagesSequentially();
}, 8000);
}
});
// 逐个隐藏消息
const hideMessagesSequentially = () => {
const hideNextMessage = () => {
if (migrationMessages.value.length > 0) {
migrationMessages.value.shift(); // 移除第一条消息
if (migrationMessages.value.length > 0) {
// 如果还有消息1秒后隐藏下一条
setTimeout(hideNextMessage, 1000);
} else {
// 所有消息都隐藏完了,同时隐藏进度条
showMessages.value = false;
}
}
};
hideNextMessage();
};
// 迁移状态
const showMigrationProgress = ref(false);
const isMigrating = computed(() => migrationProgress.status === 'migrating');
const migrationComplete = computed(() => migrationProgress.status === 'completed');
const migrationFailed = computed(() => migrationProgress.status === 'failed');
// 进度条样式和宽度
const progressBarClass = computed(() => {
switch (migrationProgress.status) {
case 'migrating':
return 'migrating';
case 'completed':
return 'success';
case 'failed':
return 'error';
default:
return '';
}
});
const progressBarWidth = computed(() => {
if (isMigrating.value) {
return migrationProgress.progress + '%';
} else if (migrationComplete.value || migrationFailed.value) {
return '100%';
}
return '0%';
});
// 重置确认状态
const resetConfirmState = ref<'idle' | 'confirming'>('idle');
let resetConfirmTimer: any = null;
// 可选键列表
const keyOptions = [
@@ -58,24 +201,39 @@ const selectedKey = computed(() => configStore.config.general.globalHotkey.key);
// 切换修饰键
const toggleModifier = (key: 'ctrl' | 'shift' | 'alt' | 'win') => {
const currentHotkey = configStore.config.general.globalHotkey;
const newHotkey = { ...currentHotkey, [key]: !currentHotkey[key] };
const newHotkey = {...currentHotkey, [key]: !currentHotkey[key]};
configStore.setGlobalHotkey(newHotkey);
};
// 更新选择的键
const updateSelectedKey = (event: Event) => {
const select = event.target as HTMLSelectElement;
const newHotkey = { ...configStore.config.general.globalHotkey, key: select.value };
const newHotkey = {...configStore.config.general.globalHotkey, key: select.value};
configStore.setGlobalHotkey(newHotkey);
};
// 重置设置
const resetSettings = async () => {
if (confirm(t('settings.confirmReset'))) {
await configStore.resetConfig();
const resetSettings = () => {
if (resetConfirmState.value === 'idle') {
// 第一次点击,进入确认状态
resetConfirmState.value = 'confirming';
// 3秒后自动返回idle状态
resetConfirmTimer = setTimeout(() => {
resetConfirmState.value = 'idle';
}, 3000);
} else if (resetConfirmState.value === 'confirming') {
// 第二次点击,执行重置
clearTimeout(resetConfirmTimer);
resetConfirmState.value = 'idle';
confirmReset();
}
};
// 确认重置
const confirmReset = async () => {
await configStore.resetConfig();
};
// 计算热键预览文本
const hotkeyPreview = computed(() => {
if (!enableGlobalHotkey.value) return '';
@@ -96,95 +254,89 @@ const currentDataPath = computed(() => configStore.config.general.dataPath);
// 选择数据存储目录
const selectDataDirectory = async () => {
try {
if (isMigrating.value) return;
const selectedPath = await DialogService.SelectDirectory();
if (selectedPath && selectedPath.trim() && selectedPath !== currentDataPath.value) {
// 显示迁移进度对话框
showMigrationProgress.value = true;
// 清除之前的消息
clearMigrationMessages();
// 连接WebSocket以接收迁移进度
await connect();
// 开始迁移
try {
await safeCall(async () => {
const oldPath = currentDataPath.value;
const newPath = selectedPath.trim();
// 先启动迁移
await MigrationService.MigrateDirectory(oldPath, newPath);
// 迁移完成后更新配置
await configStore.setDataPath(newPath);
}, 'migration.migrationFailed');
}
}, '');
} catch (error) {
await safeCall(async () => {
throw error;
}, 'settings.selectDirectoryFailed');
// 发生错误时清除消息
clearMigrationMessages();
}
}
};
// 处理迁移完成
const handleMigrationComplete = () => {
showMigrationProgress.value = false;
// 显示成功消息
safeCall(async () => {
// 空的成功操作,只为了显示成功消息
}, '', 'migration.migrationCompleted');
};
// 处理迁移关闭
const handleMigrationClose = () => {
showMigrationProgress.value = false;
};
// 处理迁移重试
const handleMigrationRetry = () => {
// 重新触发路径选择
showMigrationProgress.value = false;
selectDataDirectory();
};
// 清理定时器和WebSocket连接
onUnmounted(() => {
disconnect();
if (resetConfirmTimer) {
clearTimeout(resetConfirmTimer);
}
if (hideMessagesTimer) {
clearTimeout(hideMessagesTimer);
}
});
</script>
<template>
<div class="settings-page">
<SettingSection :title="t('settings.globalHotkey')">
<SettingItem :title="t('settings.enableGlobalHotkey')">
<ToggleSwitch v-model="enableGlobalHotkey" />
<ToggleSwitch v-model="enableGlobalHotkey"/>
</SettingItem>
<div class="hotkey-selector" :class="{ 'disabled': !enableGlobalHotkey }">
<div class="hotkey-modifiers">
<label class="modifier-label" :class="{ active: modifierKeys.ctrl }" @click="toggleModifier('ctrl')">
<input type="checkbox" :checked="modifierKeys.ctrl" class="hidden-checkbox" :disabled="!enableGlobalHotkey">
<span class="modifier-key">Ctrl</span>
</label>
<label class="modifier-label" :class="{ active: modifierKeys.shift }" @click="toggleModifier('shift')">
<input type="checkbox" :checked="modifierKeys.shift" class="hidden-checkbox" :disabled="!enableGlobalHotkey">
<span class="modifier-key">Shift</span>
</label>
<label class="modifier-label" :class="{ active: modifierKeys.alt }" @click="toggleModifier('alt')">
<input type="checkbox" :checked="modifierKeys.alt" class="hidden-checkbox" :disabled="!enableGlobalHotkey">
<span class="modifier-key">Alt</span>
</label>
<label class="modifier-label" :class="{ active: modifierKeys.win }" @click="toggleModifier('win')">
<input type="checkbox" :checked="modifierKeys.win" class="hidden-checkbox" :disabled="!enableGlobalHotkey">
<span class="modifier-key">Win</span>
</label>
<div class="hotkey-controls">
<div class="hotkey-modifiers">
<label class="modifier-label" :class="{ active: modifierKeys.ctrl }" @click="toggleModifier('ctrl')">
<input type="checkbox" :checked="modifierKeys.ctrl" class="hidden-checkbox" :disabled="!enableGlobalHotkey">
<span class="modifier-key">Ctrl</span>
</label>
<label class="modifier-label" :class="{ active: modifierKeys.shift }" @click="toggleModifier('shift')">
<input type="checkbox" :checked="modifierKeys.shift" class="hidden-checkbox"
:disabled="!enableGlobalHotkey">
<span class="modifier-key">Shift</span>
</label>
<label class="modifier-label" :class="{ active: modifierKeys.alt }" @click="toggleModifier('alt')">
<input type="checkbox" :checked="modifierKeys.alt" class="hidden-checkbox" :disabled="!enableGlobalHotkey">
<span class="modifier-key">Alt</span>
</label>
<label class="modifier-label" :class="{ active: modifierKeys.win }" @click="toggleModifier('win')">
<input type="checkbox" :checked="modifierKeys.win" class="hidden-checkbox" :disabled="!enableGlobalHotkey">
<span class="modifier-key">Win</span>
</label>
</div>
<select class="key-select" :value="selectedKey" @change="updateSelectedKey" :disabled="!enableGlobalHotkey">
<option v-for="key in keyOptions" :key="key" :value="key">{{ key }}</option>
</select>
</div>
<select class="key-select" :value="selectedKey" @change="updateSelectedKey" :disabled="!enableGlobalHotkey">
<option v-for="key in keyOptions" :key="key" :value="key">{{ key }}</option>
</select>
<div class="hotkey-preview" v-if="hotkeyPreview">
<div class="hotkey-preview">
<span class="preview-label">预览</span>
<span class="preview-hotkey">{{ hotkeyPreview }}</span>
<span class="preview-hotkey">{{ hotkeyPreview || '无' }}</span>
</div>
</div>
</SettingSection>
<SettingSection :title="t('settings.window')">
<SettingItem :title="t('settings.alwaysOnTop')">
<ToggleSwitch v-model="alwaysOnTop" />
<ToggleSwitch v-model="alwaysOnTop"/>
</SettingItem>
</SettingSection>
@@ -194,41 +346,61 @@ const handleMigrationRetry = () => {
<div class="setting-title">{{ t('settings.dataPath') }}</div>
</div>
<div class="data-path-controls">
<input
type="text"
:value="currentDataPath"
readonly
:placeholder="t('settings.clickToSelectPath')"
class="path-display-input"
@click="selectDataDirectory"
:title="t('settings.clickToSelectPath')"
/>
<div class="path-input-container">
<input
type="text"
:value="currentDataPath"
readonly
:placeholder="t('settings.clickToSelectPath')"
class="path-display-input"
@click="selectDataDirectory"
:title="t('settings.clickToSelectPath')"
:disabled="isMigrating"
/>
<div
class="progress-bar"
:class="[
{ 'active': showMessages },
progressBarClass
]"
:style="{ width: progressBarWidth }"
></div>
</div>
<div class="migration-status-container">
<Transition name="fade-slide" mode="out-in">
<div v-if="showMessages" class="migration-messages">
<TransitionGroup name="message-list" tag="div">
<div v-for="message in migrationMessages" :key="message.id" class="migration-message" :class="message.type">
{{ message.content }}
</div>
</TransitionGroup>
</div>
</Transition>
</div>
</div>
</div>
</SettingSection>
<SettingSection :title="t('settings.dangerZone')">
<div class="danger-zone">
<div class="reset-section">
<div class="reset-info">
<h4>{{ t('settings.resetAllSettings') }}</h4>
<p>{{ t('settings.resetDescription') }}</p>
</div>
<button class="reset-button" @click="resetSettings">
<SettingItem :title="t('settings.resetAllSettings')" :description="t('settings.resetDescription')">
<button
class="reset-button"
:class="{ 'confirming': resetConfirmState === 'confirming' }"
@click="resetSettings"
>
<template v-if="resetConfirmState === 'idle'">
{{ t('settings.reset') }}
</button>
</div>
</div>
</template>
<template v-else-if="resetConfirmState === 'confirming'">
{{ t('settings.confirmReset') }}
</template>
</button>
</SettingItem>
</SettingSection>
<!-- 迁移进度对话框 -->
<MigrationProgress
:visible="showMigrationProgress"
@complete="handleMigrationComplete"
@close="handleMigrationClose"
@retry="handleMigrationRetry"
/>
</div>
</template>
<style scoped lang="scss">
@@ -238,16 +410,24 @@ const handleMigrationRetry = () => {
.hotkey-selector {
padding: 15px 0 5px 20px;
transition: all 0.3s ease;
&.disabled {
opacity: 0.5;
pointer-events: none;
}
.hotkey-controls {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.hotkey-modifiers {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
.modifier-label {
@@ -298,7 +478,6 @@ const handleMigrationRetry = () => {
background-position: right 8px center;
background-size: 16px;
padding-right: 30px;
margin-bottom: 12px;
&:focus {
outline: none;
@@ -366,92 +545,261 @@ const handleMigrationRetry = () => {
flex-direction: column;
gap: 8px;
.path-input-container {
position: relative;
width: 100%;
max-width: 450px;
.path-display-input {
width: 100%;
max-width: 450px;
box-sizing: border-box;
padding: 10px 12px;
padding: 10px 12px;
background-color: #3a3a3a;
border: 1px solid #555555;
border-radius: 4px;
color: #e0e0e0;
font-size: 13px;
line-height: 1.2;
line-height: 1.2;
transition: all 0.2s ease;
cursor: pointer;
&:hover {
border-color: #4a9eff;
background-color: #404040;
}
&:focus {
outline: none;
border-color: #4a9eff;
background-color: #404040;
}
cursor: pointer;
&:hover:not(:disabled) {
border-color: #4a9eff;
background-color: #404040;
}
&:focus {
outline: none;
border-color: #4a9eff;
background-color: #404040;
}
&:disabled {
cursor: not-allowed;
opacity: 0.7;
}
&::placeholder {
color: #888888;
}
}
.progress-bar {
position: absolute;
bottom: 0;
left: 0;
height: 2px;
background-color: #22c55e;
transition: width 0.3s ease;
border-radius: 0 0 4px 4px;
opacity: 0;
&.active {
opacity: 1;
}
}
&.migrating {
background-color: #3b82f6;
}
.danger-zone {
padding: 20px;
background-color: rgba(220, 53, 69, 0.05);
border: 1px solid rgba(220, 53, 69, 0.2);
border-radius: 6px;
margin-top: 10px;
.reset-section {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
.reset-info {
flex: 1;
h4 {
margin: 0 0 6px 0;
color: #ff6b6b;
font-size: 14px;
font-weight: 600;
&.success {
background-color: #22c55e;
}
p {
margin: 0;
color: #cccccc;
font-size: 13px;
line-height: 1.4;
}
}
.reset-button {
padding: 8px 16px;
background-color: #dc3545;
border: 1px solid #dc3545;
border-radius: 4px;
color: white;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
&:hover {
background-color: #c82333;
border-color: #bd2130;
}
&:active {
transform: translateY(1px);
&.error {
background-color: #ef4444;
}
}
}
.migration-status-container {
margin-top: 8px;
min-height: 0;
overflow: hidden;
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
.migration-messages {
margin-bottom: 6px;
display: flex;
flex-direction: column;
gap: 2px;
}
.migration-message {
font-size: 11px;
padding: 2px 0;
display: flex;
align-items: center;
gap: 6px;
transform: translateY(0);
opacity: 1;
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
&::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
&.start {
color: #94a3b8;
&::before {
background-color: #94a3b8;
}
}
&.progress {
color: #3b82f6;
&::before {
background-color: #3b82f6;
animation: pulse-dot 1.5s infinite;
}
}
&.success {
color: #22c55e;
&::before {
background-color: #22c55e;
}
}
&.error {
color: #ef4444;
&::before {
background-color: #ef4444;
}
}
&.v-enter-from, &.v-leave-to {
opacity: 0;
transform: translateY(-8px);
}
}
&:empty {
min-height: 0;
margin-top: 0;
}
}
}
.reset-button {
padding: 8px 16px;
background-color: #dc3545;
border: 1px solid #dc3545;
border-radius: 4px;
color: white;
font-size: 13px;
cursor: pointer;
transition: all 0.3s ease;
min-width: 80px;
&:hover {
background-color: #c82333;
border-color: #bd2130;
}
&:active {
transform: translateY(1px);
}
&.confirming {
background-color: #ff4757;
border-color: #ff4757;
animation: pulse-button 1.5s infinite;
&:hover {
background-color: #ff3838;
border-color: #ff3838;
}
}
}
// 按钮脉冲动画
@keyframes pulse-button {
0% {
box-shadow: 0 0 0 0 rgba(255, 71, 87, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(255, 71, 87, 0);
}
100% {
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);
}
}
// Vue Transition 动画
.fade-slide-enter-active {
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.fade-slide-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.6, 1);
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY(-8px);
max-height: 0;
margin-top: 0;
margin-bottom: 0;
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(-5px);
max-height: 0;
margin-top: 0;
margin-bottom: 0;
}
.fade-slide-enter-to,
.fade-slide-leave-from {
opacity: 1;
transform: translateY(0);
max-height: 150px;
}
// 消息列表动画
.message-list-enter-active {
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.message-list-leave-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.6, 1);
}
.message-list-enter-from {
opacity: 0;
transform: translateX(-16px);
}
.message-list-leave-to {
opacity: 0;
transform: translateX(16px);
}
.message-list-move {
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
</style>