✨ Improved settings
This commit is contained in:
@@ -5,42 +5,6 @@
|
||||
// @ts-ignore: Unused imports
|
||||
import {Create as $Create} from "@wailsio/runtime";
|
||||
|
||||
/**
|
||||
* A Duration represents the elapsed time between two instants
|
||||
* as an int64 nanosecond count. The representation limits the
|
||||
* largest representable duration to approximately 290 years.
|
||||
*/
|
||||
export enum Duration {
|
||||
/**
|
||||
* The Go zero value for the underlying type of the enum.
|
||||
*/
|
||||
$zero = 0,
|
||||
|
||||
minDuration = -9223372036854775808,
|
||||
maxDuration = 9223372036854775807,
|
||||
|
||||
/**
|
||||
* Common durations. There is no definition for units of Day or larger
|
||||
* to avoid confusion across daylight savings time zone transitions.
|
||||
*
|
||||
* To count the number of units in a [Duration], divide:
|
||||
*
|
||||
* second := time.Second
|
||||
* fmt.Print(int64(second/time.Millisecond)) // prints 1000
|
||||
*
|
||||
* To convert an integer number of units to a Duration, multiply:
|
||||
*
|
||||
* seconds := 10
|
||||
* fmt.Print(time.Duration(seconds)*time.Second) // prints 10s
|
||||
*/
|
||||
Nanosecond = 1,
|
||||
Microsecond = 1000,
|
||||
Millisecond = 1000000,
|
||||
Second = 1000000000,
|
||||
Minute = 60000000000,
|
||||
Hour = 3600000000000,
|
||||
};
|
||||
|
||||
/**
|
||||
* A Time represents an instant in time with nanosecond precision.
|
||||
*
|
||||
|
@@ -35,7 +35,7 @@ export function GetConfig(): Promise<models$0.AppConfig | null> & { cancel(): vo
|
||||
}
|
||||
|
||||
/**
|
||||
* ResetConfig 重置为默认配置
|
||||
* ResetConfig 强制重置所有配置为默认值
|
||||
*/
|
||||
export function ResetConfig(): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3593047389) as any;
|
||||
|
@@ -51,10 +51,10 @@ export function ServiceShutdown(): Promise<void> & { cancel(): void } {
|
||||
}
|
||||
|
||||
/**
|
||||
* SetProgressCallback 设置进度回调
|
||||
* SetProgressBroadcaster 设置进度广播函数
|
||||
*/
|
||||
export function SetProgressCallback(callback: $models.MigrationProgressCallback): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(75752256, callback) as any;
|
||||
export function SetProgressBroadcaster(broadcaster: any): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3244071921, broadcaster) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
|
@@ -5,10 +5,6 @@
|
||||
// @ts-ignore: Unused imports
|
||||
import {Create as $Create} from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as time$0 from "../../../time/models.js";
|
||||
|
||||
/**
|
||||
* MemoryStats 内存统计信息
|
||||
*/
|
||||
@@ -85,88 +81,24 @@ export class MigrationProgress {
|
||||
*/
|
||||
"status": MigrationStatus;
|
||||
|
||||
/**
|
||||
* 当前处理的文件
|
||||
*/
|
||||
"currentFile": string;
|
||||
|
||||
/**
|
||||
* 已处理文件数
|
||||
*/
|
||||
"processedFiles": number;
|
||||
|
||||
/**
|
||||
* 总文件数
|
||||
*/
|
||||
"totalFiles": number;
|
||||
|
||||
/**
|
||||
* 已处理字节数
|
||||
*/
|
||||
"processedBytes": number;
|
||||
|
||||
/**
|
||||
* 总字节数
|
||||
*/
|
||||
"totalBytes": number;
|
||||
|
||||
/**
|
||||
* 进度百分比 (0-100)
|
||||
*/
|
||||
"progress": number;
|
||||
|
||||
/**
|
||||
* 状态消息
|
||||
*/
|
||||
"message": string;
|
||||
|
||||
/**
|
||||
* 错误信息
|
||||
*/
|
||||
"error"?: string;
|
||||
|
||||
/**
|
||||
* 开始时间
|
||||
*/
|
||||
"startTime": time$0.Time;
|
||||
|
||||
/**
|
||||
* 估计剩余时间
|
||||
*/
|
||||
"estimatedTime": time$0.Duration;
|
||||
|
||||
/** Creates a new MigrationProgress instance. */
|
||||
constructor($$source: Partial<MigrationProgress> = {}) {
|
||||
if (!("status" in $$source)) {
|
||||
this["status"] = ("" as MigrationStatus);
|
||||
}
|
||||
if (!("currentFile" in $$source)) {
|
||||
this["currentFile"] = "";
|
||||
}
|
||||
if (!("processedFiles" in $$source)) {
|
||||
this["processedFiles"] = 0;
|
||||
}
|
||||
if (!("totalFiles" in $$source)) {
|
||||
this["totalFiles"] = 0;
|
||||
}
|
||||
if (!("processedBytes" in $$source)) {
|
||||
this["processedBytes"] = 0;
|
||||
}
|
||||
if (!("totalBytes" in $$source)) {
|
||||
this["totalBytes"] = 0;
|
||||
}
|
||||
if (!("progress" in $$source)) {
|
||||
this["progress"] = 0;
|
||||
}
|
||||
if (!("message" in $$source)) {
|
||||
this["message"] = "";
|
||||
}
|
||||
if (!("startTime" in $$source)) {
|
||||
this["startTime"] = null;
|
||||
}
|
||||
if (!("estimatedTime" in $$source)) {
|
||||
this["estimatedTime"] = (0 as time$0.Duration);
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
@@ -180,11 +112,6 @@ export class MigrationProgress {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MigrationProgressCallback 进度回调函数类型
|
||||
*/
|
||||
export type MigrationProgressCallback = any;
|
||||
|
||||
/**
|
||||
* MigrationStatus 迁移状态
|
||||
*/
|
||||
@@ -194,16 +121,6 @@ export enum MigrationStatus {
|
||||
*/
|
||||
$zero = "",
|
||||
|
||||
/**
|
||||
* 空闲状态
|
||||
*/
|
||||
MigrationStatusIdle = "idle",
|
||||
|
||||
/**
|
||||
* 准备中
|
||||
*/
|
||||
MigrationStatusPreparing = "preparing",
|
||||
|
||||
/**
|
||||
* 迁移中
|
||||
*/
|
||||
@@ -218,9 +135,4 @@ export enum MigrationStatus {
|
||||
* 失败
|
||||
*/
|
||||
MigrationStatusFailed = "failed",
|
||||
|
||||
/**
|
||||
* 取消
|
||||
*/
|
||||
MigrationStatusCancelled = "cancelled",
|
||||
};
|
||||
|
1
frontend/components.d.ts
vendored
1
frontend/components.d.ts
vendored
@@ -9,7 +9,6 @@ export {}
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
MemoryMonitor: typeof import('./src/components/monitor/MemoryMonitor.vue')['default']
|
||||
MigrationProgress: typeof import('./src/components/migration/MigrationProgress.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
Toolbar: typeof import('./src/components/toolbar/Toolbar.vue')['default']
|
||||
|
@@ -1,540 +0,0 @@
|
||||
<template>
|
||||
<div v-if="visible" class="migration-overlay">
|
||||
<div class="migration-modal">
|
||||
<div class="migration-header">
|
||||
<h3>{{ t('migration.title') }}</h3>
|
||||
<div class="migration-status" :class="status">
|
||||
{{ getStatusText() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="migration-content">
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
:style="{ width: `${progress.progress || 0}%` }"
|
||||
:class="status"
|
||||
></div>
|
||||
</div>
|
||||
<div class="progress-text">
|
||||
{{ Math.round(progress.progress || 0) }}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="migration-details">
|
||||
<div v-if="progress.message" class="message">
|
||||
{{ progress.message }}
|
||||
</div>
|
||||
|
||||
<div v-if="progress.currentFile" class="current-file">
|
||||
<span class="label">{{ t('migration.currentFile') }}:</span>
|
||||
<span class="file-name">{{ progress.currentFile }}</span>
|
||||
</div>
|
||||
|
||||
<div class="stats" v-if="progress.totalFiles > 0">
|
||||
<div class="stat-item">
|
||||
<span class="label">{{ t('migration.files') }}:</span>
|
||||
<span class="value">{{ progress.processedFiles }} / {{ progress.totalFiles }}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat-item" v-if="progress.totalBytes > 0">
|
||||
<span class="label">{{ t('migration.size') }}:</span>
|
||||
<span class="value">{{ formatBytes(progress.processedBytes) }} / {{ formatBytes(progress.totalBytes) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat-item" v-if="progress.estimatedTime && status === 'migrating'">
|
||||
<span class="label">{{ t('migration.timeRemaining') }}:</span>
|
||||
<span class="value">{{ formatDuration(progress.estimatedTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="progress.error" class="error-message">
|
||||
<span class="error-icon">⚠️</span>
|
||||
{{ progress.error }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="migration-actions">
|
||||
<button
|
||||
v-if="status === 'completed'"
|
||||
@click="handleComplete"
|
||||
class="action-button success"
|
||||
>
|
||||
{{ t('migration.complete') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="status === 'failed'"
|
||||
@click="handleRetry"
|
||||
class="action-button retry"
|
||||
>
|
||||
{{ t('migration.retry') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="status === 'failed'"
|
||||
@click="handleClose"
|
||||
class="action-button secondary"
|
||||
>
|
||||
{{ t('migration.close') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, onUnmounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { MigrationService } from '../../../bindings/voidraft/internal/services';
|
||||
|
||||
interface MigrationProgress {
|
||||
status: string;
|
||||
currentFile: string;
|
||||
processedFiles: number;
|
||||
totalFiles: number;
|
||||
processedBytes: number;
|
||||
totalBytes: number;
|
||||
progress: number;
|
||||
message: string;
|
||||
error?: string;
|
||||
startTime: string;
|
||||
estimatedTime: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'complete'): void;
|
||||
(e: 'close'): void;
|
||||
(e: 'retry'): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
const { t } = useI18n();
|
||||
|
||||
// 迁移进度状态
|
||||
const progress = ref<MigrationProgress>({
|
||||
status: 'idle',
|
||||
currentFile: '',
|
||||
processedFiles: 0,
|
||||
totalFiles: 0,
|
||||
processedBytes: 0,
|
||||
totalBytes: 0,
|
||||
progress: 0,
|
||||
message: '',
|
||||
startTime: '',
|
||||
estimatedTime: 0
|
||||
});
|
||||
|
||||
const status = computed(() => progress.value.status);
|
||||
|
||||
// 定时器用于轮询进度
|
||||
let progressTimer: number | null = null;
|
||||
|
||||
// 轮询迁移进度
|
||||
const pollProgress = async () => {
|
||||
try {
|
||||
const currentProgress = await MigrationService.GetProgress();
|
||||
progress.value = currentProgress;
|
||||
|
||||
// 如果迁移完成或失败,停止轮询
|
||||
if (currentProgress.status === 'completed' || currentProgress.status === 'failed' || currentProgress.status === 'cancelled') {
|
||||
stopPolling();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get migration progress:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 开始轮询
|
||||
const startPolling = () => {
|
||||
if (progressTimer) return;
|
||||
|
||||
// 立即获取一次进度
|
||||
pollProgress();
|
||||
|
||||
// 每500ms轮询一次
|
||||
progressTimer = window.setInterval(pollProgress, 500);
|
||||
};
|
||||
|
||||
// 停止轮询
|
||||
const stopPolling = () => {
|
||||
if (progressTimer) {
|
||||
clearInterval(progressTimer);
|
||||
progressTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 监听可见性变化
|
||||
watch(() => props.visible, (visible) => {
|
||||
if (visible) {
|
||||
startPolling();
|
||||
} else {
|
||||
stopPolling();
|
||||
}
|
||||
});
|
||||
|
||||
// 组件挂载时开始轮询(如果可见)
|
||||
onMounted(() => {
|
||||
if (props.visible) {
|
||||
startPolling();
|
||||
}
|
||||
});
|
||||
|
||||
// 组件卸载时停止轮询
|
||||
onUnmounted(() => {
|
||||
stopPolling();
|
||||
});
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = () => {
|
||||
switch (status.value) {
|
||||
case 'preparing':
|
||||
return t('migration.preparing');
|
||||
case 'migrating':
|
||||
return t('migration.migrating');
|
||||
case 'completed':
|
||||
return t('migration.completed');
|
||||
case 'failed':
|
||||
return t('migration.failed');
|
||||
case 'cancelled':
|
||||
return t('migration.cancelled');
|
||||
default:
|
||||
return t('migration.idle');
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化字节数
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
// 格式化持续时间
|
||||
const formatDuration = (nanoseconds: number): string => {
|
||||
const seconds = Math.floor(nanoseconds / 1000000000);
|
||||
|
||||
if (seconds < 60) {
|
||||
return `${seconds}秒`;
|
||||
} else if (seconds < 3600) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `${minutes}分钟`;
|
||||
} else {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}小时${minutes}分钟`;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理完成
|
||||
const handleComplete = () => {
|
||||
emit('complete');
|
||||
};
|
||||
|
||||
// 处理重试
|
||||
const handleRetry = () => {
|
||||
emit('retry');
|
||||
};
|
||||
|
||||
// 处理关闭
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.migration-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.migration-modal {
|
||||
background-color: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #444444;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
animation: modalSlideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.migration-header {
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid #444444;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #e0e0e0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.migration-status {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
|
||||
&.preparing {
|
||||
color: #ffc107;
|
||||
background-color: rgba(255, 193, 7, 0.1);
|
||||
}
|
||||
|
||||
&.migrating {
|
||||
color: #17a2b8;
|
||||
background-color: rgba(23, 162, 184, 0.1);
|
||||
}
|
||||
|
||||
&.completed {
|
||||
color: #28a745;
|
||||
background-color: rgba(40, 167, 69, 0.1);
|
||||
}
|
||||
|
||||
&.failed {
|
||||
color: #dc3545;
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
}
|
||||
|
||||
&.cancelled {
|
||||
color: #6c757d;
|
||||
background-color: rgba(108, 117, 125, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.migration-content {
|
||||
padding: 20px 24px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background-color: #444444;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 4px;
|
||||
|
||||
&.preparing {
|
||||
background-color: #ffc107;
|
||||
}
|
||||
|
||||
&.migrating {
|
||||
background-color: #17a2b8;
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
rgba(255, 255, 255, 0.1) 25%,
|
||||
transparent 25%,
|
||||
transparent 50%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
rgba(255, 255, 255, 0.1) 75%,
|
||||
transparent 75%,
|
||||
transparent
|
||||
);
|
||||
background-size: 20px 20px;
|
||||
animation: progressStripes 1s linear infinite;
|
||||
}
|
||||
|
||||
&.completed {
|
||||
background-color: #28a745;
|
||||
}
|
||||
|
||||
&.failed {
|
||||
background-color: #dc3545;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
text-align: center;
|
||||
color: #b0b0b0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes progressStripes {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 20px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.migration-details {
|
||||
.message {
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.current-file {
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
|
||||
.label {
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
color: #e0e0e0;
|
||||
font-family: 'Consolas', 'Courier New', monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
|
||||
.label {
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #e0e0e0;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
border: 1px solid rgba(220, 53, 69, 0.3);
|
||||
border-radius: 4px;
|
||||
color: #ff6b6b;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
.error-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.migration-actions {
|
||||
padding: 16px 24px 20px;
|
||||
border-top: 1px solid #444444;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
|
||||
.action-button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.success {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
}
|
||||
|
||||
&.retry {
|
||||
background-color: #17a2b8;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background-color: #138496;
|
||||
}
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
.migration-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.migration-content::-webkit-scrollbar-track {
|
||||
background: #333333;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.migration-content::-webkit-scrollbar-thumb {
|
||||
background: #666666;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: #777777;
|
||||
}
|
||||
}
|
||||
</style>
|
423
frontend/src/composables/useWebSocket.ts
Normal file
423
frontend/src/composables/useWebSocket.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import { ref, onUnmounted, reactive, computed, watch, nextTick } from 'vue';
|
||||
|
||||
// 基础WebSocket消息接口
|
||||
interface WebSocketMessage<T = any> {
|
||||
type: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// 迁移进度接口(与后端保持一致)
|
||||
interface MigrationProgress {
|
||||
status: 'migrating' | 'completed' | 'failed';
|
||||
progress: number; // 0-100
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 连接状态枚举
|
||||
enum ConnectionState {
|
||||
DISCONNECTED = 'disconnected',
|
||||
CONNECTING = 'connecting',
|
||||
CONNECTED = 'connected',
|
||||
RECONNECTING = 'reconnecting',
|
||||
ERROR = 'error'
|
||||
}
|
||||
|
||||
// WebSocket配置选项
|
||||
interface WebSocketOptions {
|
||||
url?: string;
|
||||
reconnectInterval?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
debug?: boolean;
|
||||
autoConnect?: boolean;
|
||||
protocols?: string | string[];
|
||||
heartbeat?: {
|
||||
enabled: boolean;
|
||||
interval: number;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 消息处理器类型
|
||||
type MessageHandler<T = any> = (data: T) => void;
|
||||
|
||||
// 事件处理器映射
|
||||
interface EventHandlers {
|
||||
[messageType: string]: MessageHandler[];
|
||||
}
|
||||
|
||||
// 连接事件类型
|
||||
type ConnectionEventType = 'connect' | 'disconnect' | 'error' | 'reconnect';
|
||||
type ConnectionEventHandler = (event?: Event | CloseEvent | ErrorEvent) => void;
|
||||
|
||||
export function useWebSocket(options: WebSocketOptions = {}) {
|
||||
const {
|
||||
url = 'ws://localhost:8899/ws/migration',
|
||||
reconnectInterval = 3000,
|
||||
maxReconnectAttempts = 10,
|
||||
debug = false,
|
||||
autoConnect = true,
|
||||
protocols,
|
||||
heartbeat = { enabled: false, interval: 30000, message: 'ping' }
|
||||
} = options;
|
||||
|
||||
// === 状态管理 ===
|
||||
const connectionState = ref<ConnectionState>(ConnectionState.DISCONNECTED);
|
||||
const connectionError = ref<string | null>(null);
|
||||
const reconnectAttempts = ref(0);
|
||||
const lastMessage = ref<WebSocketMessage | null>(null);
|
||||
const messageHistory = ref<WebSocketMessage[]>([]);
|
||||
|
||||
// 迁移进度状态(保持向后兼容)
|
||||
const migrationProgress = reactive<MigrationProgress>({
|
||||
status: 'completed',
|
||||
progress: 0
|
||||
});
|
||||
|
||||
// === 计算属性 ===
|
||||
const isConnected = computed(() => connectionState.value === ConnectionState.CONNECTED);
|
||||
const isConnecting = computed(() =>
|
||||
connectionState.value === ConnectionState.CONNECTING ||
|
||||
connectionState.value === ConnectionState.RECONNECTING
|
||||
);
|
||||
const canReconnect = computed(() => reconnectAttempts.value < maxReconnectAttempts);
|
||||
|
||||
// === 内部状态 ===
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: number | null = null;
|
||||
let heartbeatTimer: number | null = null;
|
||||
let isManualDisconnect = false;
|
||||
|
||||
// 事件处理器
|
||||
const eventHandlers: EventHandlers = {};
|
||||
const connectionEventHandlers: Map<ConnectionEventType, ConnectionEventHandler[]> = new Map();
|
||||
|
||||
// === 工具函数 ===
|
||||
const log = (level: 'info' | 'warn' | 'error', message: string, ...args: any[]) => {
|
||||
if (debug) {
|
||||
console[level](`[WebSocket] ${message}`, ...args);
|
||||
}
|
||||
};
|
||||
|
||||
const clearTimers = () => {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const updateConnectionState = (newState: ConnectionState, error?: string) => {
|
||||
connectionState.value = newState;
|
||||
connectionError.value = error || null;
|
||||
log('info', `Connection state changed to: ${newState}`, error);
|
||||
};
|
||||
|
||||
// === 事件系统 ===
|
||||
const on = <T = any>(messageType: string, handler: MessageHandler<T>) => {
|
||||
if (!eventHandlers[messageType]) {
|
||||
eventHandlers[messageType] = [];
|
||||
}
|
||||
eventHandlers[messageType].push(handler as MessageHandler);
|
||||
|
||||
// 返回取消订阅函数
|
||||
return () => off(messageType, handler);
|
||||
};
|
||||
|
||||
const off = <T = any>(messageType: string, handler: MessageHandler<T>) => {
|
||||
if (eventHandlers[messageType]) {
|
||||
const index = eventHandlers[messageType].indexOf(handler as MessageHandler);
|
||||
if (index > -1) {
|
||||
eventHandlers[messageType].splice(index, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onConnection = (eventType: ConnectionEventType, handler: ConnectionEventHandler) => {
|
||||
if (!connectionEventHandlers.has(eventType)) {
|
||||
connectionEventHandlers.set(eventType, []);
|
||||
}
|
||||
connectionEventHandlers.get(eventType)!.push(handler);
|
||||
|
||||
return () => offConnection(eventType, handler);
|
||||
};
|
||||
|
||||
const offConnection = (eventType: ConnectionEventType, handler: ConnectionEventHandler) => {
|
||||
const handlers = connectionEventHandlers.get(eventType);
|
||||
if (handlers) {
|
||||
const index = handlers.indexOf(handler);
|
||||
if (index > -1) {
|
||||
handlers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const emit = (eventType: ConnectionEventType, event?: Event | CloseEvent | ErrorEvent) => {
|
||||
const handlers = connectionEventHandlers.get(eventType);
|
||||
if (handlers) {
|
||||
handlers.forEach(handler => {
|
||||
try {
|
||||
handler(event);
|
||||
} catch (error) {
|
||||
log('error', `Error in ${eventType} event handler:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// === 消息处理 ===
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const message: WebSocketMessage = JSON.parse(event.data);
|
||||
log('info', 'Received message:', message);
|
||||
|
||||
// 更新消息历史
|
||||
lastMessage.value = message;
|
||||
messageHistory.value.push(message);
|
||||
|
||||
// 限制历史记录长度
|
||||
if (messageHistory.value.length > 100) {
|
||||
messageHistory.value.shift();
|
||||
}
|
||||
|
||||
// 特殊处理迁移进度消息(保持向后兼容)
|
||||
if (message.type === 'migration_progress') {
|
||||
Object.assign(migrationProgress, message.data);
|
||||
}
|
||||
|
||||
// 触发注册的处理器
|
||||
const handlers = eventHandlers[message.type];
|
||||
if (handlers) {
|
||||
handlers.forEach(handler => {
|
||||
try {
|
||||
handler(message.data);
|
||||
} catch (error) {
|
||||
log('error', `Error in message handler for ${message.type}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
log('error', 'Failed to parse message:', error, event.data);
|
||||
}
|
||||
};
|
||||
|
||||
// === 心跳机制 ===
|
||||
const startHeartbeat = () => {
|
||||
if (!heartbeat.enabled || heartbeatTimer) return;
|
||||
|
||||
heartbeatTimer = window.setInterval(() => {
|
||||
if (isConnected.value) {
|
||||
send(heartbeat.message);
|
||||
}
|
||||
}, heartbeat.interval);
|
||||
};
|
||||
|
||||
const stopHeartbeat = () => {
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
// === 连接管理 ===
|
||||
const connect = async (): Promise<void> => {
|
||||
if (isConnecting.value || isConnected.value) {
|
||||
log('warn', 'Already connecting or connected');
|
||||
return;
|
||||
}
|
||||
|
||||
updateConnectionState(ConnectionState.CONNECTING);
|
||||
isManualDisconnect = false;
|
||||
|
||||
try {
|
||||
log('info', 'Connecting to:', url);
|
||||
|
||||
ws = new WebSocket(url, protocols);
|
||||
|
||||
// 连接超时处理
|
||||
const connectTimeout = setTimeout(() => {
|
||||
if (ws && ws.readyState === WebSocket.CONNECTING) {
|
||||
ws.close();
|
||||
updateConnectionState(ConnectionState.ERROR, 'Connection timeout');
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
ws.onopen = () => {
|
||||
clearTimeout(connectTimeout);
|
||||
log('info', 'Connected successfully');
|
||||
updateConnectionState(ConnectionState.CONNECTED);
|
||||
reconnectAttempts.value = 0;
|
||||
clearTimers();
|
||||
startHeartbeat();
|
||||
emit('connect');
|
||||
};
|
||||
|
||||
ws.onmessage = handleMessage;
|
||||
|
||||
ws.onclose = (event) => {
|
||||
clearTimeout(connectTimeout);
|
||||
stopHeartbeat();
|
||||
log('info', 'Connection closed:', event.code, event.reason);
|
||||
|
||||
const wasConnected = connectionState.value === ConnectionState.CONNECTED;
|
||||
updateConnectionState(ConnectionState.DISCONNECTED);
|
||||
ws = null;
|
||||
|
||||
emit('disconnect', event);
|
||||
|
||||
// 自动重连逻辑
|
||||
if (!isManualDisconnect && event.code !== 1000 && canReconnect.value) {
|
||||
scheduleReconnect();
|
||||
} else if (reconnectAttempts.value >= maxReconnectAttempts) {
|
||||
updateConnectionState(ConnectionState.ERROR, 'Max reconnection attempts reached');
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (event) => {
|
||||
clearTimeout(connectTimeout);
|
||||
log('error', 'Connection error:', event);
|
||||
updateConnectionState(ConnectionState.ERROR, 'WebSocket connection error');
|
||||
emit('error', event);
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
log('error', 'Failed to create WebSocket:', error);
|
||||
updateConnectionState(ConnectionState.ERROR, 'Failed to create WebSocket connection');
|
||||
}
|
||||
};
|
||||
|
||||
const disconnect = (code: number = 1000, reason: string = 'Manual disconnect') => {
|
||||
isManualDisconnect = true;
|
||||
clearTimers();
|
||||
stopHeartbeat();
|
||||
|
||||
if (ws) {
|
||||
log('info', 'Disconnecting manually');
|
||||
ws.close(code, reason);
|
||||
}
|
||||
|
||||
updateConnectionState(ConnectionState.DISCONNECTED);
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (!canReconnect.value || isManualDisconnect) return;
|
||||
|
||||
clearTimers();
|
||||
reconnectAttempts.value++;
|
||||
updateConnectionState(ConnectionState.RECONNECTING,
|
||||
`Reconnecting... (${reconnectAttempts.value}/${maxReconnectAttempts})`);
|
||||
|
||||
log('info', `Scheduling reconnect attempt ${reconnectAttempts.value}/${maxReconnectAttempts} in ${reconnectInterval}ms`);
|
||||
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
connect();
|
||||
}, reconnectInterval);
|
||||
|
||||
emit('reconnect');
|
||||
};
|
||||
|
||||
const reconnect = () => {
|
||||
disconnect();
|
||||
reconnectAttempts.value = 0;
|
||||
nextTick(() => {
|
||||
connect();
|
||||
});
|
||||
};
|
||||
|
||||
// === 消息发送 ===
|
||||
const send = (message: any): boolean => {
|
||||
if (!isConnected.value || !ws) {
|
||||
log('warn', 'Cannot send message: not connected');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = typeof message === 'string' ? message : JSON.stringify(message);
|
||||
ws.send(data);
|
||||
log('info', 'Sent message:', data);
|
||||
return true;
|
||||
} catch (error) {
|
||||
log('error', 'Failed to send message:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = <T = any>(type: string, data?: T): boolean => {
|
||||
return send({ type, data });
|
||||
};
|
||||
|
||||
// === 状态查询 ===
|
||||
const getConnectionInfo = () => ({
|
||||
state: connectionState.value,
|
||||
error: connectionError.value,
|
||||
reconnectAttempts: reconnectAttempts.value,
|
||||
maxReconnectAttempts,
|
||||
canReconnect: canReconnect.value,
|
||||
url,
|
||||
readyState: ws?.readyState,
|
||||
protocol: ws?.protocol,
|
||||
extensions: ws?.extensions
|
||||
});
|
||||
|
||||
// === 初始化 ===
|
||||
if (autoConnect) {
|
||||
nextTick(() => {
|
||||
connect();
|
||||
});
|
||||
}
|
||||
|
||||
// === 清理 ===
|
||||
onUnmounted(() => {
|
||||
disconnect();
|
||||
});
|
||||
|
||||
// 监听连接状态变化,用于调试
|
||||
if (debug) {
|
||||
watch(connectionState, (newState, oldState) => {
|
||||
log('info', `State transition: ${oldState} -> ${newState}`);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
// === 状态(只读) ===
|
||||
connectionState: computed(() => connectionState.value),
|
||||
isConnected,
|
||||
isConnecting,
|
||||
connectionError: computed(() => connectionError.value),
|
||||
reconnectAttempts: computed(() => reconnectAttempts.value),
|
||||
canReconnect,
|
||||
lastMessage: computed(() => lastMessage.value),
|
||||
messageHistory: computed(() => messageHistory.value),
|
||||
|
||||
// === 向后兼容的状态 ===
|
||||
migrationProgress,
|
||||
|
||||
// === 连接控制 ===
|
||||
connect,
|
||||
disconnect,
|
||||
reconnect,
|
||||
|
||||
// === 消息发送 ===
|
||||
send,
|
||||
sendMessage,
|
||||
|
||||
// === 事件系统 ===
|
||||
on,
|
||||
off,
|
||||
onConnection,
|
||||
offConnection,
|
||||
|
||||
// === 工具方法 ===
|
||||
getConnectionInfo,
|
||||
clearHistory: () => {
|
||||
messageHistory.value = [];
|
||||
lastMessage.value = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 导出类型
|
||||
export type { WebSocketMessage, MigrationProgress, WebSocketOptions, MessageHandler, ConnectionEventType };
|
||||
export { ConnectionState };
|
@@ -46,36 +46,12 @@ export default {
|
||||
saveSuccess: 'Save settings updated',
|
||||
saveFailed: 'Failed to update save settings'
|
||||
},
|
||||
migration: {
|
||||
inProgress: 'Migrating data to new path...',
|
||||
success: 'Data migration completed',
|
||||
failed: 'Data migration failed'
|
||||
}
|
||||
},
|
||||
migration: {
|
||||
title: 'Data Migration',
|
||||
preparing: 'Preparing',
|
||||
started: 'Starting data migration',
|
||||
migrating: 'Migrating',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
cancelled: 'Cancelled',
|
||||
idle: 'Idle',
|
||||
currentFile: 'Current File',
|
||||
files: 'Files',
|
||||
size: 'Size',
|
||||
timeRemaining: 'Time Remaining',
|
||||
complete: 'Complete',
|
||||
retry: 'Retry',
|
||||
close: 'Close',
|
||||
migrationInProgress: 'Migrating data to new path...',
|
||||
migrationCompleted: 'Data migration completed',
|
||||
migrationFailed: 'Data migration failed',
|
||||
recursiveCopyError: 'Target path cannot be a subdirectory of source path, this would cause infinite recursive copying',
|
||||
targetNotDirectory: 'Target path exists but is not a directory',
|
||||
targetNotEmpty: 'Target directory is not empty, cannot migrate',
|
||||
compressing: 'Compressing source directory...',
|
||||
extracting: 'Extracting to target location...',
|
||||
cleaning: 'Cleaning up source directory...'
|
||||
completed: 'Migration Completed',
|
||||
failed: 'Migration Failed'
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
@@ -89,10 +65,11 @@ export default {
|
||||
comingSoon: 'Coming Soon...',
|
||||
save: 'Save',
|
||||
reset: 'Reset',
|
||||
cancel: 'Cancel',
|
||||
dangerZone: 'Danger Zone',
|
||||
resetAllSettings: 'Reset All Settings',
|
||||
resetDescription: 'This will restore all settings to their default values. This action cannot be undone.',
|
||||
confirmReset: 'Are you sure you want to reset all settings? This action cannot be undone.',
|
||||
confirmReset: 'Click again to confirm reset',
|
||||
globalHotkey: 'Global Keyboard Shortcuts',
|
||||
enableGlobalHotkey: 'Enable Global Hotkeys',
|
||||
window: 'Window/Application',
|
||||
|
@@ -46,36 +46,12 @@ export default {
|
||||
saveSuccess: '保存设置已更新',
|
||||
saveFailed: '保存设置更新失败'
|
||||
},
|
||||
migration: {
|
||||
inProgress: '正在迁移数据到新路径...',
|
||||
success: '数据迁移完成',
|
||||
failed: '数据迁移失败'
|
||||
}
|
||||
},
|
||||
migration: {
|
||||
title: '数据迁移',
|
||||
preparing: '准备中',
|
||||
started: '开始迁移数据',
|
||||
migrating: '迁移中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
cancelled: '已取消',
|
||||
idle: '空闲',
|
||||
currentFile: '当前文件',
|
||||
files: '文件',
|
||||
size: '大小',
|
||||
timeRemaining: '剩余时间',
|
||||
complete: '完成',
|
||||
retry: '重试',
|
||||
close: '关闭',
|
||||
migrationInProgress: '正在迁移数据到新路径...',
|
||||
migrationCompleted: '数据迁移完成',
|
||||
migrationFailed: '数据迁移失败',
|
||||
recursiveCopyError: '目标路径不能是源路径的子目录,这会导致无限递归复制',
|
||||
targetNotDirectory: '目标路径存在但不是目录',
|
||||
targetNotEmpty: '目标目录不为空,无法迁移',
|
||||
compressing: '正在压缩源目录...',
|
||||
extracting: '正在解压到目标位置...',
|
||||
cleaning: '正在清理源目录...'
|
||||
completed: '迁移已完成',
|
||||
failed: '迁移失败'
|
||||
},
|
||||
settings: {
|
||||
title: '设置',
|
||||
@@ -89,10 +65,11 @@ export default {
|
||||
comingSoon: '即将推出...',
|
||||
save: '保存',
|
||||
reset: '重置',
|
||||
cancel: '取消',
|
||||
dangerZone: '危险操作',
|
||||
resetAllSettings: '重置所有设置',
|
||||
resetDescription: '这将恢复所有设置为默认值,此操作无法撤销',
|
||||
confirmReset: '确定要重置所有设置吗?此操作无法撤销。',
|
||||
confirmReset: '再次点击确认重置',
|
||||
globalHotkey: '全局键盘快捷键',
|
||||
enableGlobalHotkey: '启用全局热键',
|
||||
window: '窗口/应用程序',
|
||||
|
@@ -270,8 +270,16 @@ export const useConfigStore = defineStore('config', () => {
|
||||
|
||||
state.isLoading = true;
|
||||
try {
|
||||
// 调用后端重置配置
|
||||
await safeCall(() => ConfigService.ResetConfig(), 'config.resetFailed', 'config.resetSuccess');
|
||||
await safeCall(() => initConfig(), 'config.loadFailed', 'config.loadSuccess');
|
||||
|
||||
// 立即重新加载后端配置以确保前端状态同步
|
||||
await safeCall(async () => {
|
||||
const appConfig = await ConfigService.GetConfig();
|
||||
if (appConfig) {
|
||||
state.config = JSON.parse(JSON.stringify(appConfig)) as AppConfig;
|
||||
}
|
||||
}, 'config.loadFailed', 'config.loadSuccess');
|
||||
} finally {
|
||||
state.isLoading = false;
|
||||
}
|
||||
|
@@ -1,21 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import {useConfigStore} from '@/stores/configStore';
|
||||
import {useI18n} from 'vue-i18n';
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import {computed, onUnmounted, ref, watch} from 'vue';
|
||||
import SettingSection from '../components/SettingSection.vue';
|
||||
import SettingItem from '../components/SettingItem.vue';
|
||||
import ToggleSwitch from '../components/ToggleSwitch.vue';
|
||||
import MigrationProgress from '@/components/migration/MigrationProgress.vue';
|
||||
import {useErrorHandler} from '@/utils/errorHandler';
|
||||
import {DialogService, MigrationService} from '@/../bindings/voidraft/internal/services';
|
||||
import {useWebSocket} from '@/composables/useWebSocket';
|
||||
import * as runtime from '@wailsio/runtime';
|
||||
|
||||
const {t} = useI18n();
|
||||
const configStore = useConfigStore();
|
||||
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 = [
|
||||
@@ -70,12 +213,27 @@ const updateSelectedKey = (event: Event) => {
|
||||
};
|
||||
|
||||
// 重置设置
|
||||
const resetSettings = async () => {
|
||||
if (confirm(t('settings.confirmReset'))) {
|
||||
await configStore.resetConfig();
|
||||
const resetSettings = () => {
|
||||
if (resetConfirmState.value === 'idle') {
|
||||
// 第一次点击,进入确认状态
|
||||
resetConfirmState.value = 'confirming';
|
||||
// 3秒后自动返回idle状态
|
||||
resetConfirmTimer = setTimeout(() => {
|
||||
resetConfirmState.value = 'idle';
|
||||
}, 3000);
|
||||
} else if (resetConfirmState.value === 'confirming') {
|
||||
// 第二次点击,执行重置
|
||||
clearTimeout(resetConfirmTimer);
|
||||
resetConfirmState.value = 'idle';
|
||||
confirmReset();
|
||||
}
|
||||
};
|
||||
|
||||
// 确认重置
|
||||
const confirmReset = async () => {
|
||||
await configStore.resetConfig();
|
||||
};
|
||||
|
||||
// 计算热键预览文本
|
||||
const hotkeyPreview = computed(() => {
|
||||
if (!enableGlobalHotkey.value) return '';
|
||||
@@ -96,52 +254,43 @@ const currentDataPath = computed(() => configStore.config.general.dataPath);
|
||||
|
||||
// 选择数据存储目录
|
||||
const selectDataDirectory = async () => {
|
||||
try {
|
||||
if (isMigrating.value) return;
|
||||
|
||||
const selectedPath = await DialogService.SelectDirectory();
|
||||
|
||||
if (selectedPath && selectedPath.trim() && selectedPath !== currentDataPath.value) {
|
||||
// 显示迁移进度对话框
|
||||
showMigrationProgress.value = true;
|
||||
// 清除之前的消息
|
||||
clearMigrationMessages();
|
||||
|
||||
// 连接WebSocket以接收迁移进度
|
||||
await connect();
|
||||
|
||||
// 开始迁移
|
||||
try {
|
||||
await safeCall(async () => {
|
||||
const oldPath = currentDataPath.value;
|
||||
const newPath = selectedPath.trim();
|
||||
|
||||
// 先启动迁移
|
||||
await MigrationService.MigrateDirectory(oldPath, newPath);
|
||||
|
||||
// 迁移完成后更新配置
|
||||
await configStore.setDataPath(newPath);
|
||||
}, 'migration.migrationFailed');
|
||||
}
|
||||
}, '');
|
||||
} catch (error) {
|
||||
await safeCall(async () => {
|
||||
throw error;
|
||||
}, 'settings.selectDirectoryFailed');
|
||||
// 发生错误时清除消息
|
||||
clearMigrationMessages();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理迁移完成
|
||||
const handleMigrationComplete = () => {
|
||||
showMigrationProgress.value = false;
|
||||
// 显示成功消息
|
||||
safeCall(async () => {
|
||||
// 空的成功操作,只为了显示成功消息
|
||||
}, '', 'migration.migrationCompleted');
|
||||
};
|
||||
|
||||
// 处理迁移关闭
|
||||
const handleMigrationClose = () => {
|
||||
showMigrationProgress.value = false;
|
||||
};
|
||||
|
||||
// 处理迁移重试
|
||||
const handleMigrationRetry = () => {
|
||||
// 重新触发路径选择
|
||||
showMigrationProgress.value = false;
|
||||
selectDataDirectory();
|
||||
};
|
||||
// 清理定时器和WebSocket连接
|
||||
onUnmounted(() => {
|
||||
disconnect();
|
||||
if (resetConfirmTimer) {
|
||||
clearTimeout(resetConfirmTimer);
|
||||
}
|
||||
if (hideMessagesTimer) {
|
||||
clearTimeout(hideMessagesTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -152,13 +301,15 @@ const handleMigrationRetry = () => {
|
||||
</SettingItem>
|
||||
|
||||
<div class="hotkey-selector" :class="{ 'disabled': !enableGlobalHotkey }">
|
||||
<div class="hotkey-controls">
|
||||
<div class="hotkey-modifiers">
|
||||
<label class="modifier-label" :class="{ active: modifierKeys.ctrl }" @click="toggleModifier('ctrl')">
|
||||
<input type="checkbox" :checked="modifierKeys.ctrl" class="hidden-checkbox" :disabled="!enableGlobalHotkey">
|
||||
<span class="modifier-key">Ctrl</span>
|
||||
</label>
|
||||
<label class="modifier-label" :class="{ active: modifierKeys.shift }" @click="toggleModifier('shift')">
|
||||
<input type="checkbox" :checked="modifierKeys.shift" class="hidden-checkbox" :disabled="!enableGlobalHotkey">
|
||||
<input type="checkbox" :checked="modifierKeys.shift" class="hidden-checkbox"
|
||||
:disabled="!enableGlobalHotkey">
|
||||
<span class="modifier-key">Shift</span>
|
||||
</label>
|
||||
<label class="modifier-label" :class="{ active: modifierKeys.alt }" @click="toggleModifier('alt')">
|
||||
@@ -174,10 +325,11 @@ const handleMigrationRetry = () => {
|
||||
<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 class="hotkey-preview" v-if="hotkeyPreview">
|
||||
<div class="hotkey-preview">
|
||||
<span class="preview-label">预览:</span>
|
||||
<span class="preview-hotkey">{{ hotkeyPreview }}</span>
|
||||
<span class="preview-hotkey">{{ hotkeyPreview || '无' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingSection>
|
||||
@@ -194,6 +346,7 @@ const handleMigrationRetry = () => {
|
||||
<div class="setting-title">{{ t('settings.dataPath') }}</div>
|
||||
</div>
|
||||
<div class="data-path-controls">
|
||||
<div class="path-input-container">
|
||||
<input
|
||||
type="text"
|
||||
:value="currentDataPath"
|
||||
@@ -202,33 +355,52 @@ const handleMigrationRetry = () => {
|
||||
class="path-display-input"
|
||||
@click="selectDataDirectory"
|
||||
:title="t('settings.clickToSelectPath')"
|
||||
:disabled="isMigrating"
|
||||
/>
|
||||
<div
|
||||
class="progress-bar"
|
||||
:class="[
|
||||
{ 'active': showMessages },
|
||||
progressBarClass
|
||||
]"
|
||||
:style="{ width: progressBarWidth }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="migration-status-container">
|
||||
<Transition name="fade-slide" mode="out-in">
|
||||
<div v-if="showMessages" class="migration-messages">
|
||||
<TransitionGroup name="message-list" tag="div">
|
||||
<div v-for="message in migrationMessages" :key="message.id" class="migration-message" :class="message.type">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection :title="t('settings.dangerZone')">
|
||||
<div class="danger-zone">
|
||||
<div class="reset-section">
|
||||
<div class="reset-info">
|
||||
<h4>{{ t('settings.resetAllSettings') }}</h4>
|
||||
<p>{{ t('settings.resetDescription') }}</p>
|
||||
</div>
|
||||
<button class="reset-button" @click="resetSettings">
|
||||
<SettingItem :title="t('settings.resetAllSettings')" :description="t('settings.resetDescription')">
|
||||
<button
|
||||
class="reset-button"
|
||||
:class="{ 'confirming': resetConfirmState === 'confirming' }"
|
||||
@click="resetSettings"
|
||||
>
|
||||
<template v-if="resetConfirmState === 'idle'">
|
||||
{{ t('settings.reset') }}
|
||||
</template>
|
||||
<template v-else-if="resetConfirmState === 'confirming'">
|
||||
{{ t('settings.confirmReset') }}
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingItem>
|
||||
</SettingSection>
|
||||
|
||||
<!-- 迁移进度对话框 -->
|
||||
<MigrationProgress
|
||||
:visible="showMigrationProgress"
|
||||
@complete="handleMigrationComplete"
|
||||
@close="handleMigrationClose"
|
||||
@retry="handleMigrationRetry"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -238,16 +410,24 @@ const handleMigrationRetry = () => {
|
||||
|
||||
.hotkey-selector {
|
||||
padding: 15px 0 5px 20px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hotkey-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hotkey-modifiers {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.modifier-label {
|
||||
@@ -298,7 +478,6 @@ const handleMigrationRetry = () => {
|
||||
background-position: right 8px center;
|
||||
background-size: 16px;
|
||||
padding-right: 30px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
@@ -366,9 +545,13 @@ const handleMigrationRetry = () => {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.path-display-input {
|
||||
.path-input-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
|
||||
.path-display-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
background-color: #3a3a3a;
|
||||
@@ -380,7 +563,7 @@ const handleMigrationRetry = () => {
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #4a9eff;
|
||||
background-color: #404040;
|
||||
}
|
||||
@@ -391,44 +574,120 @@ const handleMigrationRetry = () => {
|
||||
background-color: #404040;
|
||||
}
|
||||
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #888888;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
background-color: #22c55e;
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 0 0 4px 4px;
|
||||
opacity: 0;
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.danger-zone {
|
||||
padding: 20px;
|
||||
background-color: rgba(220, 53, 69, 0.05);
|
||||
border: 1px solid rgba(220, 53, 69, 0.2);
|
||||
border-radius: 6px;
|
||||
margin-top: 10px;
|
||||
&.migrating {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
|
||||
.reset-section {
|
||||
&.success {
|
||||
background-color: #22c55e;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.migration-status-container {
|
||||
margin-top: 8px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
|
||||
.migration-messages {
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.migration-message {
|
||||
font-size: 11px;
|
||||
padding: 2px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
gap: 6px;
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
|
||||
.reset-info {
|
||||
flex: 1;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 6px 0;
|
||||
color: #ff6b6b;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
&::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #cccccc;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
&.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,8 +699,8 @@ const handleMigrationRetry = () => {
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.3s ease;
|
||||
min-width: 80px;
|
||||
|
||||
&:hover {
|
||||
background-color: #c82333;
|
||||
@@ -451,7 +710,96 @@ const handleMigrationRetry = () => {
|
||||
&: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>
|
23
go.mod
23
go.mod
@@ -6,6 +6,8 @@ toolchain go1.24.2
|
||||
|
||||
require (
|
||||
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/wailsapp/wails/v3 v3.0.0-alpha.9
|
||||
)
|
||||
@@ -16,26 +18,43 @@ require (
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/adrg/xdg v0.5.3 // 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/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/dolthub/maphash v0.1.0 // indirect
|
||||
github.com/ebitengine/purego v0.8.4 // 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/go-billy/v5 v5.6.2 // 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-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/goccy/go-json v0.10.2 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/uuid v1.6.0 // 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/json-iterator/go v1.1.12 // 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/u v1.1.1 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lmittmann/tint v1.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // 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/pjbgf/sha1cd v0.3.2 // 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/pflag v1.0.6 // 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/mimetype v1.4.1 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // 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/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
|
||||
golang.org/x/net v0.40.0 // indirect
|
||||
golang.org/x/sys v0.33.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/warnings.v0 v0.1.2 // 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/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
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/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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
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/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
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/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
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-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-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/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/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/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
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/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/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/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/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/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.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
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/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||
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/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/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
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-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/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/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||
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/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||
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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
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/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
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/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
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=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
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.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
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-20220715151400-c0bba94af5f8/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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
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/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
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 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
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/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.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/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)
|
||||
}
|
||||
|
||||
// ResetConfig 重置为默认配置
|
||||
func (cs *ConfigService) ResetConfig() error {
|
||||
// ResetConfig 强制重置所有配置为默认值
|
||||
func (cs *ConfigService) ResetConfig() {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
// 重新设置默认值
|
||||
setDefaults(cs.viper)
|
||||
defaultConfig := models.NewDefaultAppConfig()
|
||||
|
||||
// 使用 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 {
|
||||
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 设置热键配置变更回调
|
||||
|
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 (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -19,40 +18,26 @@ import (
|
||||
type MigrationStatus string
|
||||
|
||||
const (
|
||||
MigrationStatusIdle MigrationStatus = "idle" // 空闲状态
|
||||
MigrationStatusPreparing MigrationStatus = "preparing" // 准备中
|
||||
MigrationStatusMigrating MigrationStatus = "migrating" // 迁移中
|
||||
MigrationStatusCompleted MigrationStatus = "completed" // 完成
|
||||
MigrationStatusFailed MigrationStatus = "failed" // 失败
|
||||
MigrationStatusCancelled MigrationStatus = "cancelled" // 取消
|
||||
)
|
||||
|
||||
// MigrationProgress 迁移进度信息
|
||||
type MigrationProgress struct {
|
||||
Status MigrationStatus `json:"status"` // 迁移状态
|
||||
CurrentFile string `json:"currentFile"` // 当前处理的文件
|
||||
ProcessedFiles int `json:"processedFiles"` // 已处理文件数
|
||||
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 迁移服务
|
||||
type MigrationService struct {
|
||||
logger *log.LoggerService
|
||||
mu sync.RWMutex
|
||||
currentProgress MigrationProgress
|
||||
progressCallback MigrationProgressCallback
|
||||
cancelFunc context.CancelFunc
|
||||
ctx context.Context
|
||||
progressBroadcaster func(MigrationProgress) // WebSocket广播函数
|
||||
}
|
||||
|
||||
// NewMigrationService 创建迁移服务
|
||||
@@ -64,18 +49,12 @@ func NewMigrationService(logger *log.LoggerService) *MigrationService {
|
||||
return &MigrationService{
|
||||
logger: logger,
|
||||
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 获取当前进度
|
||||
func (ms *MigrationService) GetProgress() MigrationProgress {
|
||||
ms.mu.RLock()
|
||||
@@ -83,15 +62,15 @@ func (ms *MigrationService) GetProgress() MigrationProgress {
|
||||
return ms.currentProgress
|
||||
}
|
||||
|
||||
// updateProgress 更新进度并触发回调
|
||||
// updateProgress 更新进度
|
||||
func (ms *MigrationService) updateProgress(progress MigrationProgress) {
|
||||
ms.mu.Lock()
|
||||
ms.currentProgress = progress
|
||||
callback := ms.progressCallback
|
||||
ms.mu.Unlock()
|
||||
|
||||
if callback != nil {
|
||||
callback(progress)
|
||||
// 通过WebSocket广播进度
|
||||
if ms.progressBroadcaster != nil {
|
||||
ms.progressBroadcaster(progress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,22 +90,18 @@ func (ms *MigrationService) MigrateDirectory(srcPath, dstPath string) error {
|
||||
ms.mu.Unlock()
|
||||
}()
|
||||
|
||||
ms.logger.Info("Migration: Starting directory migration",
|
||||
"from", srcPath,
|
||||
"to", dstPath)
|
||||
ms.logger.Info("Migration: Starting directory migration", "from", srcPath, "to", dstPath)
|
||||
|
||||
// 初始化进度
|
||||
progress := MigrationProgress{
|
||||
Status: MigrationStatusPreparing,
|
||||
Message: "Preparing migration...",
|
||||
StartTime: time.Now(),
|
||||
Status: MigrationStatusMigrating,
|
||||
Progress: 0,
|
||||
}
|
||||
ms.updateProgress(progress)
|
||||
|
||||
// 检查源目录是否存在
|
||||
if _, err := os.Stat(srcPath); os.IsNotExist(err) {
|
||||
progress.Status = MigrationStatusCompleted
|
||||
progress.Message = "Source directory does not exist, skipping migration"
|
||||
progress.Progress = 100
|
||||
ms.updateProgress(progress)
|
||||
return nil
|
||||
@@ -137,69 +112,38 @@ func (ms *MigrationService) MigrateDirectory(srcPath, dstPath string) error {
|
||||
dstAbs, _ := filepath.Abs(dstPath)
|
||||
if srcAbs == dstAbs {
|
||||
progress.Status = MigrationStatusCompleted
|
||||
progress.Message = "Paths are identical, no migration needed"
|
||||
progress.Progress = 100
|
||||
ms.updateProgress(progress)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 检查目标路径是否是源路径的子目录,防止无限递归复制
|
||||
// 检查目标路径是否是源路径的子目录
|
||||
if ms.isSubDirectory(srcAbs, dstAbs) {
|
||||
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)
|
||||
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 errors.Is(ctx.Err(), context.Canceled) {
|
||||
progress.Status = MigrationStatusCancelled
|
||||
progress.Error = "Migration cancelled"
|
||||
} else {
|
||||
progress.Status = MigrationStatusFailed
|
||||
progress.Error = fmt.Sprintf("Migration failed: %v", err)
|
||||
}
|
||||
progress.Error = err.Error()
|
||||
ms.updateProgress(progress)
|
||||
return err
|
||||
}
|
||||
|
||||
// 迁移完成
|
||||
progress.Status = MigrationStatusCompleted
|
||||
progress.Message = "Migration completed"
|
||||
progress.Progress = 100
|
||||
progress.ProcessedFiles = totalFiles
|
||||
progress.ProcessedBytes = totalBytes
|
||||
duration := time.Since(progress.StartTime)
|
||||
ms.updateProgress(progress)
|
||||
|
||||
ms.logger.Info("Migration: Directory migration completed",
|
||||
"from", srcPath,
|
||||
"to", dstPath,
|
||||
"duration", duration,
|
||||
"files", totalFiles,
|
||||
"bytes", totalBytes)
|
||||
|
||||
ms.logger.Info("Migration: Directory migration completed", "from", srcPath, "to", dstPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// atomicMove 原子移动目录 - 使用压缩-移动-解压的方式
|
||||
// atomicMove 原子移动目录
|
||||
func (ms *MigrationService) atomicMove(ctx context.Context, srcPath, dstPath string, progress *MigrationProgress) error {
|
||||
// 检查是否取消
|
||||
select {
|
||||
@@ -211,125 +155,98 @@ func (ms *MigrationService) atomicMove(ctx context.Context, srcPath, dstPath str
|
||||
// 确保目标目录的父目录存在
|
||||
dstParent := filepath.Dir(dstPath)
|
||||
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.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)
|
||||
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 {
|
||||
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)
|
||||
|
||||
if err := os.Rename(srcPath, dstPath); err == nil {
|
||||
// 重命名成功,这是最快的方式
|
||||
ms.logger.Info("Migration: Fast rename successful")
|
||||
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)
|
||||
|
||||
return ms.atomicCompressMove(ctx, srcPath, dstPath, progress)
|
||||
}
|
||||
|
||||
// atomicCompressMove 原子压缩迁移 - 压缩、移动、解压、清理
|
||||
// atomicCompressMove 原子压缩迁移
|
||||
func (ms *MigrationService) atomicCompressMove(ctx context.Context, srcPath, dstPath string, progress *MigrationProgress) error {
|
||||
// 创建临时压缩文件
|
||||
tempDir := os.TempDir()
|
||||
tempZipFile := filepath.Join(tempDir, fmt.Sprintf("voidraft_migration_%d.zip", time.Now().UnixNano()))
|
||||
|
||||
// 确保临时文件在函数结束时被清理
|
||||
defer func() {
|
||||
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 = 10
|
||||
// 压缩源目录
|
||||
progress.Progress = 40
|
||||
ms.updateProgress(*progress)
|
||||
|
||||
if err := ms.compressDirectory(ctx, srcPath, tempZipFile, progress); err != nil {
|
||||
return fmt.Errorf("Failed to compress source directory: %v", err)
|
||||
if err := ms.compressDirectory(ctx, srcPath, tempZipFile); err != nil {
|
||||
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
|
||||
ms.updateProgress(*progress)
|
||||
|
||||
if err := ms.extractToDirectory(ctx, tempZipFile, dstPath, progress); err != nil {
|
||||
return fmt.Errorf("Failed to extract to target location: %v", err)
|
||||
if err := ms.extractToDirectory(ctx, tempZipFile, dstPath); err != nil {
|
||||
return fmt.Errorf("Failed to extract to target location")
|
||||
}
|
||||
|
||||
// 检查是否取消
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// 如果取消,需要清理已解压的目标目录
|
||||
os.RemoveAll(dstPath)
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
// 第三步: 删除源目录
|
||||
progress.Message = "Cleaning up source directory..."
|
||||
// 删除源目录
|
||||
progress.Progress = 90
|
||||
ms.updateProgress(*progress)
|
||||
|
||||
if err := os.RemoveAll(srcPath); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// compressDirectory 压缩目录到zip文件
|
||||
func (ms *MigrationService) compressDirectory(ctx context.Context, srcDir, zipFile string, progress *MigrationProgress) error {
|
||||
// 创建zip文件
|
||||
func (ms *MigrationService) compressDirectory(ctx context.Context, srcDir, zipFile string) error {
|
||||
zipWriter, err := os.Create(zipFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to create zip file: %v", err)
|
||||
return fmt.Errorf("Failed to create temporary file")
|
||||
}
|
||||
defer zipWriter.Close()
|
||||
|
||||
// 创建zip writer
|
||||
zw := zip.NewWriter(zipWriter)
|
||||
defer zw.Close()
|
||||
|
||||
// 遍历源目录并添加到zip
|
||||
return filepath.Walk(srcDir, func(filePath string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -342,31 +259,22 @@ func (ms *MigrationService) compressDirectory(ctx context.Context, srcDir, zipFi
|
||||
default:
|
||||
}
|
||||
|
||||
// 计算相对路径
|
||||
relPath, err := filepath.Rel(srcDir, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 跳过根目录
|
||||
if relPath == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 更新当前处理的文件
|
||||
progress.CurrentFile = relPath
|
||||
ms.updateProgress(*progress)
|
||||
|
||||
// 创建zip中的文件头
|
||||
header, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 使用/作为路径分隔符(zip标准)
|
||||
header.Name = strings.ReplaceAll(relPath, string(filepath.Separator), "/")
|
||||
|
||||
// 处理目录
|
||||
if info.IsDir() {
|
||||
header.Name += "/"
|
||||
header.Method = zip.Store
|
||||
@@ -374,13 +282,11 @@ func (ms *MigrationService) compressDirectory(ctx context.Context, srcDir, zipFi
|
||||
header.Method = zip.Deflate
|
||||
}
|
||||
|
||||
// 写入zip文件头
|
||||
writer, err := zw.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果是文件,复制内容
|
||||
if !info.IsDir() {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
@@ -399,20 +305,17 @@ func (ms *MigrationService) compressDirectory(ctx context.Context, srcDir, zipFi
|
||||
}
|
||||
|
||||
// extractToDirectory 从zip文件解压到目录
|
||||
func (ms *MigrationService) extractToDirectory(ctx context.Context, zipFile, dstDir string, progress *MigrationProgress) error {
|
||||
// 打开zip文件
|
||||
func (ms *MigrationService) extractToDirectory(ctx context.Context, zipFile, dstDir string) error {
|
||||
reader, err := zip.OpenReader(zipFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to open zip file: %v", err)
|
||||
return fmt.Errorf("Failed to open temporary file")
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
// 确保目标目录存在
|
||||
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 {
|
||||
// 检查是否取消
|
||||
select {
|
||||
@@ -421,19 +324,13 @@ func (ms *MigrationService) extractToDirectory(ctx context.Context, zipFile, dst
|
||||
default:
|
||||
}
|
||||
|
||||
// 更新当前处理的文件
|
||||
progress.CurrentFile = file.Name
|
||||
ms.updateProgress(*progress)
|
||||
|
||||
// 构建目标文件路径
|
||||
dstPath := filepath.Join(dstDir, file.Name)
|
||||
|
||||
// 安全检查:防止zip slip攻击
|
||||
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 err := os.MkdirAll(dstPath, file.FileInfo().Mode()); err != nil {
|
||||
return err
|
||||
@@ -441,12 +338,10 @@ func (ms *MigrationService) extractToDirectory(ctx context.Context, zipFile, dst
|
||||
continue
|
||||
}
|
||||
|
||||
// 确保父目录存在
|
||||
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 解压文件
|
||||
if err := ms.extractFile(file, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -457,21 +352,18 @@ func (ms *MigrationService) extractToDirectory(ctx context.Context, zipFile, dst
|
||||
|
||||
// extractFile 解压单个文件
|
||||
func (ms *MigrationService) extractFile(file *zip.File, dstPath string) error {
|
||||
// 打开zip中的文件
|
||||
srcFile, err := file.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
// 创建目标文件
|
||||
dstFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, file.FileInfo().Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
// 复制文件内容
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
return err
|
||||
}
|
||||
@@ -484,56 +376,23 @@ func (ms *MigrationService) isDirectoryEmpty(dirPath string) (bool, error) {
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// 尝试读取一个条目
|
||||
_, err = f.Readdir(1)
|
||||
if err == io.EOF {
|
||||
// 目录为空
|
||||
return true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// 目录不为空
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// isSubDirectory 检查target是否是parent的子目录
|
||||
func (ms *MigrationService) isSubDirectory(parent, target string) bool {
|
||||
// 确保路径以分隔符结尾,以避免误判
|
||||
parent = filepath.Clean(parent) + string(filepath.Separator)
|
||||
target = filepath.Clean(target) + string(filepath.Separator)
|
||||
|
||||
// 检查target是否以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 取消迁移
|
||||
func (ms *MigrationService) CancelMigration() error {
|
||||
ms.mu.Lock()
|
||||
@@ -545,18 +404,22 @@ func (ms *MigrationService) CancelMigration() error {
|
||||
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 服务关闭
|
||||
func (ms *MigrationService) ServiceShutdown() error {
|
||||
ms.logger.Info("Migration: Service is shutting down...")
|
||||
|
||||
// 取消正在进行的迁移
|
||||
if err := ms.CancelMigration(); err != nil {
|
||||
ms.logger.Debug("Migration: No active migration to cancel during shutdown")
|
||||
}
|
||||
|
||||
ms.logger.Info("Migration: Service shutdown completed")
|
||||
return nil
|
||||
}
|
||||
|
@@ -15,6 +15,8 @@ type ServiceManager struct {
|
||||
systemService *SystemService
|
||||
hotkeyService *HotkeyService
|
||||
dialogService *DialogService
|
||||
websocketService *WebSocketService
|
||||
httpService *HTTPService
|
||||
logger *log.LoggerService
|
||||
}
|
||||
|
||||
@@ -41,8 +43,26 @@ func NewServiceManager() *ServiceManager {
|
||||
// 初始化对话服务
|
||||
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)
|
||||
})
|
||||
if err != nil {
|
||||
@@ -50,7 +70,7 @@ func NewServiceManager() *ServiceManager {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// 设置数据路径变更监听
|
||||
// 设置数据路径变更监听,处理配置重置和路径变更
|
||||
err = configService.SetDataPathChangeCallback(func(oldPath, newPath string) error {
|
||||
return documentService.OnDataPathChanged(oldPath, newPath)
|
||||
})
|
||||
@@ -73,6 +93,8 @@ func NewServiceManager() *ServiceManager {
|
||||
systemService: systemService,
|
||||
hotkeyService: hotkeyService,
|
||||
dialogService: dialogService,
|
||||
websocketService: websocketService,
|
||||
httpService: httpService,
|
||||
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