✨ Improved settings
This commit is contained in:
@@ -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.
|
||||
*
|
||||
|
@@ -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;
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
|
@@ -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",
|
||||
};
|
||||
|
1
frontend/components.d.ts
vendored
1
frontend/components.d.ts
vendored
@@ -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']
|
||||
|
@@ -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>
|
423
frontend/src/composables/useWebSocket.ts
Normal file
423
frontend/src/composables/useWebSocket.ts
Normal 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 };
|
@@ -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',
|
||||
|
@@ -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: '窗口/应用程序',
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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>
|
Reference in New Issue
Block a user