♻️ Optimize code
This commit is contained in:
@@ -10,6 +10,9 @@
|
|||||||
// @ts-ignore: Unused imports
|
// @ts-ignore: Unused imports
|
||||||
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
|
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore: Unused imports
|
||||||
|
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore: Unused imports
|
// @ts-ignore: Unused imports
|
||||||
import * as models$0 from "../models/models.js";
|
import * as models$0 from "../models/models.js";
|
||||||
@@ -38,22 +41,6 @@ export function GetConfig(): Promise<models$0.AppConfig | null> & { cancel(): vo
|
|||||||
return $typingPromise;
|
return $typingPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* GetConfigDir 获取配置目录
|
|
||||||
*/
|
|
||||||
export function GetConfigDir(): Promise<string> & { cancel(): void } {
|
|
||||||
let $resultPromise = $Call.ByID(2275626561) as any;
|
|
||||||
return $resultPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GetSettingsPath 获取设置文件路径
|
|
||||||
*/
|
|
||||||
export function GetSettingsPath(): Promise<string> & { cancel(): void } {
|
|
||||||
let $resultPromise = $Call.ByID(2175583370) as any;
|
|
||||||
return $resultPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MigrateConfig 执行配置迁移
|
* MigrateConfig 执行配置迁移
|
||||||
*/
|
*/
|
||||||
@@ -78,6 +65,14 @@ export function ServiceShutdown(): Promise<void> & { cancel(): void } {
|
|||||||
return $resultPromise;
|
return $resultPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ServiceStartup initializes the service when the application starts
|
||||||
|
*/
|
||||||
|
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
|
||||||
|
let $resultPromise = $Call.ByID(3311949428, options) as any;
|
||||||
|
return $resultPromise;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set 设置配置项
|
* Set 设置配置项
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -268,9 +268,6 @@ export class OSInfo {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* ObserverCallback 观察者回调函数
|
* ObserverCallback 观察者回调函数
|
||||||
* 参数:
|
|
||||||
* - oldValue: 配置变更前的值
|
|
||||||
* - newValue: 配置变更后的值
|
|
||||||
*/
|
*/
|
||||||
export type ObserverCallback = any;
|
export type ObserverCallback = any;
|
||||||
|
|
||||||
|
|||||||
@@ -1,175 +1,49 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { computed, readonly, ref, shallowRef, watchEffect, onScopeDispose } from 'vue';
|
import { ref, onScopeDispose } from 'vue';
|
||||||
import type { GitBackupConfig } from '@/../bindings/voidraft/internal/models';
|
|
||||||
import { BackupService } from '@/../bindings/voidraft/internal/services';
|
import { BackupService } from '@/../bindings/voidraft/internal/services';
|
||||||
import { useConfigStore } from '@/stores/configStore';
|
import { useConfigStore } from '@/stores/configStore';
|
||||||
import { createTimerManager } from '@/common/utils/timerUtils';
|
import { createTimerManager } from '@/common/utils/timerUtils';
|
||||||
|
|
||||||
// 备份状态枚举
|
|
||||||
export enum BackupStatus {
|
|
||||||
IDLE = 'idle',
|
|
||||||
PUSHING = 'pushing',
|
|
||||||
SUCCESS = 'success',
|
|
||||||
ERROR = 'error'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 备份操作结果类型
|
|
||||||
export interface BackupResult {
|
|
||||||
status: BackupStatus;
|
|
||||||
message?: string;
|
|
||||||
timestamp?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 类型守卫函数
|
|
||||||
const isBackupError = (error: unknown): error is Error => {
|
|
||||||
return error instanceof Error;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 工具类型:提取错误消息
|
|
||||||
type ErrorMessage<T> = T extends Error ? string : string;
|
|
||||||
|
|
||||||
|
|
||||||
export const useBackupStore = defineStore('backup', () => {
|
export const useBackupStore = defineStore('backup', () => {
|
||||||
// === 核心状态 ===
|
const isPushing = ref(false);
|
||||||
const config = shallowRef<GitBackupConfig | null>(null);
|
const message = ref<string | null>(null);
|
||||||
|
const isError = ref(false);
|
||||||
|
|
||||||
// 统一的备份结果状态
|
const timer = createTimerManager();
|
||||||
const backupResult = ref<BackupResult>({
|
|
||||||
status: BackupStatus.IDLE
|
|
||||||
});
|
|
||||||
|
|
||||||
// === 定时器管理 ===
|
|
||||||
const statusTimer = createTimerManager();
|
|
||||||
|
|
||||||
// 组件卸载时清理定时器
|
|
||||||
onScopeDispose(() => {
|
|
||||||
statusTimer.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
// === 外部依赖 ===
|
|
||||||
const configStore = useConfigStore();
|
const configStore = useConfigStore();
|
||||||
|
|
||||||
// === 计算属性 ===
|
onScopeDispose(() => timer.clear());
|
||||||
const isEnabled = computed(() => configStore.config.backup.enabled);
|
|
||||||
const isConfigured = computed(() => Boolean(configStore.config.backup.repo_url?.trim()));
|
|
||||||
|
|
||||||
// 派生状态计算属性
|
const pushToRemote = async () => {
|
||||||
const isPushing = computed(() => backupResult.value.status === BackupStatus.PUSHING);
|
const isConfigured = Boolean(configStore.config.backup.repo_url?.trim());
|
||||||
const isSuccess = computed(() => backupResult.value.status === BackupStatus.SUCCESS);
|
|
||||||
const isError = computed(() => backupResult.value.status === BackupStatus.ERROR);
|
|
||||||
const errorMessage = computed(() =>
|
|
||||||
backupResult.value.status === BackupStatus.ERROR ? backupResult.value.message : null
|
|
||||||
);
|
|
||||||
|
|
||||||
// === 状态管理方法 ===
|
if (isPushing.value || !isConfigured) {
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置备份状态
|
|
||||||
* @param status 备份状态
|
|
||||||
* @param message 可选消息
|
|
||||||
* @param autoHide 是否自动隐藏(毫秒)
|
|
||||||
*/
|
|
||||||
const setBackupStatus = <T extends BackupStatus>(
|
|
||||||
status: T,
|
|
||||||
message?: T extends BackupStatus.ERROR ? string : string,
|
|
||||||
autoHide?: number
|
|
||||||
): void => {
|
|
||||||
statusTimer.clear();
|
|
||||||
|
|
||||||
backupResult.value = {
|
|
||||||
status,
|
|
||||||
message,
|
|
||||||
timestamp: Date.now()
|
|
||||||
};
|
|
||||||
|
|
||||||
// 自动隐藏逻辑
|
|
||||||
if (autoHide && (status === BackupStatus.SUCCESS || status === BackupStatus.ERROR)) {
|
|
||||||
statusTimer.set(() => {
|
|
||||||
if (backupResult.value.status === status) {
|
|
||||||
backupResult.value = { status: BackupStatus.IDLE };
|
|
||||||
}
|
|
||||||
}, autoHide);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清除当前状态
|
|
||||||
*/
|
|
||||||
const clearStatus = (): void => {
|
|
||||||
statusTimer.clear();
|
|
||||||
backupResult.value = { status: BackupStatus.IDLE };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理错误的通用方法
|
|
||||||
*/
|
|
||||||
const handleError = (error: unknown): void => {
|
|
||||||
const message: ErrorMessage<typeof error> = isBackupError(error)
|
|
||||||
? error.message
|
|
||||||
: 'Backup operation failed';
|
|
||||||
|
|
||||||
setBackupStatus(BackupStatus.ERROR, message, 5000);
|
|
||||||
};
|
|
||||||
|
|
||||||
// === 业务逻辑方法 ===
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 推送到远程仓库
|
|
||||||
* 使用现代 async/await 和错误处理
|
|
||||||
*/
|
|
||||||
const pushToRemote = async (): Promise<void> => {
|
|
||||||
// 前置条件检查
|
|
||||||
if (isPushing.value || !isConfigured.value) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setBackupStatus(BackupStatus.PUSHING);
|
isPushing.value = true;
|
||||||
|
message.value = null;
|
||||||
|
timer.clear();
|
||||||
|
|
||||||
await BackupService.PushToRemote();
|
await BackupService.PushToRemote();
|
||||||
|
|
||||||
setBackupStatus(BackupStatus.SUCCESS, 'Backup completed successfully', 3000);
|
isError.value = false;
|
||||||
|
message.value = 'push successful';
|
||||||
|
timer.set(() => { message.value = null; }, 3000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error);
|
isError.value = true;
|
||||||
|
message.value = error instanceof Error ? error.message : 'backup operation failed';
|
||||||
|
timer.set(() => { message.value = null; }, 5000);
|
||||||
|
} finally {
|
||||||
|
isPushing.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 重试备份操作
|
|
||||||
*/
|
|
||||||
const retryBackup = async (): Promise<void> => {
|
|
||||||
if (isError.value) {
|
|
||||||
await pushToRemote();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// === 响应式副作用 ===
|
|
||||||
|
|
||||||
// 监听配置变化,自动清除错误状态
|
|
||||||
watchEffect(() => {
|
|
||||||
if (isEnabled.value && isConfigured.value && isError.value) {
|
|
||||||
// 配置修复后清除错误状态
|
|
||||||
clearStatus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// === 返回的 API ===
|
|
||||||
return {
|
return {
|
||||||
// 只读状态
|
|
||||||
config: readonly(config),
|
|
||||||
backupResult: readonly(backupResult),
|
|
||||||
|
|
||||||
// 计算属性
|
|
||||||
isEnabled,
|
|
||||||
isConfigured,
|
|
||||||
isPushing,
|
isPushing,
|
||||||
isSuccess,
|
message,
|
||||||
isError,
|
isError,
|
||||||
errorMessage,
|
pushToRemote
|
||||||
|
};
|
||||||
// 方法
|
|
||||||
pushToRemote,
|
|
||||||
retryBackup,
|
|
||||||
clearStatus
|
|
||||||
} as const;
|
|
||||||
});
|
});
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
descriptionType?: 'default' | 'success' | 'error';
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -9,7 +10,16 @@ defineProps<{
|
|||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<div class="setting-title">{{ title }}</div>
|
<div class="setting-title">{{ title }}</div>
|
||||||
<div v-if="description" class="setting-description">{{ description }}</div>
|
<div
|
||||||
|
v-if="description"
|
||||||
|
class="setting-description"
|
||||||
|
:class="{
|
||||||
|
'description-success': descriptionType === 'success',
|
||||||
|
'description-error': descriptionType === 'error'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ description }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-control">
|
<div class="setting-control">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
@@ -48,6 +58,14 @@ defineProps<{
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--settings-text-secondary);
|
color: var(--settings-text-secondary);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
|
|
||||||
|
&.description-success {
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.description-error {
|
||||||
|
color: #f44336;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import {useConfigStore} from '@/stores/configStore';
|
import {useConfigStore} from '@/stores/configStore';
|
||||||
import {useBackupStore} from '@/stores/backupStore';
|
import {useBackupStore} from '@/stores/backupStore';
|
||||||
import {useI18n} from 'vue-i18n';
|
import {useI18n} from 'vue-i18n';
|
||||||
import {computed, onUnmounted} from 'vue';
|
import {computed} from 'vue';
|
||||||
import SettingSection from '../components/SettingSection.vue';
|
import SettingSection from '../components/SettingSection.vue';
|
||||||
import SettingItem from '../components/SettingItem.vue';
|
import SettingItem from '../components/SettingItem.vue';
|
||||||
import ToggleSwitch from '../components/ToggleSwitch.vue';
|
import ToggleSwitch from '../components/ToggleSwitch.vue';
|
||||||
@@ -13,18 +13,12 @@ const {t} = useI18n();
|
|||||||
const configStore = useConfigStore();
|
const configStore = useConfigStore();
|
||||||
const backupStore = useBackupStore();
|
const backupStore = useBackupStore();
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
backupStore.clearStatus();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 认证方式选项
|
|
||||||
const authMethodOptions = computed(() => [
|
const authMethodOptions = computed(() => [
|
||||||
{value: AuthMethod.Token, label: t('settings.backup.authMethods.token')},
|
{value: AuthMethod.Token, label: t('settings.backup.authMethods.token')},
|
||||||
{value: AuthMethod.SSHKey, label: t('settings.backup.authMethods.sshKey')},
|
{value: AuthMethod.SSHKey, label: t('settings.backup.authMethods.sshKey')},
|
||||||
{value: AuthMethod.UserPass, label: t('settings.backup.authMethods.userPass')}
|
{value: AuthMethod.UserPass, label: t('settings.backup.authMethods.userPass')}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 备份间隔选项(分钟)
|
|
||||||
const backupIntervalOptions = computed(() => [
|
const backupIntervalOptions = computed(() => [
|
||||||
{value: 5, label: t('settings.backup.intervals.5min')},
|
{value: 5, label: t('settings.backup.intervals.5min')},
|
||||||
{value: 10, label: t('settings.backup.intervals.10min')},
|
{value: 10, label: t('settings.backup.intervals.10min')},
|
||||||
@@ -33,124 +27,11 @@ const backupIntervalOptions = computed(() => [
|
|||||||
{value: 60, label: t('settings.backup.intervals.1hour')}
|
{value: 60, label: t('settings.backup.intervals.1hour')}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 计算属性 - 启用备份
|
|
||||||
const enableBackup = computed({
|
|
||||||
get: () => configStore.config.backup.enabled,
|
|
||||||
set: (value: boolean) => configStore.setEnableBackup(value)
|
|
||||||
});
|
|
||||||
|
|
||||||
// 计算属性 - 自动备份
|
|
||||||
const autoBackup = computed({
|
|
||||||
get: () => configStore.config.backup.auto_backup,
|
|
||||||
set: (value: boolean) => configStore.setAutoBackup(value)
|
|
||||||
});
|
|
||||||
|
|
||||||
// 仓库URL
|
|
||||||
const repoUrl = computed({
|
|
||||||
get: () => configStore.config.backup.repo_url,
|
|
||||||
set: (value: string) => configStore.setRepoUrl(value)
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// 认证方式
|
|
||||||
const authMethod = computed({
|
|
||||||
get: () => configStore.config.backup.auth_method,
|
|
||||||
set: (value: AuthMethod) => configStore.setAuthMethod(value)
|
|
||||||
});
|
|
||||||
|
|
||||||
// 备份间隔
|
|
||||||
const backupInterval = computed({
|
|
||||||
get: () => configStore.config.backup.backup_interval,
|
|
||||||
set: (value: number) => configStore.setBackupInterval(value)
|
|
||||||
});
|
|
||||||
|
|
||||||
// 用户名
|
|
||||||
const username = computed({
|
|
||||||
get: () => configStore.config.backup.username,
|
|
||||||
set: (value: string) => configStore.setUsername(value)
|
|
||||||
});
|
|
||||||
|
|
||||||
// 密码
|
|
||||||
const password = computed({
|
|
||||||
get: () => configStore.config.backup.password,
|
|
||||||
set: (value: string) => configStore.setPassword(value)
|
|
||||||
});
|
|
||||||
|
|
||||||
// 访问令牌
|
|
||||||
const token = computed({
|
|
||||||
get: () => configStore.config.backup.token,
|
|
||||||
set: (value: string) => configStore.setToken(value)
|
|
||||||
});
|
|
||||||
|
|
||||||
// SSH密钥路径
|
|
||||||
const sshKeyPath = computed({
|
|
||||||
get: () => configStore.config.backup.ssh_key_path,
|
|
||||||
set: (value: string) => configStore.setSshKeyPath(value)
|
|
||||||
});
|
|
||||||
|
|
||||||
// SSH密钥密码
|
|
||||||
const sshKeyPassphrase = computed({
|
|
||||||
get: () => configStore.config.backup.ssh_key_passphrase,
|
|
||||||
set: (value: string) => configStore.setSshKeyPassphrase(value)
|
|
||||||
});
|
|
||||||
|
|
||||||
// 处理输入变化
|
|
||||||
const handleRepoUrlChange = (event: Event) => {
|
|
||||||
const target = event.target as HTMLInputElement;
|
|
||||||
repoUrl.value = target.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const handleUsernameChange = (event: Event) => {
|
|
||||||
const target = event.target as HTMLInputElement;
|
|
||||||
username.value = target.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePasswordChange = (event: Event) => {
|
|
||||||
const target = event.target as HTMLInputElement;
|
|
||||||
password.value = target.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTokenChange = (event: Event) => {
|
|
||||||
const target = event.target as HTMLInputElement;
|
|
||||||
token.value = target.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSshKeyPassphraseChange = (event: Event) => {
|
|
||||||
const target = event.target as HTMLInputElement;
|
|
||||||
sshKeyPassphrase.value = target.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAuthMethodChange = (event: Event) => {
|
|
||||||
const target = event.target as HTMLSelectElement;
|
|
||||||
authMethod.value = target.value as AuthMethod;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBackupIntervalChange = (event: Event) => {
|
|
||||||
const target = event.target as HTMLSelectElement;
|
|
||||||
backupInterval.value = parseInt(target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 推送到远程
|
|
||||||
const pushToRemote = async () => {
|
|
||||||
await backupStore.pushToRemote();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 重试备份
|
|
||||||
const retryBackup = async () => {
|
|
||||||
await backupStore.retryBackup();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 选择SSH密钥文件
|
|
||||||
const selectSshKeyFile = async () => {
|
const selectSshKeyFile = async () => {
|
||||||
// 使用DialogService选择文件
|
|
||||||
const selectedPath = await DialogService.SelectFile();
|
const selectedPath = await DialogService.SelectFile();
|
||||||
// 检查用户是否取消了选择或路径为空
|
if (selectedPath.trim()) {
|
||||||
if (!selectedPath.trim()) {
|
configStore.setSshKeyPath(selectedPath.trim());
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// 更新SSH密钥路径
|
|
||||||
sshKeyPath.value = selectedPath.trim();
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -158,34 +39,35 @@ const selectSshKeyFile = async () => {
|
|||||||
<div class="settings-page">
|
<div class="settings-page">
|
||||||
<!-- 基本设置 -->
|
<!-- 基本设置 -->
|
||||||
<SettingSection :title="t('settings.backup.basicSettings')">
|
<SettingSection :title="t('settings.backup.basicSettings')">
|
||||||
<SettingItem
|
<SettingItem :title="t('settings.backup.enableBackup')">
|
||||||
:title="t('settings.backup.enableBackup')"
|
<ToggleSwitch
|
||||||
>
|
:modelValue="configStore.config.backup.enabled"
|
||||||
<ToggleSwitch v-model="enableBackup"/>
|
@update:modelValue="configStore.setEnableBackup"
|
||||||
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
<SettingItem
|
<SettingItem
|
||||||
:title="t('settings.backup.autoBackup')"
|
:title="t('settings.backup.autoBackup')"
|
||||||
:class="{ 'disabled-setting': !enableBackup }"
|
:class="{ 'disabled-setting': !configStore.config.backup.enabled }"
|
||||||
>
|
>
|
||||||
<ToggleSwitch v-model="autoBackup" :disabled="!enableBackup"/>
|
<ToggleSwitch
|
||||||
|
:modelValue="configStore.config.backup.auto_backup"
|
||||||
|
@update:modelValue="configStore.setAutoBackup"
|
||||||
|
:disabled="!configStore.config.backup.enabled"
|
||||||
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
<SettingItem
|
<SettingItem
|
||||||
:title="t('settings.backup.backupInterval')"
|
:title="t('settings.backup.backupInterval')"
|
||||||
:class="{ 'disabled-setting': !enableBackup || !autoBackup }"
|
:class="{ 'disabled-setting': !configStore.config.backup.enabled || !configStore.config.backup.auto_backup }"
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
class="backup-interval-select"
|
class="backup-interval-select"
|
||||||
:value="backupInterval"
|
:value="configStore.config.backup.backup_interval"
|
||||||
@change="handleBackupIntervalChange"
|
@change="(e) => configStore.setBackupInterval(Number((e.target as HTMLSelectElement).value))"
|
||||||
:disabled="!enableBackup || !autoBackup"
|
:disabled="!configStore.config.backup.enabled || !configStore.config.backup.auto_backup"
|
||||||
>
|
>
|
||||||
<option
|
<option v-for="option in backupIntervalOptions" :key="option.value" :value="option.value">
|
||||||
v-for="option in backupIntervalOptions"
|
|
||||||
:key="option.value"
|
|
||||||
:value="option.value"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -194,110 +76,94 @@ const selectSshKeyFile = async () => {
|
|||||||
|
|
||||||
<!-- 仓库配置 -->
|
<!-- 仓库配置 -->
|
||||||
<SettingSection :title="t('settings.backup.repositoryConfig')">
|
<SettingSection :title="t('settings.backup.repositoryConfig')">
|
||||||
<SettingItem
|
<SettingItem :title="t('settings.backup.repoUrl')">
|
||||||
:title="t('settings.backup.repoUrl')"
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="repo-url-input"
|
class="repo-url-input"
|
||||||
:value="repoUrl"
|
:value="configStore.config.backup.repo_url"
|
||||||
@input="handleRepoUrlChange"
|
@input="(e) => configStore.setRepoUrl((e.target as HTMLInputElement).value)"
|
||||||
:placeholder="t('settings.backup.repoUrlPlaceholder')"
|
:placeholder="t('settings.backup.repoUrlPlaceholder')"
|
||||||
:disabled="!enableBackup"
|
:disabled="!configStore.config.backup.enabled"
|
||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
|
||||||
</SettingSection>
|
</SettingSection>
|
||||||
|
|
||||||
<!-- 认证配置 -->
|
<!-- 认证配置 -->
|
||||||
<SettingSection :title="t('settings.backup.authConfig')">
|
<SettingSection :title="t('settings.backup.authConfig')">
|
||||||
<SettingItem
|
<SettingItem :title="t('settings.backup.authMethod')">
|
||||||
:title="t('settings.backup.authMethod')"
|
|
||||||
>
|
|
||||||
<select
|
<select
|
||||||
class="auth-method-select"
|
class="auth-method-select"
|
||||||
:value="authMethod"
|
:value="configStore.config.backup.auth_method"
|
||||||
@change="handleAuthMethodChange"
|
@change="(e) => configStore.setAuthMethod((e.target as HTMLSelectElement).value as AuthMethod)"
|
||||||
:disabled="!enableBackup"
|
:disabled="!configStore.config.backup.enabled"
|
||||||
>
|
>
|
||||||
<option
|
<option v-for="option in authMethodOptions" :key="option.value" :value="option.value">
|
||||||
v-for="option in authMethodOptions"
|
|
||||||
:key="option.value"
|
|
||||||
:value="option.value"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
<!-- 用户名密码认证 -->
|
<!-- 用户名密码认证 -->
|
||||||
<template v-if="authMethod === AuthMethod.UserPass">
|
<template v-if="configStore.config.backup.auth_method === AuthMethod.UserPass">
|
||||||
<SettingItem :title="t('settings.backup.username')">
|
<SettingItem :title="t('settings.backup.username')">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="username-input"
|
class="username-input"
|
||||||
:value="username"
|
:value="configStore.config.backup.username"
|
||||||
@input="handleUsernameChange"
|
@input="(e) => configStore.setUsername((e.target as HTMLInputElement).value)"
|
||||||
:placeholder="t('settings.backup.usernamePlaceholder')"
|
:placeholder="t('settings.backup.usernamePlaceholder')"
|
||||||
:disabled="!enableBackup"
|
:disabled="!configStore.config.backup.enabled"
|
||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
<SettingItem :title="t('settings.backup.password')">
|
<SettingItem :title="t('settings.backup.password')">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
class="password-input"
|
class="password-input"
|
||||||
:value="password"
|
:value="configStore.config.backup.password"
|
||||||
@input="handlePasswordChange"
|
@input="(e) => configStore.setPassword((e.target as HTMLInputElement).value)"
|
||||||
:placeholder="t('settings.backup.passwordPlaceholder')"
|
:placeholder="t('settings.backup.passwordPlaceholder')"
|
||||||
:disabled="!enableBackup"
|
:disabled="!configStore.config.backup.enabled"
|
||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 访问令牌认证 -->
|
<!-- 访问令牌认证 -->
|
||||||
<template v-if="authMethod === AuthMethod.Token">
|
<template v-if="configStore.config.backup.auth_method === AuthMethod.Token">
|
||||||
<SettingItem
|
<SettingItem :title="t('settings.backup.token')">
|
||||||
:title="t('settings.backup.token')"
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
class="token-input"
|
class="token-input"
|
||||||
:value="token"
|
:value="configStore.config.backup.token"
|
||||||
@input="handleTokenChange"
|
@input="(e) => configStore.setToken((e.target as HTMLInputElement).value)"
|
||||||
:placeholder="t('settings.backup.tokenPlaceholder')"
|
:placeholder="t('settings.backup.tokenPlaceholder')"
|
||||||
:disabled="!enableBackup"
|
:disabled="!configStore.config.backup.enabled"
|
||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- SSH密钥认证 -->
|
<!-- SSH密钥认证 -->
|
||||||
<template v-if="authMethod === AuthMethod.SSHKey">
|
<template v-if="configStore.config.backup.auth_method === AuthMethod.SSHKey">
|
||||||
<SettingItem
|
<SettingItem :title="t('settings.backup.sshKeyPath')">
|
||||||
:title="t('settings.backup.sshKeyPath')"
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="ssh-key-path-input"
|
class="ssh-key-path-input"
|
||||||
:value="sshKeyPath"
|
:value="configStore.config.backup.ssh_key_path"
|
||||||
:placeholder="t('settings.backup.sshKeyPathPlaceholder')"
|
:placeholder="t('settings.backup.sshKeyPathPlaceholder')"
|
||||||
:disabled="!enableBackup"
|
:disabled="!configStore.config.backup.enabled"
|
||||||
readonly
|
readonly
|
||||||
@click="enableBackup && selectSshKeyFile()"
|
@click="configStore.config.backup.enabled && selectSshKeyFile()"
|
||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
<SettingItem
|
<SettingItem :title="t('settings.backup.sshKeyPassphrase')">
|
||||||
:title="t('settings.backup.sshKeyPassphrase')"
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
class="ssh-passphrase-input"
|
class="ssh-passphrase-input"
|
||||||
:value="sshKeyPassphrase"
|
:value="configStore.config.backup.ssh_key_passphrase"
|
||||||
@input="handleSshKeyPassphraseChange"
|
@input="(e) => configStore.setSshKeyPassphrase((e.target as HTMLInputElement).value)"
|
||||||
:placeholder="t('settings.backup.sshKeyPassphrasePlaceholder')"
|
:placeholder="t('settings.backup.sshKeyPassphrasePlaceholder')"
|
||||||
:disabled="!enableBackup"
|
:disabled="!configStore.config.backup.enabled"
|
||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
</template>
|
</template>
|
||||||
@@ -306,35 +172,20 @@ const selectSshKeyFile = async () => {
|
|||||||
<!-- 备份操作 -->
|
<!-- 备份操作 -->
|
||||||
<SettingSection :title="t('settings.backup.backupOperations')">
|
<SettingSection :title="t('settings.backup.backupOperations')">
|
||||||
<SettingItem
|
<SettingItem
|
||||||
:title="t('settings.backup.pushToRemote')"
|
:title="t('settings.backup.pushToRemote')"
|
||||||
|
:description="backupStore.message || undefined"
|
||||||
|
:descriptionType="backupStore.message ? (backupStore.isError ? 'error' : 'success') : 'default'"
|
||||||
>
|
>
|
||||||
<div class="backup-operation-container">
|
<button
|
||||||
<div class="backup-status-icons">
|
class="push-button"
|
||||||
<span v-if="backupStore.isSuccess" class="success-icon">✓</span>
|
@click="backupStore.pushToRemote"
|
||||||
<span v-if="backupStore.isError" class="error-icon">✗</span>
|
:disabled="!configStore.config.backup.enabled || !configStore.config.backup.repo_url || backupStore.isPushing"
|
||||||
</div>
|
:class="{ 'backing-up': backupStore.isPushing }"
|
||||||
<button
|
>
|
||||||
class="push-button"
|
<span v-if="backupStore.isPushing" class="loading-spinner"></span>
|
||||||
@click="() => pushToRemote()"
|
{{ backupStore.isPushing ? t('settings.backup.pushing') : t('settings.backup.actions.push') }}
|
||||||
:disabled="!enableBackup || !repoUrl || backupStore.isPushing"
|
</button>
|
||||||
:class="{ 'backing-up': backupStore.isPushing }"
|
|
||||||
>
|
|
||||||
<span v-if="backupStore.isPushing" class="loading-spinner"></span>
|
|
||||||
{{ backupStore.isPushing ? t('settings.backup.pushing') : t('settings.backup.actions.push') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="backupStore.isError"
|
|
||||||
class="retry-button"
|
|
||||||
@click="() => retryBackup()"
|
|
||||||
:disabled="backupStore.isPushing"
|
|
||||||
>
|
|
||||||
{{ t('settings.backup.actions.retry') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
<div v-if="backupStore.errorMessage" class="error-message-row">
|
|
||||||
{{ backupStore.errorMessage }}
|
|
||||||
</div>
|
|
||||||
</SettingSection>
|
</SettingSection>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -405,38 +256,8 @@ const selectSshKeyFile = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 备份操作容器
|
|
||||||
.backup-operation-container {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 备份状态图标
|
|
||||||
.backup-status-icons {
|
|
||||||
width: 24px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 成功和错误图标
|
|
||||||
.success-icon {
|
|
||||||
color: #4caf50;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-icon {
|
|
||||||
color: #f44336;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按钮样式
|
// 按钮样式
|
||||||
.push-button,
|
.push-button {
|
||||||
.retry-button {
|
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background-color: var(--settings-input-bg);
|
background-color: var(--settings-input-bg);
|
||||||
border: 1px solid var(--settings-input-border);
|
border: 1px solid var(--settings-input-border);
|
||||||
@@ -480,30 +301,6 @@ const selectSshKeyFile = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.retry-button {
|
|
||||||
background-color: #ff9800;
|
|
||||||
border-color: #ff9800;
|
|
||||||
color: white;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background-color: #f57c00;
|
|
||||||
border-color: #f57c00;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 错误信息行样式
|
|
||||||
.error-message-row {
|
|
||||||
color: #f44336;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.4;
|
|
||||||
word-wrap: break-word;
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background-color: rgba(244, 67, 54, 0.1);
|
|
||||||
border-left: 3px solid #f44336;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 禁用状态
|
// 禁用状态
|
||||||
.disabled-setting {
|
.disabled-setting {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-git/go-git/v5"
|
"github.com/go-git/go-git/v5"
|
||||||
@@ -36,6 +37,8 @@ type BackupService struct {
|
|||||||
isInitialized bool
|
isInitialized bool
|
||||||
autoBackupTicker *time.Ticker
|
autoBackupTicker *time.Ticker
|
||||||
autoBackupStop chan bool
|
autoBackupStop chan bool
|
||||||
|
autoBackupWg sync.WaitGroup // 等待自动备份goroutine完成
|
||||||
|
mu sync.Mutex // 推送操作互斥锁
|
||||||
|
|
||||||
// 配置观察者取消函数
|
// 配置观察者取消函数
|
||||||
cancelObserver CancelFunc
|
cancelObserver CancelFunc
|
||||||
@@ -86,6 +89,11 @@ func (s *BackupService) Initialize() error {
|
|||||||
return fmt.Errorf("initializing repository: %w", err)
|
return fmt.Errorf("initializing repository: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证远程仓库连接
|
||||||
|
if err := s.verifyRemoteConnection(config); err != nil {
|
||||||
|
return fmt.Errorf("verifying remote connection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// 启动自动备份
|
// 启动自动备份
|
||||||
if config.AutoBackup && config.BackupInterval > 0 {
|
if config.AutoBackup && config.BackupInterval > 0 {
|
||||||
s.StartAutoBackup()
|
s.StartAutoBackup()
|
||||||
@@ -161,6 +169,22 @@ func (s *BackupService) initializeRepository(config *models.GitBackupConfig, rep
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// verifyRemoteConnection 验证远程仓库连接
|
||||||
|
func (s *BackupService) verifyRemoteConnection(config *models.GitBackupConfig) error {
|
||||||
|
auth, err := s.getAuthMethod(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
remote, err := s.repository.Remote("origin")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = remote.List(&git.ListOptions{Auth: auth})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// getAuthMethod 根据配置获取认证方法
|
// getAuthMethod 根据配置获取认证方法
|
||||||
func (s *BackupService) getAuthMethod(config *models.GitBackupConfig) (transport.AuthMethod, error) {
|
func (s *BackupService) getAuthMethod(config *models.GitBackupConfig) (transport.AuthMethod, error) {
|
||||||
switch config.AuthMethod {
|
switch config.AuthMethod {
|
||||||
@@ -203,31 +227,15 @@ func (s *BackupService) serializeDatabase(repoPath string) error {
|
|||||||
return errors.New("database service not available")
|
return errors.New("database service not available")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取数据库路径
|
|
||||||
dbPath, err := s.dbService.getDatabasePath()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("getting database path: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭数据库连接以确保所有更改都写入磁盘
|
|
||||||
if err := s.dbService.ServiceShutdown(); err != nil {
|
|
||||||
s.logger.Error("Failed to close database connection", "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 直接复制数据库文件到序列化文件
|
|
||||||
dbData, err := os.ReadFile(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading database file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
binFilePath := filepath.Join(repoPath, dbSerializeFile)
|
binFilePath := filepath.Join(repoPath, dbSerializeFile)
|
||||||
if err := os.WriteFile(binFilePath, dbData, 0644); err != nil {
|
|
||||||
return fmt.Errorf("writing serialized database to file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重新初始化数据库服务
|
// 使用 VACUUM INTO 创建数据库副本,不影响现有连接
|
||||||
if err := s.dbService.initDatabase(); err != nil {
|
s.dbService.mu.RLock()
|
||||||
return fmt.Errorf("reinitializing database: %w", err)
|
_, err := s.dbService.db.Exec(fmt.Sprintf("VACUUM INTO '%s'", binFilePath))
|
||||||
|
s.dbService.mu.RUnlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating database backup: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -235,6 +243,10 @@ func (s *BackupService) serializeDatabase(repoPath string) error {
|
|||||||
|
|
||||||
// PushToRemote 推送本地更改到远程仓库
|
// PushToRemote 推送本地更改到远程仓库
|
||||||
func (s *BackupService) PushToRemote() error {
|
func (s *BackupService) PushToRemote() error {
|
||||||
|
// 互斥锁防止并发推送
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
if !s.isInitialized {
|
if !s.isInitialized {
|
||||||
return errors.New("backup service not initialized")
|
return errors.New("backup service not initialized")
|
||||||
}
|
}
|
||||||
@@ -248,56 +260,62 @@ func (s *BackupService) PushToRemote() error {
|
|||||||
return errors.New("backup is disabled")
|
return errors.New("backup is disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 数据库序列化文件的路径
|
// 检查是否有未推送的commit
|
||||||
|
hasUnpushed, err := s.hasUnpushedCommits()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("checking unpushed commits: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
binFilePath := filepath.Join(repoPath, dbSerializeFile)
|
binFilePath := filepath.Join(repoPath, dbSerializeFile)
|
||||||
|
|
||||||
// 函数返回前都删除临时文件
|
// 只有在没有未推送commit时才创建新commit
|
||||||
defer func() {
|
if !hasUnpushed {
|
||||||
if _, err := os.Stat(binFilePath); err == nil {
|
// 序列化数据库
|
||||||
os.Remove(binFilePath)
|
if err := s.serializeDatabase(repoPath); err != nil {
|
||||||
|
return fmt.Errorf("serializing database: %w", err)
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
|
|
||||||
// 序列化数据库
|
// 获取工作树
|
||||||
if err := s.serializeDatabase(repoPath); err != nil {
|
w, err := s.repository.Worktree()
|
||||||
return fmt.Errorf("serializing database: %w", err)
|
if err != nil {
|
||||||
}
|
os.Remove(binFilePath)
|
||||||
|
return fmt.Errorf("getting worktree: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// 获取工作树
|
// 添加序列化的数据库文件
|
||||||
w, err := s.repository.Worktree()
|
if _, err := w.Add(dbSerializeFile); err != nil {
|
||||||
if err != nil {
|
os.Remove(binFilePath)
|
||||||
return fmt.Errorf("getting worktree: %w", err)
|
return fmt.Errorf("adding serialized database file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加序列化的数据库文件
|
// 检查是否有变化需要提交
|
||||||
if _, err := w.Add(dbSerializeFile); err != nil {
|
status, err := w.Status()
|
||||||
return fmt.Errorf("adding serialized database file: %w", err)
|
if err != nil {
|
||||||
}
|
os.Remove(binFilePath)
|
||||||
|
return fmt.Errorf("getting worktree status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否有变化需要提交
|
// 如果没有变化,删除文件并返回
|
||||||
status, err := w.Status()
|
if status.IsClean() {
|
||||||
if err != nil {
|
os.Remove(binFilePath)
|
||||||
return fmt.Errorf("getting worktree status: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果没有变化,直接返回
|
|
||||||
if status.IsClean() {
|
|
||||||
return errors.New("no changes to backup")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建提交
|
|
||||||
_, err = w.Commit(fmt.Sprintf("Backup %s", time.Now().Format("2006-01-02 15:04:05")), &git.CommitOptions{
|
|
||||||
Author: &object.Signature{
|
|
||||||
Name: "voidraft",
|
|
||||||
Email: "backup@voidraft.app",
|
|
||||||
When: time.Now(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if strings.Contains(err.Error(), "cannot create empty commit") {
|
|
||||||
return errors.New("no changes to backup")
|
return errors.New("no changes to backup")
|
||||||
}
|
}
|
||||||
return fmt.Errorf("creating commit: %w", err)
|
|
||||||
|
// 创建提交
|
||||||
|
_, err = w.Commit(fmt.Sprintf("Backup %s", time.Now().Format("2006-01-02 15:04:05")), &git.CommitOptions{
|
||||||
|
Author: &object.Signature{
|
||||||
|
Name: "voidraft",
|
||||||
|
Email: "backup@voidraft.app",
|
||||||
|
When: time.Now(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(binFilePath)
|
||||||
|
if strings.Contains(err.Error(), "cannot create empty commit") {
|
||||||
|
return errors.New("no changes to backup")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("creating commit: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取认证方法并推送到远程
|
// 获取认证方法并推送到远程
|
||||||
@@ -306,25 +324,57 @@ func (s *BackupService) PushToRemote() error {
|
|||||||
return fmt.Errorf("getting auth method: %w", err)
|
return fmt.Errorf("getting auth method: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 推送到远程仓库
|
// 推送到远程仓库(包括之前失败的commit)
|
||||||
if err := s.repository.Push(&git.PushOptions{
|
if err := s.repository.Push(&git.PushOptions{
|
||||||
RemoteName: "origin",
|
RemoteName: "origin",
|
||||||
Auth: auth,
|
Auth: auth,
|
||||||
}); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
}); err != nil {
|
||||||
// 忽略一些常见的非错误情况
|
return err
|
||||||
if strings.Contains(err.Error(), "clean working tree") ||
|
|
||||||
strings.Contains(err.Error(), "already up-to-date") ||
|
|
||||||
strings.Contains(err.Error(), " clean working tree") ||
|
|
||||||
strings.Contains(err.Error(), "reference not found") {
|
|
||||||
// 更新最后推送时间
|
|
||||||
return errors.New("no changes to backup")
|
|
||||||
}
|
|
||||||
return fmt.Errorf("push failed: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 只在推送成功后删除临时文件
|
||||||
|
os.Remove(binFilePath)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasUnpushedCommits 检查是否有未推送的commit
|
||||||
|
func (s *BackupService) hasUnpushedCommits() (bool, error) {
|
||||||
|
localRef, err := s.repository.Head()
|
||||||
|
if err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
config, _, err := s.getConfigAndPath()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
auth, err := s.getAuthMethod(config)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
remote, err := s.repository.Remote("origin")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
refs, err := remote.List(&git.ListOptions{Auth: auth})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
localHash := localRef.Hash()
|
||||||
|
|
||||||
|
for _, ref := range refs {
|
||||||
|
if ref.Name() == localRef.Name() {
|
||||||
|
return localHash != ref.Hash(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// StartAutoBackup 启动自动备份定时器
|
// StartAutoBackup 启动自动备份定时器
|
||||||
func (s *BackupService) StartAutoBackup() error {
|
func (s *BackupService) StartAutoBackup() error {
|
||||||
config, _, err := s.getConfigAndPath()
|
config, _, err := s.getConfigAndPath()
|
||||||
@@ -342,14 +392,13 @@ func (s *BackupService) StartAutoBackup() error {
|
|||||||
s.autoBackupTicker = time.NewTicker(time.Duration(config.BackupInterval) * time.Minute)
|
s.autoBackupTicker = time.NewTicker(time.Duration(config.BackupInterval) * time.Minute)
|
||||||
s.autoBackupStop = make(chan bool)
|
s.autoBackupStop = make(chan bool)
|
||||||
|
|
||||||
|
s.autoBackupWg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
|
defer s.autoBackupWg.Done()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-s.autoBackupTicker.C:
|
case <-s.autoBackupTicker.C:
|
||||||
// 执行推送操作
|
s.PushToRemote()
|
||||||
if err := s.PushToRemote(); err != nil {
|
|
||||||
s.logger.Error("Auto backup failed", "error", err)
|
|
||||||
}
|
|
||||||
case <-s.autoBackupStop:
|
case <-s.autoBackupStop:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -369,16 +418,18 @@ func (s *BackupService) StopAutoBackup() {
|
|||||||
if s.autoBackupStop != nil {
|
if s.autoBackupStop != nil {
|
||||||
close(s.autoBackupStop)
|
close(s.autoBackupStop)
|
||||||
s.autoBackupStop = nil
|
s.autoBackupStop = nil
|
||||||
|
s.autoBackupWg.Wait()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reinitialize 重新初始化备份服务,用于响应配置变更
|
// Reinitialize 重新初始化备份服务,用于响应配置变更
|
||||||
func (s *BackupService) Reinitialize() error {
|
func (s *BackupService) Reinitialize() error {
|
||||||
// 停止自动备份
|
// 先停止自动备份,等待goroutine完成
|
||||||
s.StopAutoBackup()
|
s.StopAutoBackup()
|
||||||
|
|
||||||
// 重新设置标志
|
s.mu.Lock()
|
||||||
s.isInitialized = false
|
s.isInitialized = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
// 重新初始化
|
// 重新初始化
|
||||||
return s.Initialize()
|
return s.Initialize()
|
||||||
@@ -386,11 +437,12 @@ func (s *BackupService) Reinitialize() error {
|
|||||||
|
|
||||||
// HandleConfigChange 处理备份配置变更
|
// HandleConfigChange 处理备份配置变更
|
||||||
func (s *BackupService) HandleConfigChange(config *models.GitBackupConfig) error {
|
func (s *BackupService) HandleConfigChange(config *models.GitBackupConfig) error {
|
||||||
|
|
||||||
// 如果备份功能禁用,只需停止自动备份
|
// 如果备份功能禁用,只需停止自动备份
|
||||||
if !config.Enabled {
|
if !config.Enabled {
|
||||||
s.StopAutoBackup()
|
s.StopAutoBackup()
|
||||||
|
s.mu.Lock()
|
||||||
s.isInitialized = false
|
s.isInitialized = false
|
||||||
|
s.mu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ObserverCallback 观察者回调函数
|
// ObserverCallback 观察者回调函数
|
||||||
// 参数:
|
|
||||||
// - oldValue: 配置变更前的值
|
|
||||||
// - newValue: 配置变更后的值
|
|
||||||
type ObserverCallback func(oldValue, newValue interface{})
|
type ObserverCallback func(oldValue, newValue interface{})
|
||||||
|
|
||||||
// CancelFunc 取消订阅函数
|
// CancelFunc 取消订阅函数
|
||||||
@@ -28,7 +25,6 @@ type observer struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ConfigObserver 配置观察者系统
|
// ConfigObserver 配置观察者系统
|
||||||
// 提供轻量级的配置变更监听机制
|
|
||||||
type ConfigObserver struct {
|
type ConfigObserver struct {
|
||||||
observers map[string][]*observer // 路径 -> 观察者列表
|
observers map[string][]*observer // 路径 -> 观察者列表
|
||||||
observerMu sync.RWMutex // 观察者锁
|
observerMu sync.RWMutex // 观察者锁
|
||||||
@@ -53,19 +49,6 @@ func NewConfigObserver(logger *log.LogService) *ConfigObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Watch 注册配置变更监听器
|
// Watch 注册配置变更监听器
|
||||||
// 参数:
|
|
||||||
// - path: 配置路径,如 "general.enableGlobalHotkey"
|
|
||||||
// - callback: 变更回调函数,接收旧值和新值
|
|
||||||
//
|
|
||||||
// 返回:
|
|
||||||
// - CancelFunc: 取消监听的函数,务必在不需要时调用以避免内存泄漏
|
|
||||||
//
|
|
||||||
// 示例:
|
|
||||||
//
|
|
||||||
// cancel := observer.Watch("general.hotkey", func(old, new interface{}) {
|
|
||||||
// fmt.Printf("配置从 %v 变更为 %v\n", old, new)
|
|
||||||
// })
|
|
||||||
// defer cancel() // 确保清理
|
|
||||||
func (co *ConfigObserver) Watch(path string, callback ObserverCallback) CancelFunc {
|
func (co *ConfigObserver) Watch(path string, callback ObserverCallback) CancelFunc {
|
||||||
// 生成唯一ID
|
// 生成唯一ID
|
||||||
id := fmt.Sprintf("obs_%d", co.nextObserverID.Add(1))
|
id := fmt.Sprintf("obs_%d", co.nextObserverID.Add(1))
|
||||||
@@ -88,17 +71,6 @@ func (co *ConfigObserver) Watch(path string, callback ObserverCallback) CancelFu
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WatchWithContext 使用 Context 注册监听器,Context 取消时自动清理
|
// WatchWithContext 使用 Context 注册监听器,Context 取消时自动清理
|
||||||
// 参数:
|
|
||||||
// - ctx: Context,取消时自动移除观察者
|
|
||||||
// - path: 配置路径
|
|
||||||
// - callback: 变更回调函数
|
|
||||||
//
|
|
||||||
// 示例:
|
|
||||||
//
|
|
||||||
// ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
// defer cancel()
|
|
||||||
// observer.WatchWithContext(ctx, "general.hotkey", callback)
|
|
||||||
// // Context 取消时自动清理
|
|
||||||
func (co *ConfigObserver) WatchWithContext(ctx context.Context, path string, callback ObserverCallback) {
|
func (co *ConfigObserver) WatchWithContext(ctx context.Context, path string, callback ObserverCallback) {
|
||||||
cancel := co.Watch(path, callback)
|
cancel := co.Watch(path, callback)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -132,12 +104,6 @@ func (co *ConfigObserver) removeObserver(path, id string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Notify 通知指定路径的所有观察者
|
// Notify 通知指定路径的所有观察者
|
||||||
// 参数:
|
|
||||||
// - path: 配置路径
|
|
||||||
// - oldValue: 旧值
|
|
||||||
// - newValue: 新值
|
|
||||||
//
|
|
||||||
// 注意:此方法会在独立的 goroutine 中异步执行回调,不会阻塞调用者
|
|
||||||
func (co *ConfigObserver) Notify(path string, oldValue, newValue interface{}) {
|
func (co *ConfigObserver) Notify(path string, oldValue, newValue interface{}) {
|
||||||
// 获取该路径的所有观察者(拷贝以避免并发问题)
|
// 获取该路径的所有观察者(拷贝以避免并发问题)
|
||||||
co.observerMu.RLock()
|
co.observerMu.RLock()
|
||||||
@@ -222,7 +188,6 @@ func (co *ConfigObserver) Clear() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Shutdown 关闭观察者系统
|
// Shutdown 关闭观察者系统
|
||||||
// 等待所有正在执行的回调完成
|
|
||||||
func (co *ConfigObserver) Shutdown() {
|
func (co *ConfigObserver) Shutdown() {
|
||||||
// 取消 context
|
// 取消 context
|
||||||
co.cancel()
|
co.cancel()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -45,25 +46,29 @@ func NewConfigService(logger *log.LogService) *ConfigService {
|
|||||||
configDir := filepath.Join(homeDir, ".voidraft", "config")
|
configDir := filepath.Join(homeDir, ".voidraft", "config")
|
||||||
settingsPath := filepath.Join(configDir, "settings.json")
|
settingsPath := filepath.Join(configDir, "settings.json")
|
||||||
|
|
||||||
cs := &ConfigService{
|
observerService := NewConfigObserver(logger)
|
||||||
logger: logger,
|
|
||||||
configDir: configDir,
|
configMigrator := NewConfigMigrator(logger, configDir, "settings", settingsPath)
|
||||||
settingsPath: settingsPath,
|
|
||||||
koanf: koanf.New("."),
|
return &ConfigService{
|
||||||
|
logger: logger,
|
||||||
|
configDir: configDir,
|
||||||
|
settingsPath: settingsPath,
|
||||||
|
koanf: koanf.New("."),
|
||||||
|
observer: observerService,
|
||||||
|
configMigrator: configMigrator,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化配置观察者系统
|
// ServiceStartup initializes the service when the application starts
|
||||||
cs.observer = NewConfigObserver(logger)
|
func (cs *ConfigService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
||||||
|
err := cs.initConfig()
|
||||||
// 初始化配置迁移器
|
if err != nil {
|
||||||
cs.configMigrator = NewConfigMigrator(logger, configDir, "settings", settingsPath)
|
panic(err)
|
||||||
|
}
|
||||||
cs.initConfig()
|
|
||||||
|
|
||||||
// 启动配置文件监听
|
// 启动配置文件监听
|
||||||
cs.startWatching()
|
cs.startWatching()
|
||||||
|
return nil
|
||||||
return cs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// setDefaults 设置默认配置
|
// setDefaults 设置默认配置
|
||||||
@@ -103,19 +108,10 @@ func (cs *ConfigService) MigrateConfig() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig := models.NewDefaultAppConfig()
|
defaultConfig := models.NewDefaultAppConfig()
|
||||||
result, err := cs.configMigrator.AutoMigrate(defaultConfig, cs.koanf)
|
_, err := cs.configMigrator.AutoMigrate(defaultConfig, cs.koanf)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cs.logger.Error("Failed to check config migration", "error", err)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if result != nil && result.Migrated {
|
|
||||||
cs.logger.Info("Config migration performed",
|
|
||||||
"fields", result.MissingFields,
|
|
||||||
"backup", result.BackupPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,10 +152,11 @@ func (cs *ConfigService) startWatching() {
|
|||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
oldSnapshot := cs.createConfigSnapshot()
|
oldSnapshot := cs.createConfigSnapshot()
|
||||||
cs.koanf.Load(cs.fileProvider, jsonparser.Parser())
|
cs.koanf.Load(cs.fileProvider, jsonparser.Parser())
|
||||||
|
newSnapshot := cs.createConfigSnapshot()
|
||||||
cs.mu.Unlock()
|
cs.mu.Unlock()
|
||||||
|
|
||||||
// 检测配置变更并通知观察者
|
// 检测配置变更并通知观察者
|
||||||
cs.detectAndNotifyChanges(oldSnapshot)
|
cs.notifyChanges(oldSnapshot, newSnapshot)
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -188,23 +185,32 @@ func (cs *ConfigService) GetConfig() (*models.AppConfig, error) {
|
|||||||
func (cs *ConfigService) Set(key string, value interface{}) error {
|
func (cs *ConfigService) Set(key string, value interface{}) error {
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
|
|
||||||
// 获取旧值
|
// 获取旧值用于回滚
|
||||||
oldValue := cs.koanf.Get(key)
|
oldValue := cs.koanf.Get(key)
|
||||||
|
|
||||||
// 设置值到koanf
|
// 设置值到koanf
|
||||||
cs.koanf.Set(key, value)
|
cs.koanf.Set(key, value)
|
||||||
|
|
||||||
// 更新时间戳
|
// 更新时间戳
|
||||||
cs.koanf.Set("metadata.lastUpdated", time.Now().Format(time.RFC3339))
|
newTimestamp := time.Now().Format(time.RFC3339)
|
||||||
|
cs.koanf.Set("metadata.lastUpdated", newTimestamp)
|
||||||
|
|
||||||
// 将配置写回文件
|
// 将配置写回文件
|
||||||
err := cs.writeConfigToFile()
|
err := cs.writeConfigToFile()
|
||||||
cs.mu.Unlock()
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// 写文件失败,回滚内存状态
|
||||||
|
if oldValue != nil {
|
||||||
|
cs.koanf.Set(key, oldValue)
|
||||||
|
} else {
|
||||||
|
cs.koanf.Delete(key)
|
||||||
|
}
|
||||||
|
cs.mu.Unlock()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cs.mu.Unlock()
|
||||||
|
|
||||||
if cs.observer != nil {
|
if cs.observer != nil {
|
||||||
cs.observer.Notify(key, oldValue, value)
|
cs.observer.Notify(key, oldValue, value)
|
||||||
}
|
}
|
||||||
@@ -262,13 +268,14 @@ func (cs *ConfigService) ResetConfig() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newSnapshot := cs.createConfigSnapshot()
|
||||||
cs.mu.Unlock()
|
cs.mu.Unlock()
|
||||||
|
|
||||||
// 重新启动文件监听
|
// 重新启动文件监听
|
||||||
cs.startWatching()
|
cs.startWatching()
|
||||||
|
|
||||||
// 检测配置变更并通知观察者
|
// 检测配置变更并通知观察者
|
||||||
cs.detectAndNotifyChanges(oldSnapshot)
|
cs.notifyChanges(oldSnapshot, newSnapshot)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -297,14 +304,10 @@ func (cs *ConfigService) WatchWithContext(ctx context.Context, path string, call
|
|||||||
cs.observer.WatchWithContext(ctx, path, callback)
|
cs.observer.WatchWithContext(ctx, path, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
// createConfigSnapshot 创建当前配置的快照
|
// createConfigSnapshot 创建当前配置的快照(调用者需确保已持有锁)
|
||||||
func (cs *ConfigService) createConfigSnapshot() map[string]interface{} {
|
func (cs *ConfigService) createConfigSnapshot() map[string]interface{} {
|
||||||
cs.mu.RLock()
|
|
||||||
defer cs.mu.RUnlock()
|
|
||||||
snapshot := make(map[string]interface{})
|
snapshot := make(map[string]interface{})
|
||||||
allKeys := cs.koanf.All()
|
allKeys := cs.koanf.All()
|
||||||
|
|
||||||
// 递归展平配置
|
|
||||||
flattenMap("", allKeys, snapshot)
|
flattenMap("", allKeys, snapshot)
|
||||||
return snapshot
|
return snapshot
|
||||||
}
|
}
|
||||||
@@ -331,11 +334,8 @@ func flattenMap(prefix string, data map[string]interface{}, result map[string]in
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectAndNotifyChanges 检测配置变更并通知观察者
|
// notifyChanges 检测配置变更并通知观察者
|
||||||
func (cs *ConfigService) detectAndNotifyChanges(oldSnapshot map[string]interface{}) {
|
func (cs *ConfigService) notifyChanges(oldSnapshot, newSnapshot map[string]interface{}) {
|
||||||
// 创建新快照
|
|
||||||
newSnapshot := cs.createConfigSnapshot()
|
|
||||||
|
|
||||||
// 检测变更
|
// 检测变更
|
||||||
changes := make(map[string]struct {
|
changes := make(map[string]struct {
|
||||||
OldValue interface{}
|
OldValue interface{}
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v3/pkg/services/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
// StoreOption 存储服务配置选项
|
|
||||||
type StoreOption struct {
|
|
||||||
FilePath string
|
|
||||||
AutoSave bool
|
|
||||||
Logger *log.LogService
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store 泛型存储服务
|
|
||||||
type Store[T any] struct {
|
|
||||||
option StoreOption
|
|
||||||
data atomic.Value // stores T
|
|
||||||
dataMap sync.Map // thread-safe map
|
|
||||||
unsaved atomic.Bool
|
|
||||||
initOnce sync.Once
|
|
||||||
logger *log.LogService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewStore 存储服务
|
|
||||||
func NewStore[T any](option StoreOption) *Store[T] {
|
|
||||||
logger := option.Logger
|
|
||||||
if logger == nil {
|
|
||||||
logger = log.New()
|
|
||||||
}
|
|
||||||
|
|
||||||
store := &Store[T]{
|
|
||||||
option: option,
|
|
||||||
logger: logger,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 异步初始化
|
|
||||||
store.initOnce.Do(func() {
|
|
||||||
store.initialize()
|
|
||||||
})
|
|
||||||
|
|
||||||
return store
|
|
||||||
}
|
|
||||||
|
|
||||||
// initialize 初始化存储
|
|
||||||
func (s *Store[T]) initialize() {
|
|
||||||
// 确保目录存在
|
|
||||||
if s.option.FilePath != "" {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(s.option.FilePath), 0755); err != nil {
|
|
||||||
s.logger.Error("store: failed to create directory", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载数据
|
|
||||||
s.load()
|
|
||||||
}
|
|
||||||
|
|
||||||
// load 加载数据
|
|
||||||
func (s *Store[T]) load() {
|
|
||||||
if s.option.FilePath == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查文件是否存在
|
|
||||||
if _, err := os.Stat(s.option.FilePath); os.IsNotExist(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(s.option.FilePath)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("store: failed to read file", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(data) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var value T
|
|
||||||
if err := json.Unmarshal(data, &value); err != nil {
|
|
||||||
// 尝试加载为map格式
|
|
||||||
var mapData map[string]any
|
|
||||||
if err := json.Unmarshal(data, &mapData); err != nil {
|
|
||||||
s.logger.Error("store: failed to parse data", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 将map数据存储到sync.Map中
|
|
||||||
for k, v := range mapData {
|
|
||||||
s.dataMap.Store(k, v)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.data.Store(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save 保存数据
|
|
||||||
func (s *Store[T]) Save() error {
|
|
||||||
if !s.unsaved.Load() {
|
|
||||||
return nil // 没有未保存的更改
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.saveInternal(); err != nil {
|
|
||||||
return fmt.Errorf("store: failed to save: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.unsaved.Store(false)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// saveInternal 内部保存实现
|
|
||||||
func (s *Store[T]) saveInternal() error {
|
|
||||||
if s.option.FilePath == "" {
|
|
||||||
return fmt.Errorf("store: filepath not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取要保存的数据
|
|
||||||
var data []byte
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if value := s.data.Load(); value != nil {
|
|
||||||
data, err = json.MarshalIndent(value, "", " ")
|
|
||||||
} else {
|
|
||||||
// 如果没有结构化数据,保存map数据
|
|
||||||
mapData := make(map[string]any)
|
|
||||||
s.dataMap.Range(func(key, value any) bool {
|
|
||||||
if k, ok := key.(string); ok {
|
|
||||||
mapData[k] = value
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
data, err = json.MarshalIndent(mapData, "", " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to serialize data: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 原子写入
|
|
||||||
return s.atomicWrite(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// atomicWrite 原子写入文件
|
|
||||||
func (s *Store[T]) atomicWrite(data []byte) error {
|
|
||||||
dir := filepath.Dir(s.option.FilePath)
|
|
||||||
|
|
||||||
// 创建临时文件
|
|
||||||
tempFile, err := os.CreateTemp(dir, "store-*.tmp")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tempPath := tempFile.Name()
|
|
||||||
defer func() {
|
|
||||||
tempFile.Close()
|
|
||||||
if err != nil {
|
|
||||||
os.Remove(tempPath)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// 写入数据并同步
|
|
||||||
if _, err = tempFile.Write(data); err != nil {
|
|
||||||
return fmt.Errorf("failed to write data: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = tempFile.Sync(); err != nil {
|
|
||||||
return fmt.Errorf("failed to sync file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = tempFile.Close(); err != nil {
|
|
||||||
return fmt.Errorf("failed to close temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 原子替换
|
|
||||||
if err = os.Rename(tempPath, s.option.FilePath); err != nil {
|
|
||||||
return fmt.Errorf("failed to rename file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get 获取数据
|
|
||||||
func (s *Store[T]) Get() T {
|
|
||||||
if value := s.data.Load(); value != nil {
|
|
||||||
return value.(T)
|
|
||||||
}
|
|
||||||
var zero T
|
|
||||||
return zero
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetProperty 获取指定属性
|
|
||||||
func (s *Store[T]) GetProperty(key string) any {
|
|
||||||
if key == "" {
|
|
||||||
// 返回所有map数据
|
|
||||||
result := make(map[string]any)
|
|
||||||
s.dataMap.Range(func(k, v any) bool {
|
|
||||||
if str, ok := k.(string); ok {
|
|
||||||
result[str] = v
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
if value, ok := s.dataMap.Load(key); ok {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set 设置数据
|
|
||||||
func (s *Store[T]) Set(data T) error {
|
|
||||||
s.data.Store(data)
|
|
||||||
s.unsaved.Store(true)
|
|
||||||
|
|
||||||
if s.option.AutoSave {
|
|
||||||
return s.saveInternal()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetProperty 设置指定属性
|
|
||||||
func (s *Store[T]) SetProperty(key string, value any) error {
|
|
||||||
s.dataMap.Store(key, value)
|
|
||||||
s.unsaved.Store(true)
|
|
||||||
|
|
||||||
if s.option.AutoSave {
|
|
||||||
return s.saveInternal()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete 删除指定属性
|
|
||||||
func (s *Store[T]) Delete(key string) error {
|
|
||||||
s.dataMap.Delete(key)
|
|
||||||
s.unsaved.Store(true)
|
|
||||||
|
|
||||||
if s.option.AutoSave {
|
|
||||||
return s.saveInternal()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasUnsavedChanges 是否有未保存的更改
|
|
||||||
func (s *Store[T]) HasUnsavedChanges() bool {
|
|
||||||
return s.unsaved.Load()
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user