✨ Improved settings
This commit is contained in:
@@ -5,42 +5,6 @@
|
|||||||
// @ts-ignore: Unused imports
|
// @ts-ignore: Unused imports
|
||||||
import {Create as $Create} from "@wailsio/runtime";
|
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.
|
* 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 } {
|
export function ResetConfig(): Promise<void> & { cancel(): void } {
|
||||||
let $resultPromise = $Call.ByID(3593047389) as any;
|
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 } {
|
export function SetProgressBroadcaster(broadcaster: any): Promise<void> & { cancel(): void } {
|
||||||
let $resultPromise = $Call.ByID(75752256, callback) as any;
|
let $resultPromise = $Call.ByID(3244071921, broadcaster) as any;
|
||||||
return $resultPromise;
|
return $resultPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -5,10 +5,6 @@
|
|||||||
// @ts-ignore: Unused imports
|
// @ts-ignore: Unused imports
|
||||||
import {Create as $Create} from "@wailsio/runtime";
|
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 内存统计信息
|
* MemoryStats 内存统计信息
|
||||||
*/
|
*/
|
||||||
@@ -85,88 +81,24 @@ export class MigrationProgress {
|
|||||||
*/
|
*/
|
||||||
"status": MigrationStatus;
|
"status": MigrationStatus;
|
||||||
|
|
||||||
/**
|
|
||||||
* 当前处理的文件
|
|
||||||
*/
|
|
||||||
"currentFile": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 已处理文件数
|
|
||||||
*/
|
|
||||||
"processedFiles": number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 总文件数
|
|
||||||
*/
|
|
||||||
"totalFiles": number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 已处理字节数
|
|
||||||
*/
|
|
||||||
"processedBytes": number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 总字节数
|
|
||||||
*/
|
|
||||||
"totalBytes": number;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 进度百分比 (0-100)
|
* 进度百分比 (0-100)
|
||||||
*/
|
*/
|
||||||
"progress": number;
|
"progress": number;
|
||||||
|
|
||||||
/**
|
|
||||||
* 状态消息
|
|
||||||
*/
|
|
||||||
"message": string;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 错误信息
|
* 错误信息
|
||||||
*/
|
*/
|
||||||
"error"?: string;
|
"error"?: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* 开始时间
|
|
||||||
*/
|
|
||||||
"startTime": time$0.Time;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 估计剩余时间
|
|
||||||
*/
|
|
||||||
"estimatedTime": time$0.Duration;
|
|
||||||
|
|
||||||
/** Creates a new MigrationProgress instance. */
|
/** Creates a new MigrationProgress instance. */
|
||||||
constructor($$source: Partial<MigrationProgress> = {}) {
|
constructor($$source: Partial<MigrationProgress> = {}) {
|
||||||
if (!("status" in $$source)) {
|
if (!("status" in $$source)) {
|
||||||
this["status"] = ("" as MigrationStatus);
|
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)) {
|
if (!("progress" in $$source)) {
|
||||||
this["progress"] = 0;
|
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);
|
Object.assign(this, $$source);
|
||||||
}
|
}
|
||||||
@@ -180,11 +112,6 @@ export class MigrationProgress {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* MigrationProgressCallback 进度回调函数类型
|
|
||||||
*/
|
|
||||||
export type MigrationProgressCallback = any;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MigrationStatus 迁移状态
|
* MigrationStatus 迁移状态
|
||||||
*/
|
*/
|
||||||
@@ -194,16 +121,6 @@ export enum MigrationStatus {
|
|||||||
*/
|
*/
|
||||||
$zero = "",
|
$zero = "",
|
||||||
|
|
||||||
/**
|
|
||||||
* 空闲状态
|
|
||||||
*/
|
|
||||||
MigrationStatusIdle = "idle",
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 准备中
|
|
||||||
*/
|
|
||||||
MigrationStatusPreparing = "preparing",
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 迁移中
|
* 迁移中
|
||||||
*/
|
*/
|
||||||
@@ -218,9 +135,4 @@ export enum MigrationStatus {
|
|||||||
* 失败
|
* 失败
|
||||||
*/
|
*/
|
||||||
MigrationStatusFailed = "failed",
|
MigrationStatusFailed = "failed",
|
||||||
|
|
||||||
/**
|
|
||||||
* 取消
|
|
||||||
*/
|
|
||||||
MigrationStatusCancelled = "cancelled",
|
|
||||||
};
|
};
|
||||||
|
1
frontend/components.d.ts
vendored
1
frontend/components.d.ts
vendored
@@ -9,7 +9,6 @@ export {}
|
|||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
MemoryMonitor: typeof import('./src/components/monitor/MemoryMonitor.vue')['default']
|
MemoryMonitor: typeof import('./src/components/monitor/MemoryMonitor.vue')['default']
|
||||||
MigrationProgress: typeof import('./src/components/migration/MigrationProgress.vue')['default']
|
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
Toolbar: typeof import('./src/components/toolbar/Toolbar.vue')['default']
|
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',
|
saveSuccess: 'Save settings updated',
|
||||||
saveFailed: 'Failed to update save settings'
|
saveFailed: 'Failed to update save settings'
|
||||||
},
|
},
|
||||||
migration: {
|
|
||||||
inProgress: 'Migrating data to new path...',
|
|
||||||
success: 'Data migration completed',
|
|
||||||
failed: 'Data migration failed'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
migration: {
|
migration: {
|
||||||
title: 'Data Migration',
|
started: 'Starting data migration',
|
||||||
preparing: 'Preparing',
|
|
||||||
migrating: 'Migrating',
|
migrating: 'Migrating',
|
||||||
completed: 'Completed',
|
completed: 'Migration Completed',
|
||||||
failed: 'Failed',
|
failed: 'Migration 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...'
|
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
@@ -89,10 +65,11 @@ export default {
|
|||||||
comingSoon: 'Coming Soon...',
|
comingSoon: 'Coming Soon...',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
reset: 'Reset',
|
reset: 'Reset',
|
||||||
|
cancel: 'Cancel',
|
||||||
dangerZone: 'Danger Zone',
|
dangerZone: 'Danger Zone',
|
||||||
resetAllSettings: 'Reset All Settings',
|
resetAllSettings: 'Reset All Settings',
|
||||||
resetDescription: 'This will restore all settings to their default values. This action cannot be undone.',
|
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',
|
globalHotkey: 'Global Keyboard Shortcuts',
|
||||||
enableGlobalHotkey: 'Enable Global Hotkeys',
|
enableGlobalHotkey: 'Enable Global Hotkeys',
|
||||||
window: 'Window/Application',
|
window: 'Window/Application',
|
||||||
|
@@ -46,36 +46,12 @@ export default {
|
|||||||
saveSuccess: '保存设置已更新',
|
saveSuccess: '保存设置已更新',
|
||||||
saveFailed: '保存设置更新失败'
|
saveFailed: '保存设置更新失败'
|
||||||
},
|
},
|
||||||
migration: {
|
|
||||||
inProgress: '正在迁移数据到新路径...',
|
|
||||||
success: '数据迁移完成',
|
|
||||||
failed: '数据迁移失败'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
migration: {
|
migration: {
|
||||||
title: '数据迁移',
|
started: '开始迁移数据',
|
||||||
preparing: '准备中',
|
migrating: '迁移中',
|
||||||
migrating: '迁移中',
|
completed: '迁移已完成',
|
||||||
completed: '已完成',
|
failed: '迁移失败'
|
||||||
failed: '失败',
|
|
||||||
cancelled: '已取消',
|
|
||||||
idle: '空闲',
|
|
||||||
currentFile: '当前文件',
|
|
||||||
files: '文件',
|
|
||||||
size: '大小',
|
|
||||||
timeRemaining: '剩余时间',
|
|
||||||
complete: '完成',
|
|
||||||
retry: '重试',
|
|
||||||
close: '关闭',
|
|
||||||
migrationInProgress: '正在迁移数据到新路径...',
|
|
||||||
migrationCompleted: '数据迁移完成',
|
|
||||||
migrationFailed: '数据迁移失败',
|
|
||||||
recursiveCopyError: '目标路径不能是源路径的子目录,这会导致无限递归复制',
|
|
||||||
targetNotDirectory: '目标路径存在但不是目录',
|
|
||||||
targetNotEmpty: '目标目录不为空,无法迁移',
|
|
||||||
compressing: '正在压缩源目录...',
|
|
||||||
extracting: '正在解压到目标位置...',
|
|
||||||
cleaning: '正在清理源目录...'
|
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: '设置',
|
title: '设置',
|
||||||
@@ -89,10 +65,11 @@ export default {
|
|||||||
comingSoon: '即将推出...',
|
comingSoon: '即将推出...',
|
||||||
save: '保存',
|
save: '保存',
|
||||||
reset: '重置',
|
reset: '重置',
|
||||||
|
cancel: '取消',
|
||||||
dangerZone: '危险操作',
|
dangerZone: '危险操作',
|
||||||
resetAllSettings: '重置所有设置',
|
resetAllSettings: '重置所有设置',
|
||||||
resetDescription: '这将恢复所有设置为默认值,此操作无法撤销',
|
resetDescription: '这将恢复所有设置为默认值,此操作无法撤销',
|
||||||
confirmReset: '确定要重置所有设置吗?此操作无法撤销。',
|
confirmReset: '再次点击确认重置',
|
||||||
globalHotkey: '全局键盘快捷键',
|
globalHotkey: '全局键盘快捷键',
|
||||||
enableGlobalHotkey: '启用全局热键',
|
enableGlobalHotkey: '启用全局热键',
|
||||||
window: '窗口/应用程序',
|
window: '窗口/应用程序',
|
||||||
|
@@ -270,8 +270,16 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
|
|
||||||
state.isLoading = true;
|
state.isLoading = true;
|
||||||
try {
|
try {
|
||||||
|
// 调用后端重置配置
|
||||||
await safeCall(() => ConfigService.ResetConfig(), 'config.resetFailed', 'config.resetSuccess');
|
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 {
|
} finally {
|
||||||
state.isLoading = false;
|
state.isLoading = false;
|
||||||
}
|
}
|
||||||
|
@@ -1,21 +1,164 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useConfigStore } from '@/stores/configStore';
|
import {useConfigStore} from '@/stores/configStore';
|
||||||
import { useI18n } from 'vue-i18n';
|
import {useI18n} from 'vue-i18n';
|
||||||
import { computed, ref, onMounted } from 'vue';
|
import {computed, onUnmounted, ref, watch} from 'vue';
|
||||||
import SettingSection from '../components/SettingSection.vue';
|
import SettingSection from '../components/SettingSection.vue';
|
||||||
import SettingItem from '../components/SettingItem.vue';
|
import SettingItem from '../components/SettingItem.vue';
|
||||||
import ToggleSwitch from '../components/ToggleSwitch.vue';
|
import ToggleSwitch from '../components/ToggleSwitch.vue';
|
||||||
import MigrationProgress from '@/components/migration/MigrationProgress.vue';
|
import {useErrorHandler} from '@/utils/errorHandler';
|
||||||
import { useErrorHandler } from '@/utils/errorHandler';
|
import {DialogService, MigrationService} from '@/../bindings/voidraft/internal/services';
|
||||||
import { DialogService, MigrationService } from '@/../bindings/voidraft/internal/services';
|
import {useWebSocket} from '@/composables/useWebSocket';
|
||||||
import * as runtime from '@wailsio/runtime';
|
import * as runtime from '@wailsio/runtime';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const {t} = useI18n();
|
||||||
const configStore = useConfigStore();
|
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 = [
|
const keyOptions = [
|
||||||
@@ -58,24 +201,39 @@ const selectedKey = computed(() => configStore.config.general.globalHotkey.key);
|
|||||||
// 切换修饰键
|
// 切换修饰键
|
||||||
const toggleModifier = (key: 'ctrl' | 'shift' | 'alt' | 'win') => {
|
const toggleModifier = (key: 'ctrl' | 'shift' | 'alt' | 'win') => {
|
||||||
const currentHotkey = configStore.config.general.globalHotkey;
|
const currentHotkey = configStore.config.general.globalHotkey;
|
||||||
const newHotkey = { ...currentHotkey, [key]: !currentHotkey[key] };
|
const newHotkey = {...currentHotkey, [key]: !currentHotkey[key]};
|
||||||
configStore.setGlobalHotkey(newHotkey);
|
configStore.setGlobalHotkey(newHotkey);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新选择的键
|
// 更新选择的键
|
||||||
const updateSelectedKey = (event: Event) => {
|
const updateSelectedKey = (event: Event) => {
|
||||||
const select = event.target as HTMLSelectElement;
|
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);
|
configStore.setGlobalHotkey(newHotkey);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 重置设置
|
// 重置设置
|
||||||
const resetSettings = async () => {
|
const resetSettings = () => {
|
||||||
if (confirm(t('settings.confirmReset'))) {
|
if (resetConfirmState.value === 'idle') {
|
||||||
await configStore.resetConfig();
|
// 第一次点击,进入确认状态
|
||||||
|
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(() => {
|
const hotkeyPreview = computed(() => {
|
||||||
if (!enableGlobalHotkey.value) return '';
|
if (!enableGlobalHotkey.value) return '';
|
||||||
@@ -96,95 +254,89 @@ const currentDataPath = computed(() => configStore.config.general.dataPath);
|
|||||||
|
|
||||||
// 选择数据存储目录
|
// 选择数据存储目录
|
||||||
const selectDataDirectory = async () => {
|
const selectDataDirectory = async () => {
|
||||||
try {
|
if (isMigrating.value) return;
|
||||||
|
|
||||||
const selectedPath = await DialogService.SelectDirectory();
|
const selectedPath = await DialogService.SelectDirectory();
|
||||||
|
|
||||||
if (selectedPath && selectedPath.trim() && selectedPath !== currentDataPath.value) {
|
if (selectedPath && selectedPath.trim() && selectedPath !== currentDataPath.value) {
|
||||||
// 显示迁移进度对话框
|
// 清除之前的消息
|
||||||
showMigrationProgress.value = true;
|
clearMigrationMessages();
|
||||||
|
|
||||||
|
// 连接WebSocket以接收迁移进度
|
||||||
|
await connect();
|
||||||
|
|
||||||
// 开始迁移
|
// 开始迁移
|
||||||
|
try {
|
||||||
await safeCall(async () => {
|
await safeCall(async () => {
|
||||||
const oldPath = currentDataPath.value;
|
const oldPath = currentDataPath.value;
|
||||||
const newPath = selectedPath.trim();
|
const newPath = selectedPath.trim();
|
||||||
|
|
||||||
// 先启动迁移
|
|
||||||
await MigrationService.MigrateDirectory(oldPath, newPath);
|
await MigrationService.MigrateDirectory(oldPath, newPath);
|
||||||
|
|
||||||
// 迁移完成后更新配置
|
|
||||||
await configStore.setDataPath(newPath);
|
await configStore.setDataPath(newPath);
|
||||||
}, 'migration.migrationFailed');
|
}, '');
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await safeCall(async () => {
|
// 发生错误时清除消息
|
||||||
throw error;
|
clearMigrationMessages();
|
||||||
}, 'settings.selectDirectoryFailed');
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理迁移完成
|
|
||||||
const handleMigrationComplete = () => {
|
|
||||||
showMigrationProgress.value = false;
|
|
||||||
// 显示成功消息
|
|
||||||
safeCall(async () => {
|
|
||||||
// 空的成功操作,只为了显示成功消息
|
|
||||||
}, '', 'migration.migrationCompleted');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理迁移关闭
|
// 清理定时器和WebSocket连接
|
||||||
const handleMigrationClose = () => {
|
onUnmounted(() => {
|
||||||
showMigrationProgress.value = false;
|
disconnect();
|
||||||
};
|
if (resetConfirmTimer) {
|
||||||
|
clearTimeout(resetConfirmTimer);
|
||||||
// 处理迁移重试
|
}
|
||||||
const handleMigrationRetry = () => {
|
if (hideMessagesTimer) {
|
||||||
// 重新触发路径选择
|
clearTimeout(hideMessagesTimer);
|
||||||
showMigrationProgress.value = false;
|
}
|
||||||
selectDataDirectory();
|
});
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="settings-page">
|
<div class="settings-page">
|
||||||
<SettingSection :title="t('settings.globalHotkey')">
|
<SettingSection :title="t('settings.globalHotkey')">
|
||||||
<SettingItem :title="t('settings.enableGlobalHotkey')">
|
<SettingItem :title="t('settings.enableGlobalHotkey')">
|
||||||
<ToggleSwitch v-model="enableGlobalHotkey" />
|
<ToggleSwitch v-model="enableGlobalHotkey"/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
<div class="hotkey-selector" :class="{ 'disabled': !enableGlobalHotkey }">
|
<div class="hotkey-selector" :class="{ 'disabled': !enableGlobalHotkey }">
|
||||||
<div class="hotkey-modifiers">
|
<div class="hotkey-controls">
|
||||||
<label class="modifier-label" :class="{ active: modifierKeys.ctrl }" @click="toggleModifier('ctrl')">
|
<div class="hotkey-modifiers">
|
||||||
<input type="checkbox" :checked="modifierKeys.ctrl" class="hidden-checkbox" :disabled="!enableGlobalHotkey">
|
<label class="modifier-label" :class="{ active: modifierKeys.ctrl }" @click="toggleModifier('ctrl')">
|
||||||
<span class="modifier-key">Ctrl</span>
|
<input type="checkbox" :checked="modifierKeys.ctrl" class="hidden-checkbox" :disabled="!enableGlobalHotkey">
|
||||||
</label>
|
<span class="modifier-key">Ctrl</span>
|
||||||
<label class="modifier-label" :class="{ active: modifierKeys.shift }" @click="toggleModifier('shift')">
|
</label>
|
||||||
<input type="checkbox" :checked="modifierKeys.shift" class="hidden-checkbox" :disabled="!enableGlobalHotkey">
|
<label class="modifier-label" :class="{ active: modifierKeys.shift }" @click="toggleModifier('shift')">
|
||||||
<span class="modifier-key">Shift</span>
|
<input type="checkbox" :checked="modifierKeys.shift" class="hidden-checkbox"
|
||||||
</label>
|
:disabled="!enableGlobalHotkey">
|
||||||
<label class="modifier-label" :class="{ active: modifierKeys.alt }" @click="toggleModifier('alt')">
|
<span class="modifier-key">Shift</span>
|
||||||
<input type="checkbox" :checked="modifierKeys.alt" class="hidden-checkbox" :disabled="!enableGlobalHotkey">
|
</label>
|
||||||
<span class="modifier-key">Alt</span>
|
<label class="modifier-label" :class="{ active: modifierKeys.alt }" @click="toggleModifier('alt')">
|
||||||
</label>
|
<input type="checkbox" :checked="modifierKeys.alt" class="hidden-checkbox" :disabled="!enableGlobalHotkey">
|
||||||
<label class="modifier-label" :class="{ active: modifierKeys.win }" @click="toggleModifier('win')">
|
<span class="modifier-key">Alt</span>
|
||||||
<input type="checkbox" :checked="modifierKeys.win" class="hidden-checkbox" :disabled="!enableGlobalHotkey">
|
</label>
|
||||||
<span class="modifier-key">Win</span>
|
<label class="modifier-label" :class="{ active: modifierKeys.win }" @click="toggleModifier('win')">
|
||||||
</label>
|
<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>
|
</div>
|
||||||
|
|
||||||
<select class="key-select" :value="selectedKey" @change="updateSelectedKey" :disabled="!enableGlobalHotkey">
|
<div class="hotkey-preview">
|
||||||
<option v-for="key in keyOptions" :key="key" :value="key">{{ key }}</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<div class="hotkey-preview" v-if="hotkeyPreview">
|
|
||||||
<span class="preview-label">预览:</span>
|
<span class="preview-label">预览:</span>
|
||||||
<span class="preview-hotkey">{{ hotkeyPreview }}</span>
|
<span class="preview-hotkey">{{ hotkeyPreview || '无' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SettingSection>
|
</SettingSection>
|
||||||
|
|
||||||
<SettingSection :title="t('settings.window')">
|
<SettingSection :title="t('settings.window')">
|
||||||
<SettingItem :title="t('settings.alwaysOnTop')">
|
<SettingItem :title="t('settings.alwaysOnTop')">
|
||||||
<ToggleSwitch v-model="alwaysOnTop" />
|
<ToggleSwitch v-model="alwaysOnTop"/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
</SettingSection>
|
</SettingSection>
|
||||||
|
|
||||||
@@ -194,41 +346,61 @@ const handleMigrationRetry = () => {
|
|||||||
<div class="setting-title">{{ t('settings.dataPath') }}</div>
|
<div class="setting-title">{{ t('settings.dataPath') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="data-path-controls">
|
<div class="data-path-controls">
|
||||||
<input
|
<div class="path-input-container">
|
||||||
type="text"
|
<input
|
||||||
:value="currentDataPath"
|
type="text"
|
||||||
readonly
|
:value="currentDataPath"
|
||||||
:placeholder="t('settings.clickToSelectPath')"
|
readonly
|
||||||
class="path-display-input"
|
:placeholder="t('settings.clickToSelectPath')"
|
||||||
@click="selectDataDirectory"
|
class="path-display-input"
|
||||||
:title="t('settings.clickToSelectPath')"
|
@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>
|
||||||
</div>
|
</div>
|
||||||
</SettingSection>
|
</SettingSection>
|
||||||
|
|
||||||
<SettingSection :title="t('settings.dangerZone')">
|
<SettingSection :title="t('settings.dangerZone')">
|
||||||
<div class="danger-zone">
|
<SettingItem :title="t('settings.resetAllSettings')" :description="t('settings.resetDescription')">
|
||||||
<div class="reset-section">
|
<button
|
||||||
<div class="reset-info">
|
class="reset-button"
|
||||||
<h4>{{ t('settings.resetAllSettings') }}</h4>
|
:class="{ 'confirming': resetConfirmState === 'confirming' }"
|
||||||
<p>{{ t('settings.resetDescription') }}</p>
|
@click="resetSettings"
|
||||||
</div>
|
>
|
||||||
<button class="reset-button" @click="resetSettings">
|
<template v-if="resetConfirmState === 'idle'">
|
||||||
{{ t('settings.reset') }}
|
{{ t('settings.reset') }}
|
||||||
</button>
|
</template>
|
||||||
</div>
|
<template v-else-if="resetConfirmState === 'confirming'">
|
||||||
</div>
|
{{ t('settings.confirmReset') }}
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</SettingItem>
|
||||||
</SettingSection>
|
</SettingSection>
|
||||||
|
|
||||||
<!-- 迁移进度对话框 -->
|
|
||||||
<MigrationProgress
|
|
||||||
:visible="showMigrationProgress"
|
|
||||||
@complete="handleMigrationComplete"
|
|
||||||
@close="handleMigrationClose"
|
|
||||||
@retry="handleMigrationRetry"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -238,16 +410,24 @@ const handleMigrationRetry = () => {
|
|||||||
|
|
||||||
.hotkey-selector {
|
.hotkey-selector {
|
||||||
padding: 15px 0 5px 20px;
|
padding: 15px 0 5px 20px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
&.disabled {
|
&.disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hotkey-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.hotkey-modifiers {
|
.hotkey-modifiers {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 12px;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
||||||
.modifier-label {
|
.modifier-label {
|
||||||
@@ -298,7 +478,6 @@ const handleMigrationRetry = () => {
|
|||||||
background-position: right 8px center;
|
background-position: right 8px center;
|
||||||
background-size: 16px;
|
background-size: 16px;
|
||||||
padding-right: 30px;
|
padding-right: 30px;
|
||||||
margin-bottom: 12px;
|
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -366,92 +545,261 @@ const handleMigrationRetry = () => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
|
.path-input-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
|
||||||
.path-display-input {
|
.path-display-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 450px;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background-color: #3a3a3a;
|
background-color: #3a3a3a;
|
||||||
border: 1px solid #555555;
|
border: 1px solid #555555;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: #e0e0e0;
|
color: #e0e0e0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: #4a9eff;
|
|
||||||
background-color: #404040;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #4a9eff;
|
|
||||||
background-color: #404040;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
&: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 {
|
&::placeholder {
|
||||||
color: #888888;
|
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 {
|
&.success {
|
||||||
padding: 20px;
|
background-color: #22c55e;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
&.error {
|
||||||
margin: 0;
|
background-color: #ef4444;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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>
|
</style>
|
23
go.mod
23
go.mod
@@ -6,6 +6,8 @@ toolchain go1.24.2
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
|
github.com/gin-gonic/gin v1.10.1
|
||||||
|
github.com/lxzan/gws v1.8.9
|
||||||
github.com/spf13/viper v1.20.1
|
github.com/spf13/viper v1.20.1
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.9
|
github.com/wailsapp/wails/v3 v3.0.0-alpha.9
|
||||||
)
|
)
|
||||||
@@ -16,26 +18,43 @@ require (
|
|||||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||||
github.com/adrg/xdg v0.5.3 // indirect
|
github.com/adrg/xdg v0.5.3 // indirect
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
|
github.com/bytedance/sonic v1.11.6 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
github.com/cloudflare/circl v1.6.1 // indirect
|
github.com/cloudflare/circl v1.6.1 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
||||||
|
github.com/dolthub/maphash v0.1.0 // indirect
|
||||||
github.com/ebitengine/purego v0.8.4 // indirect
|
github.com/ebitengine/purego v0.8.4 // indirect
|
||||||
github.com/emirpasic/gods v1.18.1 // indirect
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||||
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
||||||
github.com/go-git/go-git/v5 v5.16.0 // indirect
|
github.com/go-git/go-git/v5 v5.16.0 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||||
|
github.com/klauspost/compress v1.17.11 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||||
github.com/leaanthony/u v1.1.1 // indirect
|
github.com/leaanthony/u v1.1.1 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/lmittmann/tint v1.1.1 // indirect
|
github.com/lmittmann/tint v1.1.1 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/pjbgf/sha1cd v0.3.2 // indirect
|
github.com/pjbgf/sha1cd v0.3.2 // indirect
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
@@ -49,15 +68,19 @@ require (
|
|||||||
github.com/spf13/cast v1.9.2 // indirect
|
github.com/spf13/cast v1.9.2 // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
github.com/wailsapp/go-webview2 v1.0.21 // indirect
|
github.com/wailsapp/go-webview2 v1.0.21 // indirect
|
||||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/crypto v0.38.0 // indirect
|
golang.org/x/crypto v0.38.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
|
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
|
||||||
golang.org/x/net v0.40.0 // indirect
|
golang.org/x/net v0.40.0 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
golang.org/x/text v0.25.0 // indirect
|
golang.org/x/text v0.25.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.34.1 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
64
go.sum
64
go.sum
@@ -13,13 +13,23 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd
|
|||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
|
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||||
|
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||||
|
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||||
|
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
|
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
|
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
|
||||||
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
|
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
|
||||||
|
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
|
||||||
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||||
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||||
@@ -30,6 +40,12 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
|||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||||
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
|
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||||
|
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||||
@@ -42,22 +58,41 @@ github.com/go-git/go-git/v5 v5.16.0 h1:k3kuOEpkc0DeY7xlL6NaaNg39xdgQbtH5mwCafHO9
|
|||||||
github.com/go-git/go-git/v5 v5.16.0/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
github.com/go-git/go-git/v5 v5.16.0/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
||||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||||
|
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
||||||
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||||
|
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||||
|
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||||
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
@@ -69,14 +104,23 @@ github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed
|
|||||||
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/lmittmann/tint v1.1.1 h1:xmmGuinUsCSxWdwH1OqMUQ4tzQsq3BdjJLAAmVKJ9Dw=
|
github.com/lmittmann/tint v1.1.1 h1:xmmGuinUsCSxWdwH1OqMUQ4tzQsq3BdjJLAAmVKJ9Dw=
|
||||||
github.com/lmittmann/tint v1.1.1/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
github.com/lmittmann/tint v1.1.1/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||||
|
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
|
||||||
|
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
|
||||||
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
|
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
|
||||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
@@ -114,12 +158,23 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
|
|||||||
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||||
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
github.com/wailsapp/go-webview2 v1.0.21 h1:k3dtoZU4KCoN/AEIbWiPln3P2661GtA2oEgA2Pb+maA=
|
github.com/wailsapp/go-webview2 v1.0.21 h1:k3dtoZU4KCoN/AEIbWiPln3P2661GtA2oEgA2Pb+maA=
|
||||||
github.com/wailsapp/go-webview2 v1.0.21/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
github.com/wailsapp/go-webview2 v1.0.21/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||||
@@ -130,6 +185,9 @@ github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM
|
|||||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
|
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
|
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||||
@@ -147,6 +205,7 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
@@ -157,6 +216,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|||||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||||
|
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
@@ -167,5 +228,8 @@ gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
|||||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
|
@@ -229,21 +229,48 @@ func (cs *ConfigService) Get(key string) interface{} {
|
|||||||
return cs.viper.Get(key)
|
return cs.viper.Get(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetConfig 重置为默认配置
|
// ResetConfig 强制重置所有配置为默认值
|
||||||
func (cs *ConfigService) ResetConfig() error {
|
func (cs *ConfigService) ResetConfig() {
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
defer cs.mu.Unlock()
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
// 重新设置默认值
|
defaultConfig := models.NewDefaultAppConfig()
|
||||||
setDefaults(cs.viper)
|
|
||||||
|
|
||||||
// 使用 WriteConfig 写入配置文件(会触发文件监听)
|
// 通用设置 - 批量设置到viper中
|
||||||
|
cs.viper.Set("general.always_on_top", defaultConfig.General.AlwaysOnTop)
|
||||||
|
cs.viper.Set("general.data_path", defaultConfig.General.DataPath)
|
||||||
|
cs.viper.Set("general.enable_global_hotkey", defaultConfig.General.EnableGlobalHotkey)
|
||||||
|
cs.viper.Set("general.global_hotkey.ctrl", defaultConfig.General.GlobalHotkey.Ctrl)
|
||||||
|
cs.viper.Set("general.global_hotkey.shift", defaultConfig.General.GlobalHotkey.Shift)
|
||||||
|
cs.viper.Set("general.global_hotkey.alt", defaultConfig.General.GlobalHotkey.Alt)
|
||||||
|
cs.viper.Set("general.global_hotkey.win", defaultConfig.General.GlobalHotkey.Win)
|
||||||
|
cs.viper.Set("general.global_hotkey.key", defaultConfig.General.GlobalHotkey.Key)
|
||||||
|
|
||||||
|
// 编辑设置 - 批量设置到viper中
|
||||||
|
cs.viper.Set("editing.font_size", defaultConfig.Editing.FontSize)
|
||||||
|
cs.viper.Set("editing.font_family", defaultConfig.Editing.FontFamily)
|
||||||
|
cs.viper.Set("editing.font_weight", defaultConfig.Editing.FontWeight)
|
||||||
|
cs.viper.Set("editing.line_height", defaultConfig.Editing.LineHeight)
|
||||||
|
cs.viper.Set("editing.enable_tab_indent", defaultConfig.Editing.EnableTabIndent)
|
||||||
|
cs.viper.Set("editing.tab_size", defaultConfig.Editing.TabSize)
|
||||||
|
cs.viper.Set("editing.tab_type", defaultConfig.Editing.TabType)
|
||||||
|
cs.viper.Set("editing.auto_save_delay", defaultConfig.Editing.AutoSaveDelay)
|
||||||
|
|
||||||
|
// 外观设置 - 批量设置到viper中
|
||||||
|
cs.viper.Set("appearance.language", defaultConfig.Appearance.Language)
|
||||||
|
|
||||||
|
// 元数据 - 批量设置到viper中
|
||||||
|
cs.viper.Set("metadata.version", defaultConfig.Metadata.Version)
|
||||||
|
cs.viper.Set("metadata.last_updated", time.Now())
|
||||||
|
|
||||||
|
// 一次性写入配置文件,触发配置变更通知
|
||||||
if err := cs.viper.WriteConfig(); err != nil {
|
if err := cs.viper.WriteConfig(); err != nil {
|
||||||
return &ConfigError{Operation: "reset_config", Err: err}
|
cs.logger.Error("Config: Failed to write config during reset", "error", err)
|
||||||
|
} else {
|
||||||
|
cs.logger.Info("Config: All settings have been reset to defaults")
|
||||||
|
// 手动触发配置变更检查,确保通知系统能感知到变更
|
||||||
|
cs.notificationService.CheckConfigChanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
cs.logger.Info("Config: Successfully reset to default configuration")
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetHotkeyChangeCallback 设置热键配置变更回调
|
// SetHotkeyChangeCallback 设置热键配置变更回调
|
||||||
|
156
internal/services/http_service.go
Normal file
156
internal/services/http_service.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/services/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTPService HTTP 服务
|
||||||
|
type HTTPService struct {
|
||||||
|
logger *log.LoggerService
|
||||||
|
server *http.Server
|
||||||
|
wsService *WebSocketService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTTPService 创建 HTTP 服务
|
||||||
|
func NewHTTPService(logger *log.LoggerService, wsService *WebSocketService) *HTTPService {
|
||||||
|
if logger == nil {
|
||||||
|
logger = log.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &HTTPService{
|
||||||
|
logger: logger,
|
||||||
|
wsService: wsService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartServer 启动 HTTP 服务器
|
||||||
|
func (hs *HTTPService) StartServer(port string) error {
|
||||||
|
// 设置 Gin 为发布模式
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
|
||||||
|
// 创建 Gin 路由器
|
||||||
|
router := gin.New()
|
||||||
|
|
||||||
|
// 添加中间件
|
||||||
|
router.Use(gin.Recovery())
|
||||||
|
router.Use(hs.corsMiddleware())
|
||||||
|
|
||||||
|
// 设置路由
|
||||||
|
hs.setupRoutes(router)
|
||||||
|
|
||||||
|
// 创建 HTTP 服务器
|
||||||
|
hs.server = &http.Server{
|
||||||
|
Addr: ":" + port,
|
||||||
|
Handler: router,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
MaxHeaderBytes: 1 << 20, // 1MB
|
||||||
|
}
|
||||||
|
|
||||||
|
hs.logger.Info("HTTP: Starting server", "port", port)
|
||||||
|
|
||||||
|
// 启动服务器
|
||||||
|
go func() {
|
||||||
|
if err := hs.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
hs.logger.Error("HTTP: Server failed to start", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupRoutes 设置路由
|
||||||
|
func (hs *HTTPService) setupRoutes(router *gin.Engine) {
|
||||||
|
// WebSocket 端点
|
||||||
|
router.GET("/ws/migration", hs.handleWebSocket)
|
||||||
|
|
||||||
|
// API 端点组
|
||||||
|
api := router.Group("/api")
|
||||||
|
{
|
||||||
|
api.GET("/health", hs.handleHealth)
|
||||||
|
api.GET("/ws/clients", hs.handleWSClients)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWebSocket 处理 WebSocket 连接
|
||||||
|
func (hs *HTTPService) handleWebSocket(c *gin.Context) {
|
||||||
|
if hs.wsService == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "WebSocket service not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hs.wsService.HandleUpgrade(c.Writer, c.Request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHealth 健康检查端点
|
||||||
|
func (hs *HTTPService) handleHealth(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": "ok",
|
||||||
|
"timestamp": time.Now().Unix(),
|
||||||
|
"service": "voidraft-http",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWSClients 获取 WebSocket 客户端数量
|
||||||
|
func (hs *HTTPService) handleWSClients(c *gin.Context) {
|
||||||
|
if hs.wsService == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "WebSocket service not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
count := hs.wsService.GetConnectedClientsCount()
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"connected_clients": count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// corsMiddleware CORS 中间件
|
||||||
|
func (hs *HTTPService) corsMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
|
||||||
|
c.Header("Access-Control-Allow-Credentials", "true")
|
||||||
|
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopServer 停止 HTTP 服务器
|
||||||
|
func (hs *HTTPService) StopServer() error {
|
||||||
|
if hs.server == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hs.logger.Info("HTTP: Stopping server...")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
return hs.server.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceShutdown 服务关闭
|
||||||
|
func (hs *HTTPService) ServiceShutdown() error {
|
||||||
|
hs.logger.Info("HTTP: Service is shutting down...")
|
||||||
|
|
||||||
|
if err := hs.StopServer(); err != nil {
|
||||||
|
hs.logger.Error("HTTP: Failed to stop server", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hs.logger.Info("HTTP: Service shutdown completed")
|
||||||
|
return nil
|
||||||
|
}
|
@@ -3,7 +3,6 @@ package services
|
|||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -19,40 +18,26 @@ import (
|
|||||||
type MigrationStatus string
|
type MigrationStatus string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MigrationStatusIdle MigrationStatus = "idle" // 空闲状态
|
|
||||||
MigrationStatusPreparing MigrationStatus = "preparing" // 准备中
|
|
||||||
MigrationStatusMigrating MigrationStatus = "migrating" // 迁移中
|
MigrationStatusMigrating MigrationStatus = "migrating" // 迁移中
|
||||||
MigrationStatusCompleted MigrationStatus = "completed" // 完成
|
MigrationStatusCompleted MigrationStatus = "completed" // 完成
|
||||||
MigrationStatusFailed MigrationStatus = "failed" // 失败
|
MigrationStatusFailed MigrationStatus = "failed" // 失败
|
||||||
MigrationStatusCancelled MigrationStatus = "cancelled" // 取消
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// MigrationProgress 迁移进度信息
|
// MigrationProgress 迁移进度信息
|
||||||
type MigrationProgress struct {
|
type MigrationProgress struct {
|
||||||
Status MigrationStatus `json:"status"` // 迁移状态
|
Status MigrationStatus `json:"status"` // 迁移状态
|
||||||
CurrentFile string `json:"currentFile"` // 当前处理的文件
|
Progress float64 `json:"progress"` // 进度百分比 (0-100)
|
||||||
ProcessedFiles int `json:"processedFiles"` // 已处理文件数
|
Error string `json:"error,omitempty"` // 错误信息
|
||||||
TotalFiles int `json:"totalFiles"` // 总文件数
|
|
||||||
ProcessedBytes int64 `json:"processedBytes"` // 已处理字节数
|
|
||||||
TotalBytes int64 `json:"totalBytes"` // 总字节数
|
|
||||||
Progress float64 `json:"progress"` // 进度百分比 (0-100)
|
|
||||||
Message string `json:"message"` // 状态消息
|
|
||||||
Error string `json:"error,omitempty"` // 错误信息
|
|
||||||
StartTime time.Time `json:"startTime"` // 开始时间
|
|
||||||
EstimatedTime time.Duration `json:"estimatedTime"` // 估计剩余时间
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MigrationProgressCallback 进度回调函数类型
|
|
||||||
type MigrationProgressCallback func(progress MigrationProgress)
|
|
||||||
|
|
||||||
// MigrationService 迁移服务
|
// MigrationService 迁移服务
|
||||||
type MigrationService struct {
|
type MigrationService struct {
|
||||||
logger *log.LoggerService
|
logger *log.LoggerService
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
currentProgress MigrationProgress
|
currentProgress MigrationProgress
|
||||||
progressCallback MigrationProgressCallback
|
cancelFunc context.CancelFunc
|
||||||
cancelFunc context.CancelFunc
|
ctx context.Context
|
||||||
ctx context.Context
|
progressBroadcaster func(MigrationProgress) // WebSocket广播函数
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMigrationService 创建迁移服务
|
// NewMigrationService 创建迁移服务
|
||||||
@@ -64,18 +49,12 @@ func NewMigrationService(logger *log.LoggerService) *MigrationService {
|
|||||||
return &MigrationService{
|
return &MigrationService{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
currentProgress: MigrationProgress{
|
currentProgress: MigrationProgress{
|
||||||
Status: MigrationStatusIdle,
|
Status: MigrationStatusCompleted, // 初始状态为完成
|
||||||
|
Progress: 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetProgressCallback 设置进度回调
|
|
||||||
func (ms *MigrationService) SetProgressCallback(callback MigrationProgressCallback) {
|
|
||||||
ms.mu.Lock()
|
|
||||||
defer ms.mu.Unlock()
|
|
||||||
ms.progressCallback = callback
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetProgress 获取当前进度
|
// GetProgress 获取当前进度
|
||||||
func (ms *MigrationService) GetProgress() MigrationProgress {
|
func (ms *MigrationService) GetProgress() MigrationProgress {
|
||||||
ms.mu.RLock()
|
ms.mu.RLock()
|
||||||
@@ -83,15 +62,15 @@ func (ms *MigrationService) GetProgress() MigrationProgress {
|
|||||||
return ms.currentProgress
|
return ms.currentProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateProgress 更新进度并触发回调
|
// updateProgress 更新进度
|
||||||
func (ms *MigrationService) updateProgress(progress MigrationProgress) {
|
func (ms *MigrationService) updateProgress(progress MigrationProgress) {
|
||||||
ms.mu.Lock()
|
ms.mu.Lock()
|
||||||
ms.currentProgress = progress
|
ms.currentProgress = progress
|
||||||
callback := ms.progressCallback
|
|
||||||
ms.mu.Unlock()
|
ms.mu.Unlock()
|
||||||
|
|
||||||
if callback != nil {
|
// 通过WebSocket广播进度
|
||||||
callback(progress)
|
if ms.progressBroadcaster != nil {
|
||||||
|
ms.progressBroadcaster(progress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,22 +90,18 @@ func (ms *MigrationService) MigrateDirectory(srcPath, dstPath string) error {
|
|||||||
ms.mu.Unlock()
|
ms.mu.Unlock()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
ms.logger.Info("Migration: Starting directory migration",
|
ms.logger.Info("Migration: Starting directory migration", "from", srcPath, "to", dstPath)
|
||||||
"from", srcPath,
|
|
||||||
"to", dstPath)
|
|
||||||
|
|
||||||
// 初始化进度
|
// 初始化进度
|
||||||
progress := MigrationProgress{
|
progress := MigrationProgress{
|
||||||
Status: MigrationStatusPreparing,
|
Status: MigrationStatusMigrating,
|
||||||
Message: "Preparing migration...",
|
Progress: 0,
|
||||||
StartTime: time.Now(),
|
|
||||||
}
|
}
|
||||||
ms.updateProgress(progress)
|
ms.updateProgress(progress)
|
||||||
|
|
||||||
// 检查源目录是否存在
|
// 检查源目录是否存在
|
||||||
if _, err := os.Stat(srcPath); os.IsNotExist(err) {
|
if _, err := os.Stat(srcPath); os.IsNotExist(err) {
|
||||||
progress.Status = MigrationStatusCompleted
|
progress.Status = MigrationStatusCompleted
|
||||||
progress.Message = "Source directory does not exist, skipping migration"
|
|
||||||
progress.Progress = 100
|
progress.Progress = 100
|
||||||
ms.updateProgress(progress)
|
ms.updateProgress(progress)
|
||||||
return nil
|
return nil
|
||||||
@@ -137,69 +112,38 @@ func (ms *MigrationService) MigrateDirectory(srcPath, dstPath string) error {
|
|||||||
dstAbs, _ := filepath.Abs(dstPath)
|
dstAbs, _ := filepath.Abs(dstPath)
|
||||||
if srcAbs == dstAbs {
|
if srcAbs == dstAbs {
|
||||||
progress.Status = MigrationStatusCompleted
|
progress.Status = MigrationStatusCompleted
|
||||||
progress.Message = "Paths are identical, no migration needed"
|
|
||||||
progress.Progress = 100
|
progress.Progress = 100
|
||||||
ms.updateProgress(progress)
|
ms.updateProgress(progress)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查目标路径是否是源路径的子目录,防止无限递归复制
|
// 检查目标路径是否是源路径的子目录
|
||||||
if ms.isSubDirectory(srcAbs, dstAbs) {
|
if ms.isSubDirectory(srcAbs, dstAbs) {
|
||||||
progress.Status = MigrationStatusFailed
|
progress.Status = MigrationStatusFailed
|
||||||
progress.Error = "Target path cannot be a subdirectory of source path, this would cause infinite recursive copying"
|
progress.Error = "Target path cannot be a subdirectory of source path"
|
||||||
ms.updateProgress(progress)
|
ms.updateProgress(progress)
|
||||||
return fmt.Errorf("target path cannot be a subdirectory of source path: src=%s, dst=%s", srcAbs, dstAbs)
|
return fmt.Errorf("target path cannot be a subdirectory of source path")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算目录大小(用于显示进度)
|
|
||||||
totalFiles, totalBytes, err := ms.calculateDirectorySize(ctx, srcPath)
|
|
||||||
if err != nil {
|
|
||||||
progress.Status = MigrationStatusFailed
|
|
||||||
progress.Error = fmt.Sprintf("Failed to calculate directory size: %v", err)
|
|
||||||
ms.updateProgress(progress)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
progress.TotalFiles = totalFiles
|
|
||||||
progress.TotalBytes = totalBytes
|
|
||||||
progress.Status = MigrationStatusMigrating
|
|
||||||
progress.Message = "Starting atomic migration..."
|
|
||||||
ms.updateProgress(progress)
|
|
||||||
|
|
||||||
// 执行原子迁移
|
// 执行原子迁移
|
||||||
err = ms.atomicMove(ctx, srcPath, dstPath, &progress)
|
err := ms.atomicMove(ctx, srcPath, dstPath, &progress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(ctx.Err(), context.Canceled) {
|
progress.Status = MigrationStatusFailed
|
||||||
progress.Status = MigrationStatusCancelled
|
progress.Error = err.Error()
|
||||||
progress.Error = "Migration cancelled"
|
|
||||||
} else {
|
|
||||||
progress.Status = MigrationStatusFailed
|
|
||||||
progress.Error = fmt.Sprintf("Migration failed: %v", err)
|
|
||||||
}
|
|
||||||
ms.updateProgress(progress)
|
ms.updateProgress(progress)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 迁移完成
|
// 迁移完成
|
||||||
progress.Status = MigrationStatusCompleted
|
progress.Status = MigrationStatusCompleted
|
||||||
progress.Message = "Migration completed"
|
|
||||||
progress.Progress = 100
|
progress.Progress = 100
|
||||||
progress.ProcessedFiles = totalFiles
|
|
||||||
progress.ProcessedBytes = totalBytes
|
|
||||||
duration := time.Since(progress.StartTime)
|
|
||||||
ms.updateProgress(progress)
|
ms.updateProgress(progress)
|
||||||
|
|
||||||
ms.logger.Info("Migration: Directory migration completed",
|
ms.logger.Info("Migration: Directory migration completed", "from", srcPath, "to", dstPath)
|
||||||
"from", srcPath,
|
|
||||||
"to", dstPath,
|
|
||||||
"duration", duration,
|
|
||||||
"files", totalFiles,
|
|
||||||
"bytes", totalBytes)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// atomicMove 原子移动目录 - 使用压缩-移动-解压的方式
|
// atomicMove 原子移动目录
|
||||||
func (ms *MigrationService) atomicMove(ctx context.Context, srcPath, dstPath string, progress *MigrationProgress) error {
|
func (ms *MigrationService) atomicMove(ctx context.Context, srcPath, dstPath string, progress *MigrationProgress) error {
|
||||||
// 检查是否取消
|
// 检查是否取消
|
||||||
select {
|
select {
|
||||||
@@ -211,125 +155,98 @@ func (ms *MigrationService) atomicMove(ctx context.Context, srcPath, dstPath str
|
|||||||
// 确保目标目录的父目录存在
|
// 确保目标目录的父目录存在
|
||||||
dstParent := filepath.Dir(dstPath)
|
dstParent := filepath.Dir(dstPath)
|
||||||
if err := os.MkdirAll(dstParent, 0755); err != nil {
|
if err := os.MkdirAll(dstParent, 0755); err != nil {
|
||||||
return fmt.Errorf("Failed to create target parent directory: %v", err)
|
return fmt.Errorf("Failed to create target parent directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查目标路径情况
|
// 检查目标路径情况
|
||||||
if stat, err := os.Stat(dstPath); err == nil {
|
if stat, err := os.Stat(dstPath); err == nil {
|
||||||
if !stat.IsDir() {
|
if !stat.IsDir() {
|
||||||
return fmt.Errorf("Target path exists but is not a directory: %s", dstPath)
|
return fmt.Errorf("Target path exists but is not a directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查目录是否为空
|
|
||||||
isEmpty, err := ms.isDirectoryEmpty(dstPath)
|
isEmpty, err := ms.isDirectoryEmpty(dstPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Failed to check if target directory is empty: %v", err)
|
return fmt.Errorf("Failed to check target directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isEmpty {
|
if !isEmpty {
|
||||||
return fmt.Errorf("Target directory is not empty: %s", dstPath)
|
return fmt.Errorf("Target directory is not empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 目录存在但为空,可以继续迁移
|
|
||||||
ms.logger.Info("Migration: Target directory exists but is empty, proceeding with migration")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试直接重命名(如果在同一分区,这会很快)
|
// 尝试直接重命名(如果在同一分区,这会很快)
|
||||||
progress.Message = "Attempting fast move..."
|
progress.Progress = 20
|
||||||
ms.updateProgress(*progress)
|
ms.updateProgress(*progress)
|
||||||
|
|
||||||
if err := os.Rename(srcPath, dstPath); err == nil {
|
if err := os.Rename(srcPath, dstPath); err == nil {
|
||||||
// 重命名成功,这是最快的方式
|
|
||||||
ms.logger.Info("Migration: Fast rename successful")
|
ms.logger.Info("Migration: Fast rename successful")
|
||||||
return nil
|
return nil
|
||||||
|
} else {
|
||||||
|
ms.logger.Info("Migration: Fast rename failed, using copy method", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重命名失败(可能跨分区),使用原子压缩迁移
|
// 重命名失败,使用压缩迁移
|
||||||
progress.Message = "Starting atomic compress migration..."
|
progress.Progress = 30
|
||||||
ms.updateProgress(*progress)
|
ms.updateProgress(*progress)
|
||||||
|
|
||||||
return ms.atomicCompressMove(ctx, srcPath, dstPath, progress)
|
return ms.atomicCompressMove(ctx, srcPath, dstPath, progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
// atomicCompressMove 原子压缩迁移 - 压缩、移动、解压、清理
|
// atomicCompressMove 原子压缩迁移
|
||||||
func (ms *MigrationService) atomicCompressMove(ctx context.Context, srcPath, dstPath string, progress *MigrationProgress) error {
|
func (ms *MigrationService) atomicCompressMove(ctx context.Context, srcPath, dstPath string, progress *MigrationProgress) error {
|
||||||
// 创建临时压缩文件
|
|
||||||
tempDir := os.TempDir()
|
tempDir := os.TempDir()
|
||||||
tempZipFile := filepath.Join(tempDir, fmt.Sprintf("voidraft_migration_%d.zip", time.Now().UnixNano()))
|
tempZipFile := filepath.Join(tempDir, fmt.Sprintf("voidraft_migration_%d.zip", time.Now().UnixNano()))
|
||||||
|
|
||||||
// 确保临时文件在函数结束时被清理
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := os.Remove(tempZipFile); err != nil && !os.IsNotExist(err) {
|
if err := os.Remove(tempZipFile); err != nil && !os.IsNotExist(err) {
|
||||||
ms.logger.Error("Migration: Failed to clean up temporary zip file", "file", tempZipFile, "error", err)
|
ms.logger.Error("Migration: Failed to clean up temporary zip file", "error", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// 第一步: 压缩源目录
|
// 压缩源目录
|
||||||
progress.Message = "Compressing source directory..."
|
progress.Progress = 40
|
||||||
progress.Progress = 10
|
|
||||||
ms.updateProgress(*progress)
|
ms.updateProgress(*progress)
|
||||||
|
|
||||||
if err := ms.compressDirectory(ctx, srcPath, tempZipFile, progress); err != nil {
|
if err := ms.compressDirectory(ctx, srcPath, tempZipFile); err != nil {
|
||||||
return fmt.Errorf("Failed to compress source directory: %v", err)
|
return fmt.Errorf("Failed to compress source directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否取消
|
// 解压到目标位置
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
// 第二步: 解压到目标位置
|
|
||||||
progress.Message = "Extracting to target location..."
|
|
||||||
progress.Progress = 70
|
progress.Progress = 70
|
||||||
ms.updateProgress(*progress)
|
ms.updateProgress(*progress)
|
||||||
|
|
||||||
if err := ms.extractToDirectory(ctx, tempZipFile, dstPath, progress); err != nil {
|
if err := ms.extractToDirectory(ctx, tempZipFile, dstPath); err != nil {
|
||||||
return fmt.Errorf("Failed to extract to target location: %v", err)
|
return fmt.Errorf("Failed to extract to target location")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否取消
|
// 检查是否取消
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
// 如果取消,需要清理已解压的目标目录
|
|
||||||
os.RemoveAll(dstPath)
|
os.RemoveAll(dstPath)
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第三步: 删除源目录
|
// 删除源目录
|
||||||
progress.Message = "Cleaning up source directory..."
|
|
||||||
progress.Progress = 90
|
progress.Progress = 90
|
||||||
ms.updateProgress(*progress)
|
ms.updateProgress(*progress)
|
||||||
|
|
||||||
if err := os.RemoveAll(srcPath); err != nil {
|
if err := os.RemoveAll(srcPath); err != nil {
|
||||||
ms.logger.Error("Migration: Failed to remove source directory", "error", err)
|
ms.logger.Error("Migration: Failed to remove source directory", "error", err)
|
||||||
// 不返回错误,因为迁移已经成功
|
|
||||||
}
|
}
|
||||||
|
|
||||||
progress.Message = "Migration completed"
|
|
||||||
progress.Progress = 100
|
|
||||||
ms.updateProgress(*progress)
|
|
||||||
|
|
||||||
ms.logger.Info("Migration: Atomic compress-move completed successfully")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// compressDirectory 压缩目录到zip文件
|
// compressDirectory 压缩目录到zip文件
|
||||||
func (ms *MigrationService) compressDirectory(ctx context.Context, srcDir, zipFile string, progress *MigrationProgress) error {
|
func (ms *MigrationService) compressDirectory(ctx context.Context, srcDir, zipFile string) error {
|
||||||
// 创建zip文件
|
|
||||||
zipWriter, err := os.Create(zipFile)
|
zipWriter, err := os.Create(zipFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Failed to create zip file: %v", err)
|
return fmt.Errorf("Failed to create temporary file")
|
||||||
}
|
}
|
||||||
defer zipWriter.Close()
|
defer zipWriter.Close()
|
||||||
|
|
||||||
// 创建zip writer
|
|
||||||
zw := zip.NewWriter(zipWriter)
|
zw := zip.NewWriter(zipWriter)
|
||||||
defer zw.Close()
|
defer zw.Close()
|
||||||
|
|
||||||
// 遍历源目录并添加到zip
|
|
||||||
return filepath.Walk(srcDir, func(filePath string, info os.FileInfo, err error) error {
|
return filepath.Walk(srcDir, func(filePath string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -342,31 +259,22 @@ func (ms *MigrationService) compressDirectory(ctx context.Context, srcDir, zipFi
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算相对路径
|
|
||||||
relPath, err := filepath.Rel(srcDir, filePath)
|
relPath, err := filepath.Rel(srcDir, filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跳过根目录
|
|
||||||
if relPath == "." {
|
if relPath == "." {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新当前处理的文件
|
|
||||||
progress.CurrentFile = relPath
|
|
||||||
ms.updateProgress(*progress)
|
|
||||||
|
|
||||||
// 创建zip中的文件头
|
|
||||||
header, err := zip.FileInfoHeader(info)
|
header, err := zip.FileInfoHeader(info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用/作为路径分隔符(zip标准)
|
|
||||||
header.Name = strings.ReplaceAll(relPath, string(filepath.Separator), "/")
|
header.Name = strings.ReplaceAll(relPath, string(filepath.Separator), "/")
|
||||||
|
|
||||||
// 处理目录
|
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
header.Name += "/"
|
header.Name += "/"
|
||||||
header.Method = zip.Store
|
header.Method = zip.Store
|
||||||
@@ -374,13 +282,11 @@ func (ms *MigrationService) compressDirectory(ctx context.Context, srcDir, zipFi
|
|||||||
header.Method = zip.Deflate
|
header.Method = zip.Deflate
|
||||||
}
|
}
|
||||||
|
|
||||||
// 写入zip文件头
|
|
||||||
writer, err := zw.CreateHeader(header)
|
writer, err := zw.CreateHeader(header)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是文件,复制内容
|
|
||||||
if !info.IsDir() {
|
if !info.IsDir() {
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -399,20 +305,17 @@ func (ms *MigrationService) compressDirectory(ctx context.Context, srcDir, zipFi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// extractToDirectory 从zip文件解压到目录
|
// extractToDirectory 从zip文件解压到目录
|
||||||
func (ms *MigrationService) extractToDirectory(ctx context.Context, zipFile, dstDir string, progress *MigrationProgress) error {
|
func (ms *MigrationService) extractToDirectory(ctx context.Context, zipFile, dstDir string) error {
|
||||||
// 打开zip文件
|
|
||||||
reader, err := zip.OpenReader(zipFile)
|
reader, err := zip.OpenReader(zipFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Failed to open zip file: %v", err)
|
return fmt.Errorf("Failed to open temporary file")
|
||||||
}
|
}
|
||||||
defer reader.Close()
|
defer reader.Close()
|
||||||
|
|
||||||
// 确保目标目录存在
|
|
||||||
if err := os.MkdirAll(dstDir, 0755); err != nil {
|
if err := os.MkdirAll(dstDir, 0755); err != nil {
|
||||||
return fmt.Errorf("Failed to create target directory: %v", err)
|
return fmt.Errorf("Failed to create target directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解压每个文件
|
|
||||||
for _, file := range reader.File {
|
for _, file := range reader.File {
|
||||||
// 检查是否取消
|
// 检查是否取消
|
||||||
select {
|
select {
|
||||||
@@ -421,19 +324,13 @@ func (ms *MigrationService) extractToDirectory(ctx context.Context, zipFile, dst
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新当前处理的文件
|
|
||||||
progress.CurrentFile = file.Name
|
|
||||||
ms.updateProgress(*progress)
|
|
||||||
|
|
||||||
// 构建目标文件路径
|
|
||||||
dstPath := filepath.Join(dstDir, file.Name)
|
dstPath := filepath.Join(dstDir, file.Name)
|
||||||
|
|
||||||
// 安全检查:防止zip slip攻击
|
// 安全检查:防止zip slip攻击
|
||||||
if !strings.HasPrefix(dstPath, filepath.Clean(dstDir)+string(os.PathSeparator)) {
|
if !strings.HasPrefix(dstPath, filepath.Clean(dstDir)+string(os.PathSeparator)) {
|
||||||
return fmt.Errorf("Invalid file path: %s", file.Name)
|
return fmt.Errorf("Invalid file path in archive")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理目录
|
|
||||||
if file.FileInfo().IsDir() {
|
if file.FileInfo().IsDir() {
|
||||||
if err := os.MkdirAll(dstPath, file.FileInfo().Mode()); err != nil {
|
if err := os.MkdirAll(dstPath, file.FileInfo().Mode()); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -441,12 +338,10 @@ func (ms *MigrationService) extractToDirectory(ctx context.Context, zipFile, dst
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保父目录存在
|
|
||||||
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解压文件
|
|
||||||
if err := ms.extractFile(file, dstPath); err != nil {
|
if err := ms.extractFile(file, dstPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -457,21 +352,18 @@ func (ms *MigrationService) extractToDirectory(ctx context.Context, zipFile, dst
|
|||||||
|
|
||||||
// extractFile 解压单个文件
|
// extractFile 解压单个文件
|
||||||
func (ms *MigrationService) extractFile(file *zip.File, dstPath string) error {
|
func (ms *MigrationService) extractFile(file *zip.File, dstPath string) error {
|
||||||
// 打开zip中的文件
|
|
||||||
srcFile, err := file.Open()
|
srcFile, err := file.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer srcFile.Close()
|
defer srcFile.Close()
|
||||||
|
|
||||||
// 创建目标文件
|
|
||||||
dstFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, file.FileInfo().Mode())
|
dstFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, file.FileInfo().Mode())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer dstFile.Close()
|
defer dstFile.Close()
|
||||||
|
|
||||||
// 复制文件内容
|
|
||||||
_, err = io.Copy(dstFile, srcFile)
|
_, err = io.Copy(dstFile, srcFile)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -484,56 +376,23 @@ func (ms *MigrationService) isDirectoryEmpty(dirPath string) (bool, error) {
|
|||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
// 尝试读取一个条目
|
|
||||||
_, err = f.Readdir(1)
|
_, err = f.Readdir(1)
|
||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
// 目录为空
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
// 目录不为空
|
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isSubDirectory 检查target是否是parent的子目录
|
// isSubDirectory 检查target是否是parent的子目录
|
||||||
func (ms *MigrationService) isSubDirectory(parent, target string) bool {
|
func (ms *MigrationService) isSubDirectory(parent, target string) bool {
|
||||||
// 确保路径以分隔符结尾,以避免误判
|
|
||||||
parent = filepath.Clean(parent) + string(filepath.Separator)
|
parent = filepath.Clean(parent) + string(filepath.Separator)
|
||||||
target = filepath.Clean(target) + string(filepath.Separator)
|
target = filepath.Clean(target) + string(filepath.Separator)
|
||||||
|
|
||||||
// 检查target是否以parent开头
|
|
||||||
return len(target) > len(parent) && target[:len(parent)] == parent
|
return len(target) > len(parent) && target[:len(parent)] == parent
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateDirectorySize 计算目录大小和文件数
|
|
||||||
func (ms *MigrationService) calculateDirectorySize(ctx context.Context, dirPath string) (int, int64, error) {
|
|
||||||
var totalFiles int
|
|
||||||
var totalBytes int64
|
|
||||||
|
|
||||||
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否取消
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
if !info.IsDir() {
|
|
||||||
totalFiles++
|
|
||||||
totalBytes += info.Size()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
return totalFiles, totalBytes, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// CancelMigration 取消迁移
|
// CancelMigration 取消迁移
|
||||||
func (ms *MigrationService) CancelMigration() error {
|
func (ms *MigrationService) CancelMigration() error {
|
||||||
ms.mu.Lock()
|
ms.mu.Lock()
|
||||||
@@ -545,18 +404,22 @@ func (ms *MigrationService) CancelMigration() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("no active migration to cancel")
|
return fmt.Errorf("No active migration to cancel")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetProgressBroadcaster 设置进度广播函数
|
||||||
|
func (ms *MigrationService) SetProgressBroadcaster(broadcaster func(MigrationProgress)) {
|
||||||
|
ms.mu.Lock()
|
||||||
|
defer ms.mu.Unlock()
|
||||||
|
ms.progressBroadcaster = broadcaster
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceShutdown 服务关闭
|
// ServiceShutdown 服务关闭
|
||||||
func (ms *MigrationService) ServiceShutdown() error {
|
func (ms *MigrationService) ServiceShutdown() error {
|
||||||
ms.logger.Info("Migration: Service is shutting down...")
|
ms.logger.Info("Migration: Service is shutting down...")
|
||||||
|
|
||||||
// 取消正在进行的迁移
|
|
||||||
if err := ms.CancelMigration(); err != nil {
|
if err := ms.CancelMigration(); err != nil {
|
||||||
ms.logger.Debug("Migration: No active migration to cancel during shutdown")
|
ms.logger.Debug("Migration: No active migration to cancel during shutdown")
|
||||||
}
|
}
|
||||||
|
|
||||||
ms.logger.Info("Migration: Service shutdown completed")
|
ms.logger.Info("Migration: Service shutdown completed")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@@ -15,6 +15,8 @@ type ServiceManager struct {
|
|||||||
systemService *SystemService
|
systemService *SystemService
|
||||||
hotkeyService *HotkeyService
|
hotkeyService *HotkeyService
|
||||||
dialogService *DialogService
|
dialogService *DialogService
|
||||||
|
websocketService *WebSocketService
|
||||||
|
httpService *HTTPService
|
||||||
logger *log.LoggerService
|
logger *log.LoggerService
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,8 +43,26 @@ func NewServiceManager() *ServiceManager {
|
|||||||
// 初始化对话服务
|
// 初始化对话服务
|
||||||
dialogService := NewDialogService(logger)
|
dialogService := NewDialogService(logger)
|
||||||
|
|
||||||
|
// 初始化 WebSocket 服务
|
||||||
|
websocketService := NewWebSocketService(logger)
|
||||||
|
|
||||||
|
// 初始化 HTTP 服务
|
||||||
|
httpService := NewHTTPService(logger, websocketService)
|
||||||
|
|
||||||
|
// 设置迁移服务的WebSocket广播
|
||||||
|
migrationService.SetProgressBroadcaster(func(progress MigrationProgress) {
|
||||||
|
websocketService.BroadcastMigrationProgress(progress)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 启动 HTTP 服务器
|
||||||
|
err := httpService.StartServer("8899")
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to start HTTP server", "error", err)
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
// 使用新的配置通知系统设置热键配置变更监听
|
// 使用新的配置通知系统设置热键配置变更监听
|
||||||
err := configService.SetHotkeyChangeCallback(func(enable bool, hotkey *models.HotkeyCombo) error {
|
err = configService.SetHotkeyChangeCallback(func(enable bool, hotkey *models.HotkeyCombo) error {
|
||||||
return hotkeyService.UpdateHotkey(enable, hotkey)
|
return hotkeyService.UpdateHotkey(enable, hotkey)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -50,7 +70,7 @@ func NewServiceManager() *ServiceManager {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置数据路径变更监听
|
// 设置数据路径变更监听,处理配置重置和路径变更
|
||||||
err = configService.SetDataPathChangeCallback(func(oldPath, newPath string) error {
|
err = configService.SetDataPathChangeCallback(func(oldPath, newPath string) error {
|
||||||
return documentService.OnDataPathChanged(oldPath, newPath)
|
return documentService.OnDataPathChanged(oldPath, newPath)
|
||||||
})
|
})
|
||||||
@@ -73,6 +93,8 @@ func NewServiceManager() *ServiceManager {
|
|||||||
systemService: systemService,
|
systemService: systemService,
|
||||||
hotkeyService: hotkeyService,
|
hotkeyService: hotkeyService,
|
||||||
dialogService: dialogService,
|
dialogService: dialogService,
|
||||||
|
websocketService: websocketService,
|
||||||
|
httpService: httpService,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
159
internal/services/websocket_service.go
Normal file
159
internal/services/websocket_service.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/lxzan/gws"
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/services/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WebSocketService WebSocket 服务
|
||||||
|
type WebSocketService struct {
|
||||||
|
logger *log.LoggerService
|
||||||
|
upgrader *gws.Upgrader
|
||||||
|
clients map[*gws.Conn]bool
|
||||||
|
clientsMu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWebSocketService 创建 WebSocket 服务
|
||||||
|
func NewWebSocketService(logger *log.LoggerService) *WebSocketService {
|
||||||
|
if logger == nil {
|
||||||
|
logger = log.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
ws := &WebSocketService{
|
||||||
|
logger: logger,
|
||||||
|
clients: make(map[*gws.Conn]bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 WebSocket 升级器
|
||||||
|
ws.upgrader = gws.NewUpgrader(&WebSocketHandler{service: ws}, &gws.ServerOption{
|
||||||
|
ParallelEnabled: true,
|
||||||
|
Recovery: gws.Recovery,
|
||||||
|
PermessageDeflate: gws.PermessageDeflate{Enabled: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
return ws
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocketHandler WebSocket 事件处理器
|
||||||
|
type WebSocketHandler struct {
|
||||||
|
service *WebSocketService
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnOpen 连接建立时调用
|
||||||
|
func (h *WebSocketHandler) OnOpen(socket *gws.Conn) {
|
||||||
|
h.service.logger.Info("WebSocket: Client connected")
|
||||||
|
|
||||||
|
h.service.clientsMu.Lock()
|
||||||
|
h.service.clients[socket] = true
|
||||||
|
h.service.clientsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnClose 连接关闭时调用
|
||||||
|
func (h *WebSocketHandler) OnClose(socket *gws.Conn, err error) {
|
||||||
|
h.service.logger.Info("WebSocket: Client disconnected", "error", err)
|
||||||
|
|
||||||
|
h.service.clientsMu.Lock()
|
||||||
|
delete(h.service.clients, socket)
|
||||||
|
h.service.clientsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnPing 收到 Ping 时调用
|
||||||
|
func (h *WebSocketHandler) OnPing(socket *gws.Conn, payload []byte) {
|
||||||
|
_ = socket.WritePong(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnPong 收到 Pong 时调用
|
||||||
|
func (h *WebSocketHandler) OnPong(socket *gws.Conn, payload []byte) {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnMessage 收到消息时调用
|
||||||
|
func (h *WebSocketHandler) OnMessage(socket *gws.Conn, message *gws.Message) {
|
||||||
|
defer message.Close()
|
||||||
|
|
||||||
|
h.service.logger.Debug("WebSocket: Received message", "message", string(message.Bytes()))
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUpgrade 处理 WebSocket 升级请求
|
||||||
|
func (ws *WebSocketService) HandleUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||||
|
socket, err := ws.upgrader.Upgrade(w, r)
|
||||||
|
if err != nil {
|
||||||
|
ws.logger.Error("WebSocket: Failed to upgrade connection", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动读取循环(必须在 goroutine 中运行以防止阻塞)
|
||||||
|
go func() {
|
||||||
|
socket.ReadLoop()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastMessage 广播消息给所有连接的客户端
|
||||||
|
func (ws *WebSocketService) BroadcastMessage(messageType string, data interface{}) {
|
||||||
|
message := map[string]interface{}{
|
||||||
|
"type": messageType,
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(message)
|
||||||
|
if err != nil {
|
||||||
|
ws.logger.Error("WebSocket: Failed to marshal message", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.clientsMu.RLock()
|
||||||
|
clients := make([]*gws.Conn, 0, len(ws.clients))
|
||||||
|
for client := range ws.clients {
|
||||||
|
clients = append(clients, client)
|
||||||
|
}
|
||||||
|
ws.clientsMu.RUnlock()
|
||||||
|
|
||||||
|
// 使用广播器进行高效广播
|
||||||
|
broadcaster := gws.NewBroadcaster(gws.OpcodeText, jsonData)
|
||||||
|
defer broadcaster.Close()
|
||||||
|
|
||||||
|
for _, client := range clients {
|
||||||
|
if err := broadcaster.Broadcast(client); err != nil {
|
||||||
|
ws.logger.Error("WebSocket: Failed to broadcast to client", "error", err)
|
||||||
|
// 清理失效的连接
|
||||||
|
ws.clientsMu.Lock()
|
||||||
|
delete(ws.clients, client)
|
||||||
|
ws.clientsMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.logger.Debug("WebSocket: Broadcasted message", "type", messageType, "clients", len(clients))
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastMigrationProgress 广播迁移进度
|
||||||
|
func (ws *WebSocketService) BroadcastMigrationProgress(progress MigrationProgress) {
|
||||||
|
ws.BroadcastMessage("migration_progress", progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConnectedClientsCount 获取连接的客户端数量
|
||||||
|
func (ws *WebSocketService) GetConnectedClientsCount() int {
|
||||||
|
ws.clientsMu.RLock()
|
||||||
|
defer ws.clientsMu.RUnlock()
|
||||||
|
return len(ws.clients)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceShutdown 服务关闭
|
||||||
|
func (ws *WebSocketService) ServiceShutdown() error {
|
||||||
|
ws.logger.Info("WebSocket: Service is shutting down...")
|
||||||
|
|
||||||
|
// 关闭所有客户端连接
|
||||||
|
ws.clientsMu.Lock()
|
||||||
|
for client := range ws.clients {
|
||||||
|
_ = client.WriteClose(1000, []byte("Server shutting down"))
|
||||||
|
}
|
||||||
|
ws.clients = make(map[*gws.Conn]bool)
|
||||||
|
ws.clientsMu.Unlock()
|
||||||
|
|
||||||
|
ws.logger.Info("WebSocket: Service shutdown completed")
|
||||||
|
return nil
|
||||||
|
}
|
Reference in New Issue
Block a user