✨ Add configuration merge service
This commit is contained in:
@@ -891,6 +891,11 @@ export class KeyBindingConfig {
|
||||
* KeyBindingMetadata 快捷键配置元数据
|
||||
*/
|
||||
export class KeyBindingMetadata {
|
||||
/**
|
||||
* 配置版本
|
||||
*/
|
||||
"version": string;
|
||||
|
||||
/**
|
||||
* 最后更新时间
|
||||
*/
|
||||
@@ -898,6 +903,9 @@ export class KeyBindingMetadata {
|
||||
|
||||
/** Creates a new KeyBindingMetadata instance. */
|
||||
constructor($$source: Partial<KeyBindingMetadata> = {}) {
|
||||
if (!("version" in $$source)) {
|
||||
this["version"] = "";
|
||||
}
|
||||
if (!("lastUpdated" in $$source)) {
|
||||
this["lastUpdated"] = "";
|
||||
}
|
||||
|
@@ -19,6 +19,10 @@ export default {
|
||||
noLanguageFound: 'No language found',
|
||||
formatHint: 'Current block supports formatting, use Ctrl+Shift+F shortcut for formatting',
|
||||
},
|
||||
languages: {
|
||||
'zh-CN': 'Chinese',
|
||||
'en-US': 'English'
|
||||
},
|
||||
systemTheme: {
|
||||
dark: 'Dark',
|
||||
light: 'Light',
|
||||
|
@@ -19,6 +19,10 @@ export default {
|
||||
noLanguageFound: '未找到匹配的语言',
|
||||
formatHint: '当前区块支持格式化,使用 Ctrl+Shift+F 进行格式化',
|
||||
},
|
||||
languages: {
|
||||
'zh-CN': '简体中文',
|
||||
'en-US': 'English'
|
||||
},
|
||||
systemTheme: {
|
||||
dark: '深色',
|
||||
light: '浅色',
|
||||
|
@@ -149,7 +149,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||
},
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
lastUpdated: new Date().toString()
|
||||
lastUpdated: new Date().toString(),
|
||||
}
|
||||
};
|
||||
|
||||
|
@@ -17,76 +17,69 @@ export const useDocumentStore = defineStore('document', () => {
|
||||
const isSaveInProgress = computed(() => isSaving.value);
|
||||
const lastSavedTime = computed(() => lastSaved.value);
|
||||
|
||||
// 状态管理包装器
|
||||
const withStateGuard = async <T>(
|
||||
operation: () => Promise<T>,
|
||||
stateRef: typeof isLoading | typeof isSaving
|
||||
): Promise<T | null> => {
|
||||
if (stateRef.value) return null;
|
||||
|
||||
stateRef.value = true;
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
stateRef.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 加载文档
|
||||
const loadDocument = () => withStateGuard(
|
||||
async () => {
|
||||
const loadDocument = async (): Promise<Document | null> => {
|
||||
if (isLoading.value) return null;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const doc = await DocumentService.GetActiveDocument();
|
||||
activeDocument.value = doc;
|
||||
return doc;
|
||||
},
|
||||
isLoading
|
||||
);
|
||||
} catch (error) {
|
||||
return null;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 保存文档
|
||||
const saveDocument = async (content: string): Promise<boolean> => {
|
||||
const result = await withStateGuard(
|
||||
async () => {
|
||||
await DocumentService.UpdateActiveDocumentContent(content);
|
||||
lastSaved.value = new Date();
|
||||
|
||||
// 使用可选链更新本地副本
|
||||
if (activeDocument.value) {
|
||||
activeDocument.value.content = content;
|
||||
activeDocument.value.meta.lastUpdated = lastSaved.value;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
isSaving
|
||||
);
|
||||
if (isSaving.value) return false;
|
||||
|
||||
return result ?? false;
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await DocumentService.UpdateActiveDocumentContent(content);
|
||||
lastSaved.value = new Date();
|
||||
|
||||
// 更新本地副本
|
||||
if (activeDocument.value) {
|
||||
activeDocument.value.content = content;
|
||||
activeDocument.value.meta.lastUpdated = lastSaved.value;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 强制保存文档到磁盘
|
||||
const forceSaveDocument = async (): Promise<boolean> => {
|
||||
const result = await withStateGuard(
|
||||
async () => {
|
||||
// 直接调用强制保存API
|
||||
await DocumentService.ForceSave();
|
||||
|
||||
lastSaved.value = new Date();
|
||||
|
||||
// 使用可选链更新时间戳
|
||||
if (activeDocument.value) {
|
||||
activeDocument.value.meta.lastUpdated = lastSaved.value;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
isSaving
|
||||
);
|
||||
if (isSaving.value) return false;
|
||||
|
||||
return result ?? false;
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await DocumentService.ForceSave();
|
||||
lastSaved.value = new Date();
|
||||
|
||||
// 更新时间戳
|
||||
if (activeDocument.value) {
|
||||
activeDocument.value.meta.lastUpdated = lastSaved.value;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化
|
||||
const initialize = async () => {
|
||||
const initialize = async (): Promise<void> => {
|
||||
await loadDocument();
|
||||
};
|
||||
|
||||
|
@@ -5,7 +5,6 @@ import {EditorState, Extension} from '@codemirror/state';
|
||||
import {useConfigStore} from './configStore';
|
||||
import {useDocumentStore} from './documentStore';
|
||||
import {useThemeStore} from './themeStore';
|
||||
import {useI18n} from 'vue-i18n';
|
||||
import {SystemThemeType} from '@/../bindings/voidraft/internal/models/models';
|
||||
import {DocumentService} from '@/../bindings/voidraft/internal/services';
|
||||
import {ensureSyntaxTree} from "@codemirror/language"
|
||||
@@ -28,7 +27,6 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
const configStore = useConfigStore();
|
||||
const documentStore = useDocumentStore();
|
||||
const themeStore = useThemeStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
// 状态
|
||||
const documentStats = ref<DocumentStats>({
|
||||
@@ -267,10 +265,7 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
editorContainer,
|
||||
|
||||
// 方法
|
||||
setEditorView,
|
||||
setEditorContainer,
|
||||
updateDocumentStats,
|
||||
applyFontSize,
|
||||
createEditor,
|
||||
reconfigureTabSettings,
|
||||
reconfigureFontSettings,
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import {defineStore} from 'pinia';
|
||||
import {computed, ref} from 'vue';
|
||||
import * as runtime from '@wailsio/runtime';
|
||||
|
||||
export interface SystemEnvironment {
|
||||
@@ -50,8 +50,7 @@ export const useSystemStore = defineStore('system', () => {
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const env = await runtime.System.Environment();
|
||||
environment.value = env;
|
||||
environment.value = await runtime.System.Environment();
|
||||
} catch (err) {
|
||||
error.value = 'Failed to get system environment';
|
||||
environment.value = null;
|
||||
|
@@ -5,7 +5,12 @@ import {computed, onUnmounted, ref} from 'vue';
|
||||
import SettingSection from '../components/SettingSection.vue';
|
||||
import SettingItem from '../components/SettingItem.vue';
|
||||
import ToggleSwitch from '../components/ToggleSwitch.vue';
|
||||
import {DialogService, MigrationService, MigrationProgress, MigrationStatus} from '@/../bindings/voidraft/internal/services';
|
||||
import {
|
||||
DialogService,
|
||||
MigrationProgress,
|
||||
MigrationService,
|
||||
MigrationStatus
|
||||
} from '@/../bindings/voidraft/internal/services';
|
||||
import * as runtime from '@wailsio/runtime';
|
||||
|
||||
const {t} = useI18n();
|
||||
@@ -29,28 +34,28 @@ let hideProgressTimer: any = null;
|
||||
// 开始轮询迁移进度
|
||||
const startPolling = () => {
|
||||
if (isPolling.value) return;
|
||||
|
||||
|
||||
isPolling.value = true;
|
||||
showProgress.value = true;
|
||||
progressError.value = '';
|
||||
|
||||
|
||||
// 立即重置迁移进度状态,避免从之前的失败状态渐变
|
||||
migrationProgress.value = new MigrationProgress({
|
||||
status: MigrationStatus.MigrationStatusMigrating,
|
||||
progress: 0
|
||||
});
|
||||
|
||||
|
||||
pollingTimer = window.setInterval(async () => {
|
||||
try {
|
||||
const progress = await MigrationService.GetProgress();
|
||||
migrationProgress.value = progress;
|
||||
|
||||
const { status, error } = progress;
|
||||
const {status, error} = progress;
|
||||
const isCompleted = [MigrationStatus.MigrationStatusCompleted, MigrationStatus.MigrationStatusFailed].includes(status);
|
||||
|
||||
|
||||
if (isCompleted) {
|
||||
stopPolling();
|
||||
|
||||
|
||||
// 设置错误信息(如果是失败状态)
|
||||
progressError.value = (status === MigrationStatus.MigrationStatusFailed) ? (error || 'Migration failed') : '';
|
||||
|
||||
@@ -59,7 +64,7 @@ const startPolling = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
stopPolling();
|
||||
|
||||
|
||||
// 使用常量简化错误处理
|
||||
const errorMsg = 'Failed to get migration progress';
|
||||
Object.assign(migrationProgress.value, {
|
||||
@@ -68,7 +73,7 @@ const startPolling = () => {
|
||||
error: errorMsg
|
||||
});
|
||||
progressError.value = errorMsg;
|
||||
|
||||
|
||||
hideProgressTimer = setTimeout(hideProgress, 5000);
|
||||
}
|
||||
}, 200);
|
||||
@@ -87,13 +92,13 @@ const stopPolling = () => {
|
||||
const hideProgress = () => {
|
||||
showProgress.value = false;
|
||||
progressError.value = '';
|
||||
|
||||
|
||||
// 重置迁移状态,避免下次显示时状态不正确
|
||||
migrationProgress.value = new MigrationProgress({
|
||||
status: MigrationStatus.MigrationStatusCompleted,
|
||||
progress: 0
|
||||
});
|
||||
|
||||
|
||||
if (hideProgressTimer) {
|
||||
clearTimeout(hideProgressTimer);
|
||||
hideProgressTimer = null;
|
||||
@@ -110,8 +115,8 @@ const statusClassMap = new Map([
|
||||
[MigrationStatus.MigrationStatusFailed, 'error']
|
||||
]);
|
||||
|
||||
const progressBarClass = computed(() =>
|
||||
showProgress.value ? statusClassMap.get(migrationProgress.value.status) ?? '' : ''
|
||||
const progressBarClass = computed(() =>
|
||||
showProgress.value ? statusClassMap.get(migrationProgress.value.status) ?? '' : ''
|
||||
);
|
||||
|
||||
const progressBarWidth = computed(() => {
|
||||
@@ -125,7 +130,7 @@ let resetConfirmTimer: any = null;
|
||||
|
||||
// 可选键列表
|
||||
const keyOptions = [
|
||||
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
|
||||
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
|
||||
'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
|
||||
'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12'
|
||||
@@ -210,16 +215,16 @@ const confirmReset = async () => {
|
||||
// 计算热键预览文本 - 使用现代语法简化
|
||||
const hotkeyPreview = computed(() => {
|
||||
if (!enableGlobalHotkey.value) return '';
|
||||
|
||||
const { ctrl, shift, alt, win, key } = configStore.config.general.globalHotkey;
|
||||
|
||||
const {ctrl, shift, alt, win, key} = configStore.config.general.globalHotkey;
|
||||
const modifiers = [
|
||||
ctrl && 'Ctrl',
|
||||
shift && 'Shift',
|
||||
shift && 'Shift',
|
||||
alt && 'Alt',
|
||||
win && 'Win',
|
||||
key
|
||||
].filter(Boolean);
|
||||
|
||||
|
||||
return modifiers.join(' + ');
|
||||
});
|
||||
|
||||
@@ -229,45 +234,42 @@ const currentDataPath = computed(() => configStore.config.general.dataPath);
|
||||
// 选择数据存储目录
|
||||
const selectDataDirectory = async () => {
|
||||
if (isMigrating.value) return;
|
||||
|
||||
try {
|
||||
const selectedPath = await DialogService.SelectDirectory();
|
||||
|
||||
// 检查用户是否取消了选择或路径为空
|
||||
if (!selectedPath || !selectedPath.trim() || selectedPath === currentDataPath.value) {
|
||||
return;
|
||||
}
|
||||
const oldPath = currentDataPath.value;
|
||||
const newPath = selectedPath.trim();
|
||||
|
||||
// 清除之前的进度状态
|
||||
hideProgress();
|
||||
|
||||
// 开始轮询迁移进度
|
||||
startPolling();
|
||||
|
||||
// 开始迁移
|
||||
try {
|
||||
await MigrationService.MigrateDirectory(oldPath, newPath);
|
||||
await configStore.setDataPath(newPath);
|
||||
} catch (error) {
|
||||
stopPolling();
|
||||
|
||||
// 使用解构和默认值简化错误处理
|
||||
const errorMsg = error?.toString() || 'Migration failed';
|
||||
showProgress.value = true;
|
||||
|
||||
Object.assign(migrationProgress.value, {
|
||||
status: MigrationStatus.MigrationStatusFailed,
|
||||
progress: 0,
|
||||
error: errorMsg
|
||||
});
|
||||
progressError.value = errorMsg;
|
||||
|
||||
hideProgressTimer = setTimeout(hideProgress, 5000);
|
||||
}
|
||||
} catch (dialogError) {
|
||||
console.error(dialogError);
|
||||
|
||||
const selectedPath = await DialogService.SelectDirectory();
|
||||
|
||||
// 检查用户是否取消了选择或路径为空
|
||||
if (!selectedPath || !selectedPath.trim() || selectedPath === currentDataPath.value) {
|
||||
return;
|
||||
}
|
||||
const oldPath = currentDataPath.value;
|
||||
const newPath = selectedPath.trim();
|
||||
|
||||
// 清除之前的进度状态
|
||||
hideProgress();
|
||||
|
||||
// 开始轮询迁移进度
|
||||
startPolling();
|
||||
|
||||
// 开始迁移
|
||||
try {
|
||||
await MigrationService.MigrateDirectory(oldPath, newPath);
|
||||
await configStore.setDataPath(newPath);
|
||||
} catch (error) {
|
||||
stopPolling();
|
||||
|
||||
// 使用解构和默认值简化错误处理
|
||||
const errorMsg = error?.toString() || 'Migration failed';
|
||||
showProgress.value = true;
|
||||
|
||||
Object.assign(migrationProgress.value, {
|
||||
status: MigrationStatus.MigrationStatusFailed,
|
||||
progress: 0,
|
||||
error: errorMsg
|
||||
});
|
||||
progressError.value = errorMsg;
|
||||
|
||||
hideProgressTimer = setTimeout(hideProgress, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -287,12 +289,13 @@ onUnmounted(() => {
|
||||
<SettingItem :title="t('settings.enableGlobalHotkey')">
|
||||
<ToggleSwitch v-model="enableGlobalHotkey"/>
|
||||
</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">
|
||||
<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')">
|
||||
@@ -301,27 +304,29 @@ onUnmounted(() => {
|
||||
<span class="modifier-key">Shift</span>
|
||||
</label>
|
||||
<label class="modifier-label" :class="{ active: modifierKeys.alt }" @click="toggleModifier('alt')">
|
||||
<input type="checkbox" :checked="modifierKeys.alt" class="hidden-checkbox" :disabled="!enableGlobalHotkey">
|
||||
<input type="checkbox" :checked="modifierKeys.alt" class="hidden-checkbox"
|
||||
:disabled="!enableGlobalHotkey">
|
||||
<span class="modifier-key">Alt</span>
|
||||
</label>
|
||||
<label class="modifier-label" :class="{ active: modifierKeys.win }" @click="toggleModifier('win')">
|
||||
<input type="checkbox" :checked="modifierKeys.win" class="hidden-checkbox" :disabled="!enableGlobalHotkey">
|
||||
<input type="checkbox" :checked="modifierKeys.win" class="hidden-checkbox"
|
||||
:disabled="!enableGlobalHotkey">
|
||||
<span class="modifier-key">Win</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<select class="key-select" :value="selectedKey" @change="updateSelectedKey" :disabled="!enableGlobalHotkey">
|
||||
<option v-for="key in keyOptions" :key="key" :value="key">{{ key }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="hotkey-preview">
|
||||
<span class="preview-label">预览:</span>
|
||||
<span class="preview-hotkey">{{ hotkeyPreview || '无' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingSection>
|
||||
|
||||
|
||||
<SettingSection :title="t('settings.window')">
|
||||
<SettingItem :title="t('settings.alwaysOnTop')">
|
||||
<ToggleSwitch v-model="alwaysOnTop"/>
|
||||
@@ -330,13 +335,13 @@ onUnmounted(() => {
|
||||
<ToggleSwitch v-model="enableSystemTray"/>
|
||||
</SettingItem>
|
||||
</SettingSection>
|
||||
|
||||
|
||||
<SettingSection :title="t('settings.startup')">
|
||||
<SettingItem :title="t('settings.startAtLogin')">
|
||||
<ToggleSwitch v-model="startAtLogin"/>
|
||||
</SettingItem>
|
||||
</SettingSection>
|
||||
|
||||
|
||||
<SettingSection :title="t('settings.dataStorage')">
|
||||
<div class="data-path-setting">
|
||||
<div class="setting-header">
|
||||
@@ -344,27 +349,27 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="data-path-controls">
|
||||
<div class="path-input-container">
|
||||
<input
|
||||
type="text"
|
||||
:value="currentDataPath"
|
||||
readonly
|
||||
:placeholder="t('settings.clickToSelectPath')"
|
||||
class="path-display-input"
|
||||
@click="selectDataDirectory"
|
||||
:title="t('settings.clickToSelectPath')"
|
||||
:disabled="isMigrating"
|
||||
<input
|
||||
type="text"
|
||||
:value="currentDataPath"
|
||||
readonly
|
||||
:placeholder="t('settings.clickToSelectPath')"
|
||||
class="path-display-input"
|
||||
@click="selectDataDirectory"
|
||||
:title="t('settings.clickToSelectPath')"
|
||||
:disabled="isMigrating"
|
||||
/>
|
||||
<!-- 简洁的进度条 -->
|
||||
<div
|
||||
class="progress-bar"
|
||||
:class="[
|
||||
<div
|
||||
class="progress-bar"
|
||||
:class="[
|
||||
{ 'active': showProgress },
|
||||
progressBarClass
|
||||
]"
|
||||
:style="{ width: progressBarWidth }"
|
||||
:style="{ width: progressBarWidth }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<Transition name="error-fade">
|
||||
<div v-if="progressError" class="progress-error">
|
||||
@@ -374,13 +379,13 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</SettingSection>
|
||||
|
||||
|
||||
<SettingSection :title="t('settings.dangerZone')">
|
||||
<SettingItem :title="t('settings.resetAllSettings')">
|
||||
<button
|
||||
class="reset-button"
|
||||
:class="{ 'confirming': resetConfirmState === 'confirming' }"
|
||||
@click="resetSettings"
|
||||
<button
|
||||
class="reset-button"
|
||||
:class="{ 'confirming': resetConfirmState === 'confirming' }"
|
||||
@click="resetSettings"
|
||||
>
|
||||
<template v-if="resetConfirmState === 'idle'">
|
||||
{{ t('settings.reset') }}
|
||||
@@ -404,12 +409,12 @@ onUnmounted(() => {
|
||||
.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;
|
||||
@@ -417,23 +422,23 @@ onUnmounted(() => {
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
|
||||
.hotkey-modifiers {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
|
||||
.modifier-label {
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
||||
.hidden-checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.modifier-key {
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
@@ -443,12 +448,12 @@ onUnmounted(() => {
|
||||
color: var(--settings-text-secondary);
|
||||
font-size: 12px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: var(--settings-hover);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
&.active .modifier-key {
|
||||
background-color: #2c5a9e;
|
||||
color: #ffffff;
|
||||
@@ -456,39 +461,39 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.key-select {
|
||||
min-width: 80px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--settings-input-bg);
|
||||
border: 1px solid var(--settings-input-border);
|
||||
border-radius: 4px;
|
||||
color: var(--settings-text);
|
||||
font-size: 12px;
|
||||
appearance: none;
|
||||
border-radius: 4px;
|
||||
color: var(--settings-text);
|
||||
font-size: 12px;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23999999' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
background-size: 16px;
|
||||
padding-right: 30px;
|
||||
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background-color: var(--settings-hover);
|
||||
}
|
||||
|
||||
|
||||
option {
|
||||
background-color: var(--settings-input-bg);
|
||||
color: var(--settings-text);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.hotkey-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -498,34 +503,34 @@ onUnmounted(() => {
|
||||
border: 1px solid var(--settings-border);
|
||||
border-radius: 4px;
|
||||
margin-top: 8px;
|
||||
|
||||
.preview-label {
|
||||
font-size: 11px;
|
||||
color: var(--settings-text-secondary);
|
||||
}
|
||||
|
||||
.preview-hotkey {
|
||||
font-size: 12px;
|
||||
color: var(--settings-text);
|
||||
font-weight: 500;
|
||||
font-family: 'Consolas', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
font-size: 11px;
|
||||
color: var(--settings-text-secondary);
|
||||
}
|
||||
|
||||
.preview-hotkey {
|
||||
font-size: 12px;
|
||||
color: var(--settings-text);
|
||||
font-weight: 500;
|
||||
font-family: 'Consolas', 'Courier New', monospace;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.data-path-setting {
|
||||
padding: 14px 0;
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
|
||||
.setting-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
|
||||
|
||||
.setting-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
@@ -538,12 +543,12 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
|
||||
.path-input-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
|
||||
|
||||
.path-display-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
@@ -556,27 +561,27 @@ onUnmounted(() => {
|
||||
line-height: 1.2;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--settings-hover);
|
||||
background-color: var(--settings-hover);
|
||||
}
|
||||
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.progress-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@@ -588,10 +593,10 @@ onUnmounted(() => {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
z-index: 1;
|
||||
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
|
||||
|
||||
&.migrating {
|
||||
background: linear-gradient(90deg, #22c55e, #16a34a);
|
||||
animation: progress-pulse 2s ease-in-out infinite;
|
||||
@@ -634,21 +639,21 @@ onUnmounted(() => {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
min-width: 80px;
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: #c82333;
|
||||
border-color: #bd2130;
|
||||
}
|
||||
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
|
||||
&.confirming {
|
||||
background-color: #ff4757;
|
||||
border-color: #ff4757;
|
||||
animation: pulse-button 1.5s infinite;
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: #ff3838;
|
||||
border-color: #ff3838;
|
||||
|
1
go.mod
1
go.mod
@@ -5,6 +5,7 @@ go 1.23.0
|
||||
toolchain go1.24.2
|
||||
|
||||
require (
|
||||
github.com/Masterminds/semver/v3 v3.3.1
|
||||
github.com/knadh/koanf/parsers/json v1.0.0
|
||||
github.com/knadh/koanf/providers/file v1.2.0
|
||||
github.com/knadh/koanf/providers/structs v1.0.0
|
||||
|
2
go.sum
2
go.sum
@@ -1,5 +1,7 @@
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4=
|
||||
github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
|
@@ -153,3 +153,23 @@ func NewDefaultAppConfig() *AppConfig {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetVersion 获取配置版本
|
||||
func (ac *AppConfig) GetVersion() string {
|
||||
return ac.Metadata.Version
|
||||
}
|
||||
|
||||
// SetVersion 设置配置版本
|
||||
func (ac *AppConfig) SetVersion(version string) {
|
||||
ac.Metadata.Version = version
|
||||
}
|
||||
|
||||
// SetLastUpdated 设置最后更新时间
|
||||
func (ac *AppConfig) SetLastUpdated(timeStr string) {
|
||||
ac.Metadata.LastUpdated = timeStr
|
||||
}
|
||||
|
||||
// GetDefaultConfig 获取默认配置
|
||||
func (ac *AppConfig) GetDefaultConfig() any {
|
||||
return NewDefaultAppConfig()
|
||||
}
|
||||
|
@@ -93,6 +93,7 @@ const (
|
||||
|
||||
// KeyBindingMetadata 快捷键配置元数据
|
||||
type KeyBindingMetadata struct {
|
||||
Version string `json:"version"` // 配置版本
|
||||
LastUpdated string `json:"lastUpdated"` // 最后更新时间
|
||||
}
|
||||
|
||||
@@ -107,6 +108,7 @@ func NewDefaultKeyBindingConfig() *KeyBindingConfig {
|
||||
return &KeyBindingConfig{
|
||||
KeyBindings: NewDefaultKeyBindings(),
|
||||
Metadata: KeyBindingMetadata{
|
||||
Version: "1.0.0",
|
||||
LastUpdated: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
@@ -504,3 +506,23 @@ func NewDefaultKeyBindings() []KeyBinding {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetVersion 获取配置版本
|
||||
func (kbc *KeyBindingConfig) GetVersion() string {
|
||||
return kbc.Metadata.Version
|
||||
}
|
||||
|
||||
// SetVersion 设置配置版本
|
||||
func (kbc *KeyBindingConfig) SetVersion(version string) {
|
||||
kbc.Metadata.Version = version
|
||||
}
|
||||
|
||||
// SetLastUpdated 设置最后更新时间
|
||||
func (kbc *KeyBindingConfig) SetLastUpdated(timeStr string) {
|
||||
kbc.Metadata.LastUpdated = timeStr
|
||||
}
|
||||
|
||||
// GetDefaultConfig 获取默认配置
|
||||
func (kbc *KeyBindingConfig) GetDefaultConfig() any {
|
||||
return NewDefaultKeyBindingConfig()
|
||||
}
|
||||
|
325
internal/services/config_migration_service.go
Normal file
325
internal/services/config_migration_service.go
Normal file
@@ -0,0 +1,325 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"time"
|
||||
"voidraft/internal/models"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
jsonparser "github.com/knadh/koanf/parsers/json"
|
||||
"github.com/knadh/koanf/providers/structs"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/log"
|
||||
)
|
||||
|
||||
const (
|
||||
// CurrentAppConfigVersion 当前应用配置版本
|
||||
CurrentAppConfigVersion = "1.0.0"
|
||||
// CurrentKeyBindingConfigVersion 当前快捷键配置版本
|
||||
CurrentKeyBindingConfigVersion = "1.0.0"
|
||||
// BackupFilePattern 备份文件名模式
|
||||
BackupFilePattern = "%s.backup.%s.json"
|
||||
|
||||
// 资源限制常量
|
||||
MaxConfigFileSize = 10 * 1024 * 1024 // 10MB
|
||||
MaxRecursionDepth = 50 // 最大递归深度
|
||||
)
|
||||
|
||||
// Migratable 可迁移的配置接口
|
||||
type Migratable interface {
|
||||
GetVersion() string // 获取当前版本
|
||||
SetVersion(string) // 设置版本
|
||||
SetLastUpdated(string) // 设置最后更新时间
|
||||
GetDefaultConfig() any // 获取默认配置
|
||||
}
|
||||
|
||||
// ConfigMigrationService 配置迁移服务
|
||||
type ConfigMigrationService[T Migratable] struct {
|
||||
logger *log.LoggerService
|
||||
pathManager *PathManager
|
||||
configName string
|
||||
targetVersion string
|
||||
configPath string
|
||||
}
|
||||
|
||||
// MigrationResult 迁移结果
|
||||
type MigrationResult struct {
|
||||
Migrated, ConfigUpdated bool
|
||||
FromVersion, ToVersion string
|
||||
BackupPath string
|
||||
}
|
||||
|
||||
// NewConfigMigrationService 创建配置迁移服务
|
||||
func NewConfigMigrationService[T Migratable](
|
||||
logger *log.LoggerService,
|
||||
pathManager *PathManager,
|
||||
configName, targetVersion, configPath string,
|
||||
) *ConfigMigrationService[T] {
|
||||
return &ConfigMigrationService[T]{
|
||||
logger: orDefault(logger, log.New()),
|
||||
pathManager: orDefault(pathManager, NewPathManager()),
|
||||
configName: configName,
|
||||
targetVersion: targetVersion,
|
||||
configPath: configPath,
|
||||
}
|
||||
}
|
||||
|
||||
// MigrateConfig 迁移配置文件
|
||||
func (cms *ConfigMigrationService[T]) MigrateConfig(existingConfig *koanf.Koanf) (*MigrationResult, error) {
|
||||
currentVersion := orDefault(existingConfig.String("metadata.version"), "0.0.0")
|
||||
result := &MigrationResult{
|
||||
FromVersion: currentVersion,
|
||||
ToVersion: cms.targetVersion,
|
||||
}
|
||||
|
||||
if needsMigration, err := cms.needsMigration(currentVersion); err != nil {
|
||||
return result, fmt.Errorf("version comparison failed: %w", err)
|
||||
} else if !needsMigration {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 资源检查和备份
|
||||
if err := cms.checkResourceLimits(); err != nil {
|
||||
return result, fmt.Errorf("resource limit check failed: %w", err)
|
||||
}
|
||||
|
||||
if backupPath, err := cms.createBackupOptimized(); err != nil {
|
||||
return result, fmt.Errorf("backup creation failed: %w", err)
|
||||
} else {
|
||||
result.BackupPath = backupPath
|
||||
}
|
||||
|
||||
// 自动恢复检查
|
||||
cms.tryQuickRecovery(existingConfig)
|
||||
|
||||
// 执行迁移
|
||||
if configUpdated, err := cms.performOptimizedMigration(existingConfig); err != nil {
|
||||
return result, fmt.Errorf("migration failed: %w", err)
|
||||
} else {
|
||||
result.Migrated = true
|
||||
result.ConfigUpdated = configUpdated
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// needsMigration 检查是否需要迁移
|
||||
func (cms *ConfigMigrationService[T]) needsMigration(current string) (bool, error) {
|
||||
currentVer, err := semver.NewVersion(current)
|
||||
if err != nil {
|
||||
return true, nil
|
||||
}
|
||||
targetVer, err := semver.NewVersion(cms.targetVersion)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid target version: %s", cms.targetVersion)
|
||||
}
|
||||
return currentVer.LessThan(targetVer), nil
|
||||
}
|
||||
|
||||
// checkResourceLimits 检查资源限制
|
||||
func (cms *ConfigMigrationService[T]) checkResourceLimits() error {
|
||||
if info, err := os.Stat(cms.configPath); err == nil && info.Size() > MaxConfigFileSize {
|
||||
return fmt.Errorf("config file size (%d bytes) exceeds limit (%d bytes)", info.Size(), MaxConfigFileSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createBackupOptimized 优化的备份创建(单次扫描删除旧备份)
|
||||
func (cms *ConfigMigrationService[T]) createBackupOptimized() (string, error) {
|
||||
if _, err := os.Stat(cms.configPath); os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
configDir := cms.pathManager.GetConfigDir()
|
||||
timestamp := time.Now().Format("20060102150405")
|
||||
newBackupPath := filepath.Join(configDir, fmt.Sprintf(BackupFilePattern, cms.configName, timestamp))
|
||||
|
||||
// 单次扫描:删除旧备份并创建新备份
|
||||
pattern := filepath.Join(configDir, fmt.Sprintf("%s.backup.*.json", cms.configName))
|
||||
if matches, err := filepath.Glob(pattern); err == nil {
|
||||
for _, oldBackup := range matches {
|
||||
if oldBackup != newBackupPath {
|
||||
os.Remove(oldBackup) // 忽略删除错误,继续处理
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newBackupPath, copyFile(cms.configPath, newBackupPath)
|
||||
}
|
||||
|
||||
// tryQuickRecovery 快速恢复检查(避免完整的备份恢复)
|
||||
func (cms *ConfigMigrationService[T]) tryQuickRecovery(existingConfig *koanf.Koanf) {
|
||||
var testConfig T
|
||||
if existingConfig.Unmarshal("", &testConfig) != nil {
|
||||
cms.logger.Info("Config appears corrupted, attempting quick recovery")
|
||||
if backupPath := cms.findLatestBackupQuick(); backupPath != "" {
|
||||
if data, err := os.ReadFile(backupPath); err == nil {
|
||||
existingConfig.Delete("")
|
||||
existingConfig.Load(&BytesProvider{data}, jsonparser.Parser())
|
||||
cms.logger.Info("Quick recovery successful")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// findLatestBackupQuick 快速查找最新备份(优化排序)
|
||||
func (cms *ConfigMigrationService[T]) findLatestBackupQuick() string {
|
||||
pattern := filepath.Join(cms.pathManager.GetConfigDir(), fmt.Sprintf("%s.backup.*.json", cms.configName))
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil || len(matches) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Strings(matches) // 字典序排序,时间戳格式确保正确性
|
||||
return matches[len(matches)-1]
|
||||
}
|
||||
|
||||
// performOptimizedMigration 优化的迁移执行
|
||||
func (cms *ConfigMigrationService[T]) performOptimizedMigration(existingConfig *koanf.Koanf) (bool, error) {
|
||||
// 直接从koanf实例获取配置,避免额外序列化
|
||||
var currentConfig T
|
||||
if err := existingConfig.Unmarshal("", ¤tConfig); err != nil {
|
||||
return false, fmt.Errorf("unmarshal existing config failed: %w", err)
|
||||
}
|
||||
|
||||
defaultConfig, ok := currentConfig.GetDefaultConfig().(T)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("default config type mismatch")
|
||||
}
|
||||
|
||||
return cms.mergeInPlace(existingConfig, currentConfig, defaultConfig)
|
||||
}
|
||||
|
||||
// mergeInPlace 就地合并配置
|
||||
func (cms *ConfigMigrationService[T]) mergeInPlace(existingConfig *koanf.Koanf, currentConfig, defaultConfig T) (bool, error) {
|
||||
// 创建临时合并实例
|
||||
mergeKoanf := koanf.New(".")
|
||||
|
||||
// 使用快速加载链
|
||||
if err := chainLoad(mergeKoanf,
|
||||
func() error { return mergeKoanf.Load(structs.Provider(defaultConfig, "json"), nil) },
|
||||
func() error {
|
||||
return mergeKoanf.Load(structs.Provider(currentConfig, "json"), nil,
|
||||
koanf.WithMergeFunc(cms.fastMerge))
|
||||
},
|
||||
); err != nil {
|
||||
return false, fmt.Errorf("config merge failed: %w", err)
|
||||
}
|
||||
|
||||
// 更新元数据(直接操作,无需重新序列化)
|
||||
mergeKoanf.Set("metadata.version", cms.targetVersion)
|
||||
mergeKoanf.Set("metadata.lastUpdated", time.Now().Format(time.RFC3339))
|
||||
|
||||
// 一次性序列化和原子写入
|
||||
configBytes, err := mergeKoanf.Marshal(jsonparser.Parser())
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("marshal config failed: %w", err)
|
||||
}
|
||||
|
||||
if len(configBytes) > MaxConfigFileSize {
|
||||
return false, fmt.Errorf("merged config size exceeds limit")
|
||||
}
|
||||
|
||||
// 原子写入
|
||||
return true, cms.atomicWrite(existingConfig, configBytes)
|
||||
}
|
||||
|
||||
// atomicWrite 原子写入操作
|
||||
func (cms *ConfigMigrationService[T]) atomicWrite(existingConfig *koanf.Koanf, configBytes []byte) error {
|
||||
tempPath := cms.configPath + ".tmp"
|
||||
|
||||
if err := os.WriteFile(tempPath, configBytes, 0644); err != nil {
|
||||
return fmt.Errorf("write temp config failed: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tempPath, cms.configPath); err != nil {
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("atomic rename failed: %w", err)
|
||||
}
|
||||
|
||||
// 重新加载到原实例
|
||||
existingConfig.Delete("")
|
||||
return existingConfig.Load(&BytesProvider{configBytes}, jsonparser.Parser())
|
||||
}
|
||||
|
||||
// fastMerge 快速合并函数(优化版本)
|
||||
func (cms *ConfigMigrationService[T]) fastMerge(src, dest map[string]interface{}) error {
|
||||
return cms.fastMergeRecursive(src, dest, 0)
|
||||
}
|
||||
|
||||
// fastMergeRecursive 快速递归合并(单次遍历,最小化反射使用)
|
||||
func (cms *ConfigMigrationService[T]) fastMergeRecursive(src, dest map[string]interface{}, depth int) error {
|
||||
if depth > MaxRecursionDepth {
|
||||
return fmt.Errorf("recursion depth exceeded")
|
||||
}
|
||||
|
||||
for key, srcVal := range src {
|
||||
if destVal, exists := dest[key]; exists {
|
||||
// 优先检查map类型(最常见情况)
|
||||
if srcMap, srcOK := srcVal.(map[string]interface{}); srcOK {
|
||||
if destMap, destOK := destVal.(map[string]interface{}); destOK {
|
||||
if err := cms.fastMergeRecursive(srcMap, destMap, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
// 快速空值检查(避免反射)
|
||||
if srcVal == nil || srcVal == "" || srcVal == 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
dest[key] = srcVal
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BytesProvider 轻量字节提供器
|
||||
type BytesProvider struct{ data []byte }
|
||||
|
||||
func (bp *BytesProvider) ReadBytes() ([]byte, error) { return bp.data, nil }
|
||||
func (bp *BytesProvider) Read() (map[string]interface{}, error) {
|
||||
var result map[string]interface{}
|
||||
return result, json.Unmarshal(bp.data, &result)
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
func orDefault[T any](value, defaultValue T) T {
|
||||
var zero T
|
||||
if reflect.DeepEqual(value, zero) {
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, data, 0644)
|
||||
}
|
||||
|
||||
func chainLoad(k *koanf.Koanf, loaders ...func() error) error {
|
||||
for _, loader := range loaders {
|
||||
if err := loader(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 工厂函数
|
||||
func NewAppConfigMigrationService(logger *log.LoggerService, pathManager *PathManager) *ConfigMigrationService[*models.AppConfig] {
|
||||
return NewConfigMigrationService[*models.AppConfig](
|
||||
logger, pathManager, "settings", CurrentAppConfigVersion, pathManager.GetSettingsPath())
|
||||
}
|
||||
|
||||
func NewKeyBindingMigrationService(logger *log.LoggerService, pathManager *PathManager) *ConfigMigrationService[*models.KeyBindingConfig] {
|
||||
return NewConfigMigrationService[*models.KeyBindingConfig](
|
||||
logger, pathManager, "keybindings", CurrentKeyBindingConfigVersion, pathManager.GetKeybindsPath())
|
||||
}
|
@@ -25,6 +25,8 @@ type ConfigService struct {
|
||||
|
||||
// 配置通知服务
|
||||
notificationService *ConfigNotificationService
|
||||
// 配置迁移服务
|
||||
migrationService *ConfigMigrationService[*models.AppConfig]
|
||||
}
|
||||
|
||||
// ConfigError 配置错误
|
||||
@@ -65,16 +67,18 @@ func NewConfigService(logger *log.LoggerService, pathManager *PathManager) *Conf
|
||||
// 使用"."作为键路径分隔符
|
||||
k := koanf.New(".")
|
||||
|
||||
notificationService := NewConfigNotificationService(k, logger)
|
||||
migrationService := NewAppConfigMigrationService(logger, pathManager)
|
||||
|
||||
// 构造配置服务实例
|
||||
service := &ConfigService{
|
||||
koanf: k,
|
||||
logger: logger,
|
||||
pathManager: pathManager,
|
||||
koanf: k,
|
||||
logger: logger,
|
||||
pathManager: pathManager,
|
||||
notificationService: notificationService,
|
||||
migrationService: migrationService,
|
||||
}
|
||||
|
||||
// 初始化配置通知服务
|
||||
service.notificationService = NewConfigNotificationService(k, logger)
|
||||
|
||||
// 初始化配置
|
||||
if err := service.initConfig(); err != nil {
|
||||
panic(err)
|
||||
@@ -108,12 +112,25 @@ func (cs *ConfigService) initConfig() error {
|
||||
return cs.createDefaultConfig()
|
||||
}
|
||||
|
||||
// 配置文件存在,直接加载
|
||||
// 配置文件存在,先加载现有配置
|
||||
cs.fileProvider = file.Provider(configPath)
|
||||
if err := cs.koanf.Load(cs.fileProvider, jsonparser.Parser()); err != nil {
|
||||
return &ConfigError{Operation: "load_config_file", Err: err}
|
||||
}
|
||||
|
||||
// 检查并执行配置迁移
|
||||
if cs.migrationService != nil {
|
||||
result, err := cs.migrationService.MigrateConfig(cs.koanf)
|
||||
if err != nil {
|
||||
return &ConfigError{Operation: "migrate_config", Err: err}
|
||||
}
|
||||
|
||||
if result.Migrated && result.ConfigUpdated {
|
||||
// 迁移完成且配置已更新,重新创建文件提供器以监听新文件
|
||||
cs.fileProvider = file.Provider(configPath)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -176,7 +193,7 @@ func (cs *ConfigService) GetConfig() (*models.AppConfig, error) {
|
||||
defer cs.mu.RUnlock()
|
||||
|
||||
var config models.AppConfig
|
||||
if err := cs.koanf.Unmarshal("", &config); err != nil {
|
||||
if err := cs.koanf.UnmarshalWithConf("", &config, koanf.UnmarshalConf{Tag: "json"}); err != nil {
|
||||
return nil, &ConfigError{Operation: "unmarshal_config", Err: err}
|
||||
}
|
||||
|
||||
|
@@ -26,6 +26,9 @@ type KeyBindingService struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
initOnce sync.Once
|
||||
|
||||
// 配置迁移服务
|
||||
migrationService *ConfigMigrationService[*models.KeyBindingConfig]
|
||||
}
|
||||
|
||||
// KeyBindingError 快捷键错误
|
||||
@@ -64,12 +67,15 @@ func NewKeyBindingService(logger *log.LoggerService, pathManager *PathManager) *
|
||||
|
||||
k := koanf.New(".")
|
||||
|
||||
migrationService := NewKeyBindingMigrationService(logger, pathManager)
|
||||
|
||||
service := &KeyBindingService{
|
||||
koanf: k,
|
||||
logger: logger,
|
||||
pathManager: pathManager,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
koanf: k,
|
||||
logger: logger,
|
||||
pathManager: pathManager,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
migrationService: migrationService,
|
||||
}
|
||||
|
||||
// 异步初始化
|
||||
@@ -109,12 +115,25 @@ func (kbs *KeyBindingService) initConfig() error {
|
||||
return kbs.createDefaultConfig()
|
||||
}
|
||||
|
||||
// 配置文件存在,直接加载
|
||||
// 配置文件存在,先加载现有配置
|
||||
kbs.fileProvider = file.Provider(configPath)
|
||||
if err := kbs.koanf.Load(kbs.fileProvider, jsonparser.Parser()); err != nil {
|
||||
return &KeyBindingError{"load_config_file", "", err}
|
||||
}
|
||||
|
||||
// 检查并执行配置迁移
|
||||
if kbs.migrationService != nil {
|
||||
result, err := kbs.migrationService.MigrateConfig(kbs.koanf)
|
||||
if err != nil {
|
||||
return &KeyBindingError{"migrate_config", "", err}
|
||||
}
|
||||
|
||||
if result.Migrated && result.ConfigUpdated {
|
||||
// 迁移完成且配置已更新,重新创建文件提供器以监听新文件
|
||||
kbs.fileProvider = file.Provider(configPath)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@@ -41,6 +41,11 @@ func (pm *PathManager) GetKeybindsPath() string {
|
||||
return pm.keybindsPath
|
||||
}
|
||||
|
||||
// GetConfigDir 获取配置目录路径
|
||||
func (pm *PathManager) GetConfigDir() string {
|
||||
return pm.configDir
|
||||
}
|
||||
|
||||
// EnsureConfigDir 确保配置目录存在
|
||||
func (pm *PathManager) EnsureConfigDir() error {
|
||||
return os.MkdirAll(pm.configDir, 0755)
|
||||
|
Reference in New Issue
Block a user