Files
voidraft/frontend/src/views/settings/pages/GeneralPage.vue

719 lines
19 KiB
Vue

<script setup lang="ts">
import {useConfigStore} from '@/stores/configStore';
import {useI18n} from 'vue-i18n';
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,
MigrationProgress,
MigrationService,
MigrationStatus
} from '@/../bindings/voidraft/internal/services';
import * as runtime from '@wailsio/runtime';
const {t} = useI18n();
const configStore = useConfigStore();
// 迁移进度状态
const migrationProgress = ref<MigrationProgress>(new MigrationProgress({
status: MigrationStatus.MigrationStatusCompleted,
progress: 0
}));
// 轮询相关
let pollingTimer: number | null = null;
const isPolling = ref(false);
// 进度条显示控制
const showProgress = ref(false);
const progressError = ref('');
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 isCompleted = [MigrationStatus.MigrationStatusCompleted, MigrationStatus.MigrationStatusFailed].includes(status);
if (isCompleted) {
stopPolling();
// 设置错误信息(如果是失败状态)
progressError.value = (status === MigrationStatus.MigrationStatusFailed) ? (error || 'Migration failed') : '';
const delay = status === MigrationStatus.MigrationStatusCompleted ? 3000 : 5000;
hideProgressTimer = setTimeout(hideProgress, delay);
}
} catch (error) {
stopPolling();
// 使用常量简化错误处理
const errorMsg = 'Failed to get migration progress';
Object.assign(migrationProgress.value, {
status: MigrationStatus.MigrationStatusFailed,
progress: 0,
error: errorMsg
});
progressError.value = errorMsg;
hideProgressTimer = setTimeout(hideProgress, 5000);
}
}, 200);
};
// 停止轮询
const stopPolling = () => {
if (pollingTimer) {
clearInterval(pollingTimer);
pollingTimer = null;
}
isPolling.value = false;
};
// 隐藏进度条
const hideProgress = () => {
showProgress.value = false;
progressError.value = '';
// 重置迁移状态,避免下次显示时状态不正确
migrationProgress.value = new MigrationProgress({
status: MigrationStatus.MigrationStatusCompleted,
progress: 0
});
if (hideProgressTimer) {
clearTimeout(hideProgressTimer);
hideProgressTimer = null;
}
};
// 简化的迁移状态管理
const isMigrating = computed(() => migrationProgress.value.status === MigrationStatus.MigrationStatusMigrating);
// 进度条样式 - 使用 Map 简化条件判断
const statusClassMap = new Map([
[MigrationStatus.MigrationStatusMigrating, 'migrating'],
[MigrationStatus.MigrationStatusCompleted, 'success'],
[MigrationStatus.MigrationStatusFailed, 'error']
]);
const progressBarClass = computed(() =>
showProgress.value ? statusClassMap.get(migrationProgress.value.status) ?? '' : ''
);
const progressBarWidth = computed(() => {
if (!showProgress.value) return '0%';
return isMigrating.value ? `${migrationProgress.value.progress}%` : '100%';
});
// 重置确认状态
const resetConfirmState = ref<'idle' | 'confirming'>('idle');
let resetConfirmTimer: any = null;
// 可选键列表
const keyOptions = [
'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'
];
// 计算属性 - 启用全局热键
const enableGlobalHotkey = computed({
get: () => configStore.config.general.enableGlobalHotkey,
set: (value: boolean) => configStore.setEnableGlobalHotkey(value)
});
// 计算属性 - 窗口始终置顶
const alwaysOnTop = computed({
get: () => configStore.config.general.alwaysOnTop,
set: async (value: boolean) => {
// 先更新配置
await configStore.setAlwaysOnTop(value);
// 然后立即应用窗口置顶状态
await runtime.Window.SetAlwaysOnTop(value);
}
});
// 计算属性 - 启用系统托盘
const enableSystemTray = computed({
get: () => configStore.config.general.enableSystemTray,
set: (value: boolean) => configStore.setEnableSystemTray(value)
});
// 计算属性 - 开机启动
const startAtLogin = computed({
get: () => configStore.config.general.startAtLogin,
set: (value: boolean) => configStore.setStartAtLogin(value)
});
// 修饰键配置 - 只读计算属性
const modifierKeys = computed(() => ({
ctrl: configStore.config.general.globalHotkey.ctrl,
shift: configStore.config.general.globalHotkey.shift,
alt: configStore.config.general.globalHotkey.alt,
win: configStore.config.general.globalHotkey.win
}));
// 主键配置 - 只读计算属性
const selectedKey = computed(() => configStore.config.general.globalHotkey.key);
// 切换修饰键
const toggleModifier = (key: 'ctrl' | 'shift' | 'alt' | 'win') => {
const currentHotkey = configStore.config.general.globalHotkey;
const newHotkey = {...currentHotkey, [key]: !currentHotkey[key]};
configStore.setGlobalHotkey(newHotkey);
};
// 更新选择的键
const updateSelectedKey = (event: Event) => {
const select = event.target as HTMLSelectElement;
const newHotkey = {...configStore.config.general.globalHotkey, key: select.value};
configStore.setGlobalHotkey(newHotkey);
};
// 重置设置
const resetSettings = () => {
if (resetConfirmState.value === 'idle') {
// 第一次点击,进入确认状态
resetConfirmState.value = 'confirming';
// 3秒后自动返回idle状态
resetConfirmTimer = setTimeout(() => {
resetConfirmState.value = 'idle';
}, 3000);
} else if (resetConfirmState.value === 'confirming') {
// 第二次点击,执行重置
clearTimeout(resetConfirmTimer);
resetConfirmState.value = 'idle';
confirmReset();
}
};
// 确认重置
const confirmReset = async () => {
await configStore.resetConfig();
};
// 计算热键预览文本 - 使用现代语法简化
const hotkeyPreview = computed(() => {
if (!enableGlobalHotkey.value) return '';
const {ctrl, shift, alt, win, key} = configStore.config.general.globalHotkey;
const modifiers = [
ctrl && 'Ctrl',
shift && 'Shift',
alt && 'Alt',
win && 'Win',
key
].filter(Boolean);
return modifiers.join(' + ');
});
// 数据路径配置
const currentDataPath = computed(() => configStore.config.general.dataPath);
// 选择数据存储目录
const selectDataDirectory = async () => {
if (isMigrating.value) return;
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);
}
};
// 清理定时器
onUnmounted(() => {
stopPolling();
hideProgress();
if (resetConfirmTimer) {
clearTimeout(resetConfirmTimer);
}
});
</script>
<template>
<div class="settings-page">
<SettingSection :title="t('settings.globalHotkey')">
<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">
<span class="modifier-key">Ctrl</span>
</label>
<label class="modifier-label" :class="{ active: modifierKeys.shift }" @click="toggleModifier('shift')">
<input type="checkbox" :checked="modifierKeys.shift" class="hidden-checkbox"
:disabled="!enableGlobalHotkey">
<span class="modifier-key">Shift</span>
</label>
<label class="modifier-label" :class="{ active: modifierKeys.alt }" @click="toggleModifier('alt')">
<input type="checkbox" :checked="modifierKeys.alt" class="hidden-checkbox"
:disabled="!enableGlobalHotkey">
<span class="modifier-key">Alt</span>
</label>
<label class="modifier-label" :class="{ active: modifierKeys.win }" @click="toggleModifier('win')">
<input type="checkbox" :checked="modifierKeys.win" class="hidden-checkbox"
:disabled="!enableGlobalHotkey">
<span class="modifier-key">Win</span>
</label>
</div>
<select class="key-select" :value="selectedKey" @change="updateSelectedKey" :disabled="!enableGlobalHotkey">
<option v-for="key in keyOptions" :key="key" :value="key">{{ key }}</option>
</select>
</div>
<div class="hotkey-preview">
<span class="preview-label">{{ t('settings.hotkeyPreview') }}</span>
<span class="preview-hotkey">{{ hotkeyPreview || t('settings.none') }}</span>
</div>
</div>
</SettingSection>
<SettingSection :title="t('settings.window')">
<SettingItem :title="t('settings.alwaysOnTop')">
<ToggleSwitch v-model="alwaysOnTop"/>
</SettingItem>
<SettingItem :title="t('settings.enableSystemTray')">
<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">
<div class="setting-title">{{ t('settings.dataPath') }}</div>
</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"
/>
<!-- 简洁的进度条 -->
<div
class="progress-bar"
:class="[
{ 'active': showProgress },
progressBarClass
]"
:style="{ width: progressBarWidth }"
></div>
</div>
<!-- 错误提示 -->
<Transition name="error-fade">
<div v-if="progressError" class="progress-error">
{{ progressError }}
</div>
</Transition>
</div>
</div>
</SettingSection>
<SettingSection :title="t('settings.dangerZone')">
<SettingItem :title="t('settings.resetAllSettings')">
<button
class="reset-button"
:class="{ 'confirming': resetConfirmState === 'confirming' }"
@click="resetSettings"
>
<template v-if="resetConfirmState === 'idle'">
{{ t('settings.reset') }}
</template>
<template v-else-if="resetConfirmState === 'confirming'">
{{ t('settings.confirmReset') }}
</template>
</button>
</SettingItem>
</SettingSection>
</div>
</template>
<style scoped lang="scss">
.settings-page {
max-width: 800px;
}
.hotkey-selector {
padding: 15px 0 5px 20px;
transition: all 0.3s ease;
&.disabled {
opacity: 0.5;
pointer-events: none;
}
.hotkey-controls {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.hotkey-modifiers {
display: flex;
gap: 8px;
flex-wrap: wrap;
.modifier-label {
cursor: pointer;
&.disabled {
cursor: not-allowed;
}
.hidden-checkbox {
display: none;
}
.modifier-key {
display: inline-block;
padding: 6px 12px;
background-color: var(--settings-input-bg);
border: 1px solid var(--settings-input-border);
border-radius: 4px;
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;
border-color: #3a6db1;
}
}
}
.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;
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;
gap: 8px;
padding: 8px 12px;
background-color: var(--settings-card-bg);
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;
}
}
}
.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;
color: var(--settings-text);
}
}
}
.data-path-controls {
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;
padding: 10px 12px;
background-color: var(--settings-input-bg);
border: 1px solid var(--settings-input-border);
border-radius: 4px;
color: var(--settings-text);
font-size: 12px;
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;
left: 0;
height: 3px;
background-color: transparent;
border-radius: 0 0 4px 4px;
transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), opacity 0.3s ease;
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;
transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}
&.success {
background-color: #22c55e;
animation: none;
transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}
&.error {
background-color: #ef4444;
animation: none;
transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}
}
}
}
.progress-error {
margin-top: 6px;
font-size: 12px;
color: #ef4444;
padding: 0 2px;
line-height: 1.4;
opacity: 1;
transition: all 0.3s ease;
}
}
.reset-button {
padding: 8px 16px;
background-color: #dc3545;
border: 1px solid #dc3545;
border-radius: 4px;
color: white;
font-size: 13px;
cursor: pointer;
transition: all 0.3s ease;
min-width: 80px;
&:hover {
background-color: #c82333;
border-color: #bd2130;
}
&:active {
transform: translateY(1px);
}
&.confirming {
background-color: #ff4757;
border-color: #ff4757;
animation: pulse-button 1.5s infinite;
&:hover {
background-color: #ff3838;
border-color: #ff3838;
}
}
}
// 进度条脉冲动画
@keyframes progress-pulse {
0%, 100% {
opacity: 0.8;
transform: scaleY(1);
}
50% {
opacity: 1;
transform: scaleY(1.1);
}
}
// 按钮脉冲动画
@keyframes pulse-button {
0% {
box-shadow: 0 0 0 0 rgba(255, 71, 87, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(255, 71, 87, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(255, 71, 87, 0);
}
}
// 消息点脉冲动画
@keyframes pulse-dot {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.2);
}
}
// 错误提示动画
.error-fade-enter-active {
transition: all 0.3s ease;
}
.error-fade-leave-active {
transition: all 0.3s ease;
}
.error-fade-enter-from {
opacity: 0;
transform: translateY(-4px);
}
.error-fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>