refactor(settings): 拆分设置页面为独立Tab组件,优化捐赠列表性能

- 将设置页面拆分为7个独立Tab组件(Basic/Playback/Application/Network/System/About/Donation)
- 抽取自定义SBtn/SSelect/SInput组件替代naive-ui原生组件
- 使用provide/inject共享setData/message/dialog
- 捐赠列表:去除dicebear外部头像改用首字母头像,去除n-popover改用title属性
- 捐赠列表:IntersectionObserver自动分页加载,首字母跳过*号等符号字符
- SInput:有suffix时增大右侧padding防止数值遮挡单位
This commit is contained in:
alger
2026-03-11 22:27:52 +08:00
parent bf341fa7c8
commit b5bac30258
13 changed files with 1865 additions and 1412 deletions

View File

@@ -55,7 +55,7 @@
</div>
<!-- 捐赠者列表 -->
<div class="donors-list">
<div class="donors-list px-4">
<div class="flex items-center justify-between mb-4 px-1">
<h4 class="text-lg font-bold text-gray-900 dark:text-white flex items-center gap-2">
<i class="ri-user-heart-line text-primary"></i>
@@ -69,23 +69,23 @@
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div
v-for="(donor, index) in donors"
v-for="(donor, index) in visibleDonors"
:key="donor.id"
class="donor-card group animate-fade-in-up"
:style="{ animationDelay: `${index * 10}ms` }"
class="donor-card group"
:class="index < FIRST_BATCH ? 'animate-fade-in-up' : ''"
:style="index < FIRST_BATCH ? { animationDelay: `${index * 10}ms` } : undefined"
>
<div
class="h-full bg-white dark:bg-neutral-800/50 border border-gray-100 dark:border-gray-800 rounded-xl p-3 flex gap-3 hover:border-primary/30 hover:shadow-md hover:bg-white dark:hover:bg-neutral-800 transition-all duration-300"
>
<!-- 头像 -->
<div class="relative flex-shrink-0">
<n-avatar
:src="donor.avatar"
:fallback-src="defaultAvatar"
round
:size="40"
class="border border-gray-100 dark:border-gray-700"
/>
<div
class="w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold border border-gray-100 dark:border-gray-700"
:class="avatarColorClass(donor.name)"
>
{{ avatarInitial(donor.name) }}
</div>
<div
v-if="index < 3"
class="absolute -top-1 -right-1 w-4 h-4 rounded-full flex items-center justify-center text-[10px] text-white border border-white dark:border-gray-800"
@@ -110,16 +110,13 @@
<!-- 留言或日期 -->
<div class="mt-1">
<n-popover v-if="donor.message" trigger="hover" placement="top">
<template #trigger>
<div
class="text-xs text-gray-500 dark:text-gray-400 truncate cursor-help border-b border-dashed border-gray-300 dark:border-gray-600 inline-block max-w-full"
>
"{{ donor.message }}"
</div>
</template>
<div class="max-w-[200px] text-xs">{{ donor.message }}</div>
</n-popover>
<div
v-if="donor.message"
class="text-xs text-gray-500 dark:text-gray-400 truncate border-b border-dashed border-gray-300 dark:border-gray-600 inline-block max-w-full"
:title="donor.message"
>
"{{ donor.message }}"
</div>
<div v-else class="text-xs text-gray-400 dark:text-gray-600">
{{ donor.date }}
</div>
@@ -128,12 +125,15 @@
</div>
</div>
</div>
<!-- 自动加载哨兵 -->
<div v-if="hasMore" ref="sentinelRef" class="h-1"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { onActivated, onMounted, ref } from 'vue';
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Donor } from '@/api/donation';
@@ -143,21 +143,81 @@ import wechat from '@/assets/wechat.png';
const { t } = useI18n();
const defaultAvatar = 'https://avatars.githubusercontent.com/u/0?v=4';
const donors = ref<Donor[]>([]);
const PAGE_SIZE = 40;
const FIRST_BATCH = 16;
const AVATAR_COLORS = [
'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400',
'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400',
'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400',
'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400',
'bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400',
'bg-pink-100 text-pink-600 dark:bg-pink-900/30 dark:text-pink-400',
'bg-cyan-100 text-cyan-600 dark:bg-cyan-900/30 dark:text-cyan-400',
'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400'
];
const allDonors = ref<Donor[]>([]);
const visibleCount = ref(PAGE_SIZE);
const isLoading = ref(false);
const sentinelRef = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
const visibleDonors = computed(() => allDonors.value.slice(0, visibleCount.value));
const hasMore = computed(() => visibleCount.value < allDonors.value.length);
const isTextChar = (ch: string) => /[\p{L}\p{N}]/u.test(ch);
const avatarInitial = (name: string) => {
if (!name) return '?';
for (const ch of name) {
if (isTextChar(ch)) {
return ch.toUpperCase();
}
}
return '?';
};
const avatarColorClass = (name: string) => {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
};
const loadMore = () => {
visibleCount.value = Math.min(visibleCount.value + PAGE_SIZE, allDonors.value.length);
};
const setupObserver = () => {
if (observer) observer.disconnect();
if (!sentinelRef.value) return;
observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasMore.value) {
loadMore();
}
},
{ rootMargin: '200px' }
);
observer.observe(sentinelRef.value);
};
watch(sentinelRef, (el) => {
if (el) setupObserver();
});
onBeforeUnmount(() => {
observer?.disconnect();
});
const fetchDonors = async () => {
isLoading.value = true;
try {
const data = await getDonationList();
// Sort by amount desc
donors.value = data
.sort((a, b) => Number(b.amount) - Number(a.amount))
.map((donor) => ({
...donor,
avatar: `https://api.dicebear.com/7.x/micah/svg?seed=${donor.name}`
}));
allDonors.value = data.sort((a, b) => Number(b.amount) - Number(a.amount));
visibleCount.value = PAGE_SIZE;
} catch (error) {
console.error('Failed to fetch donors:', error);
} finally {
@@ -173,7 +233,7 @@ onMounted(() => fetchDonors());
onActivated(() => fetchDonors());
</script>
<style lang="scss" scoped>
<style scoped>
.animate-fade-in-up {
animation: fadeInUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) backwards;
}

View File

@@ -0,0 +1,45 @@
<template>
<button
:disabled="disabled || loading"
class="inline-flex items-center justify-center gap-1.5 rounded-[10px] px-3.5 py-1.5 text-sm font-medium transition-all duration-200 select-none disabled:opacity-50 disabled:cursor-not-allowed active:scale-95"
:class="variantClass"
@click="$emit('click', $event)"
>
<i v-if="loading" class="ri-loader-4-line animate-spin text-sm" />
<slot />
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue';
defineOptions({ name: 'SBtn' });
const props = withDefaults(
defineProps<{
variant?: 'default' | 'primary' | 'danger' | 'ghost';
disabled?: boolean;
loading?: boolean;
}>(),
{
variant: 'default',
disabled: false,
loading: false
}
);
defineEmits<{ click: [event: MouseEvent] }>();
const variantClass = computed(() => {
switch (props.variant) {
case 'primary':
return 'bg-primary text-white hover:bg-primary/85 shadow-sm shadow-primary/20';
case 'danger':
return 'bg-red-50 text-red-600 hover:bg-red-100 dark:bg-red-950/40 dark:text-red-400 dark:hover:bg-red-950/60';
case 'ghost':
return 'bg-transparent text-neutral-500 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:bg-neutral-800';
default:
return 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700';
}
});
</script>

View File

@@ -0,0 +1,145 @@
<template>
<div class="inline-flex items-center gap-1" :class="widthClass">
<button
v-if="type === 'number' && showButtons"
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-neutral-100 text-neutral-500 transition-colors hover:bg-neutral-200 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
:disabled="disabled || atMin"
@click="decrement"
>
<i class="ri-subtract-line text-sm" />
</button>
<div class="relative flex-1 min-w-0">
<input
ref="inputRef"
:type="type === 'number' ? 'text' : type"
:value="displayValue"
:placeholder="placeholder"
:disabled="disabled"
inputmode="decimal"
class="w-full rounded-[10px] border border-neutral-200 bg-neutral-50 py-1.5 pl-3 text-sm text-neutral-900 transition-all duration-200 outline-none placeholder:text-neutral-400 focus:border-primary focus:bg-white focus:ring-2 focus:ring-primary/20 disabled:opacity-50 disabled:cursor-not-allowed dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-500 dark:focus:bg-neutral-900"
:class="[type === 'number' && !suffix ? 'text-center pr-3' : '', suffix ? 'pr-10' : 'pr-3']"
@input="handleInput"
@blur="handleBlur"
@keydown="handleKeydown"
/>
<span
v-if="suffix"
class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs text-neutral-400"
>
{{ suffix }}
</span>
</div>
<button
v-if="type === 'number' && showButtons"
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-neutral-100 text-neutral-500 transition-colors hover:bg-neutral-200 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
:disabled="disabled || atMax"
@click="increment"
>
<i class="ri-add-line text-sm" />
</button>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
defineOptions({ name: 'SInput' });
const props = withDefaults(
defineProps<{
modelValue?: string | number;
type?: 'text' | 'number';
placeholder?: string;
disabled?: boolean;
min?: number;
max?: number;
step?: number;
suffix?: string;
showButtons?: boolean;
width?: string;
}>(),
{
modelValue: '',
type: 'text',
placeholder: '',
disabled: false,
min: -Infinity,
max: Infinity,
step: 1,
suffix: '',
showButtons: true,
width: ''
}
);
const emit = defineEmits<{
'update:modelValue': [value: string | number];
blur: [];
}>();
const inputRef = ref<HTMLInputElement | null>(null);
const numericValue = computed(() => {
const n = Number(props.modelValue);
return Number.isFinite(n) ? n : 0;
});
const displayValue = computed(() => {
if (props.type === 'number') {
return String(props.modelValue ?? '');
}
return String(props.modelValue ?? '');
});
const atMin = computed(() => numericValue.value <= props.min);
const atMax = computed(() => numericValue.value >= props.max);
const widthClass = computed(() => props.width);
const clamp = (n: number) => Math.min(props.max, Math.max(props.min, n));
const handleInput = (e: Event) => {
const val = (e.target as HTMLInputElement).value;
if (props.type === 'text') {
emit('update:modelValue', val);
return;
}
if (val === '' || val === '-') {
emit('update:modelValue', val as unknown as number);
return;
}
const n = Number(val);
if (!Number.isNaN(n)) {
emit('update:modelValue', n);
}
};
const handleBlur = () => {
if (props.type === 'number') {
const clamped = clamp(numericValue.value);
emit('update:modelValue', clamped);
}
emit('blur');
};
const increment = () => {
emit('update:modelValue', clamp(numericValue.value + props.step));
};
const decrement = () => {
emit('update:modelValue', clamp(numericValue.value - props.step));
};
const handleKeydown = (e: KeyboardEvent) => {
if (props.type !== 'number') return;
if (e.key === 'ArrowUp') {
e.preventDefault();
increment();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
decrement();
}
};
</script>

View File

@@ -0,0 +1,129 @@
<template>
<div class="relative inline-block" :class="widthClass" ref="wrapperRef">
<button
:disabled="disabled"
class="flex w-full items-center justify-between gap-2 rounded-[10px] border px-3 py-1.5 text-sm transition-all duration-200 select-none disabled:opacity-50 disabled:cursor-not-allowed"
:class="
isOpen
? 'border-primary bg-white dark:bg-neutral-900 ring-2 ring-primary/20'
: 'border-neutral-200 bg-neutral-50 hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600'
"
@click="toggle"
>
<span
class="truncate"
:class="selectedLabel ? 'text-neutral-900 dark:text-neutral-100' : 'text-neutral-400'"
>
{{ selectedLabel || placeholder }}
</span>
<i
class="ri-arrow-down-s-line text-base text-neutral-400 transition-transform duration-200 flex-shrink-0"
:class="{ 'rotate-180': isOpen }"
/>
</button>
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0 -translate-y-1 scale-[0.98]"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 -translate-y-1 scale-[0.98]"
>
<div
v-if="isOpen"
class="absolute z-50 mt-1.5 w-full min-w-[160px] overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-900"
:class="dropdownPosition === 'top' ? 'bottom-full mb-1.5 mt-0' : ''"
>
<div class="max-h-[240px] overflow-y-auto py-1">
<div
v-for="opt in options"
:key="String(opt.value)"
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm transition-colors"
:class="
opt.value === modelValue
? 'bg-primary/10 text-primary font-medium'
: 'text-neutral-700 hover:bg-neutral-50 dark:text-neutral-300 dark:hover:bg-neutral-800'
"
@click="select(opt.value)"
>
<span class="truncate flex-1">{{ opt.label }}</span>
<i
v-if="opt.value === modelValue"
class="ri-check-line text-primary text-base flex-shrink-0"
/>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
defineOptions({ name: 'SSelect' });
type OptionValue = string | number | boolean;
const props = withDefaults(
defineProps<{
modelValue?: OptionValue;
options?: { label: string; value: OptionValue }[];
placeholder?: string;
disabled?: boolean;
width?: string;
}>(),
{
modelValue: undefined,
options: () => [],
placeholder: '',
disabled: false,
width: 'w-40'
}
);
const emit = defineEmits<{
'update:modelValue': [value: OptionValue];
}>();
const isOpen = ref(false);
const wrapperRef = ref<HTMLElement | null>(null);
const dropdownPosition = ref<'bottom' | 'top'>('bottom');
const widthClass = computed(() => props.width);
const selectedLabel = computed(() => {
const opt = props.options.find((o) => o.value === props.modelValue);
return opt?.label ?? '';
});
const toggle = () => {
if (props.disabled) return;
isOpen.value = !isOpen.value;
if (isOpen.value) {
checkDropdownPosition();
}
};
const select = (value: OptionValue) => {
emit('update:modelValue', value);
isOpen.value = false;
};
const checkDropdownPosition = () => {
if (!wrapperRef.value) return;
const rect = wrapperRef.value.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
dropdownPosition.value = spaceBelow < 260 ? 'top' : 'bottom';
};
const onClickOutside = (e: MouseEvent) => {
if (wrapperRef.value && !wrapperRef.value.contains(e.target as Node)) {
isOpen.value = false;
}
};
onMounted(() => document.addEventListener('click', onClickOutside));
onBeforeUnmount(() => document.removeEventListener('click', onClickOutside));
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
import type { DialogApi, MessageApi } from 'naive-ui';
import type { InjectionKey, WritableComputedRef } from 'vue';
export const SETTINGS_DATA_KEY = Symbol('settings-data') as InjectionKey<WritableComputedRef<any>>;
export const SETTINGS_MESSAGE_KEY = Symbol('settings-message') as InjectionKey<MessageApi>;
export const SETTINGS_DIALOG_KEY = Symbol('settings-dialog') as InjectionKey<DialogApi>;

View File

@@ -0,0 +1,160 @@
<template>
<setting-section :title="t('settings.sections.about')">
<setting-item :title="t('settings.about.version')">
<template #description>
<div class="flex flex-wrap items-center gap-2">
<span>{{ updateInfo.currentVersion }}</span>
<n-tag v-if="updateInfo.hasUpdate" type="success">
{{ t('settings.about.hasUpdate') }} {{ updateInfo.latestVersion }}
</n-tag>
</div>
<div v-if="hasManualUpdateFallback" class="mt-2 text-xs text-amber-600">
<i class="ri-information-line mr-1"></i>
{{ appUpdateState.errorMessage || t('settings.about.messages.checkError') }}
</div>
</template>
<template #action>
<div class="flex items-center gap-2 flex-wrap">
<s-btn :loading="checking" @click="checkForUpdates(true)">
{{ checking ? t('settings.about.checking') : t('settings.about.checkUpdate') }}
</s-btn>
<s-btn v-if="updateInfo.hasUpdate" variant="primary" @click="openReleasePage">
{{ t('settings.about.gotoUpdate') }}
</s-btn>
<s-btn v-if="hasManualUpdateFallback" variant="ghost" @click="openManualUpdatePage">
{{ t('settings.about.manualUpdate') }}
</s-btn>
</div>
</template>
</setting-item>
<setting-item
:title="t('settings.about.author')"
:description="t('settings.about.authorDesc')"
clickable
@click="openAuthor"
>
<s-btn @click.stop="openAuthor">
<i class="ri-github-line mr-1"></i>{{ t('settings.about.gotoGithub') }}
</s-btn>
</setting-item>
</setting-section>
</template>
<script setup lang="ts">
import { computed, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useSettingsStore } from '@/store/modules/settings';
import { isElectron } from '@/utils';
import { checkUpdate, UpdateResult } from '@/utils/update';
import config from '../../../../../package.json';
import { APP_UPDATE_STATUS, hasAvailableAppUpdate } from '../../../../shared/appUpdate';
import { SETTINGS_DATA_KEY, SETTINGS_MESSAGE_KEY } from '../keys';
import SBtn from '../SBtn.vue';
import SettingItem from '../SettingItem.vue';
import SettingSection from '../SettingSection.vue';
const { t } = useI18n();
const settingsStore = useSettingsStore();
const setData = inject(SETTINGS_DATA_KEY)!;
const message = inject(SETTINGS_MESSAGE_KEY)!;
const checking = ref(false);
const webUpdateInfo = ref<UpdateResult>({
hasUpdate: false,
latestVersion: '',
currentVersion: config.version,
releaseInfo: null
});
const appUpdateState = computed(() => settingsStore.appUpdateState);
const hasAppUpdate = computed(() => hasAvailableAppUpdate(appUpdateState.value));
const hasManualUpdateFallback = computed(
() => isElectron && appUpdateState.value.status === APP_UPDATE_STATUS.error
);
const updateInfo = computed<UpdateResult>(() => {
if (!isElectron) {
return webUpdateInfo.value;
}
return {
hasUpdate: hasAppUpdate.value,
latestVersion: appUpdateState.value.availableVersion ?? '',
currentVersion: appUpdateState.value.currentVersion || config.version,
releaseInfo: appUpdateState.value.availableVersion
? {
tag_name: appUpdateState.value.availableVersion,
body: appUpdateState.value.releaseNotes,
html_url: appUpdateState.value.releasePageUrl,
assets: []
}
: null
};
});
const checkForUpdates = async (isClick = false) => {
checking.value = true;
try {
if (isElectron) {
const result = await window.api.checkAppUpdate(isClick);
settingsStore.setAppUpdateState(result);
if (hasAvailableAppUpdate(result)) {
if (isClick) {
settingsStore.setShowUpdateModal(true);
}
} else if (result.status === APP_UPDATE_STATUS.notAvailable && isClick) {
message.success(t('settings.about.latest'));
} else if (result.status === APP_UPDATE_STATUS.error && isClick) {
message.error(result.errorMessage || t('settings.about.messages.checkError'));
}
return;
}
const result = await checkUpdate(config.version);
if (result) {
webUpdateInfo.value = result;
if (!result.hasUpdate && isClick) {
message.success(t('settings.about.latest'));
}
} else if (isClick) {
message.success(t('settings.about.latest'));
}
} catch (error) {
console.error('检查更新失败:', error);
if (isClick) {
message.error(t('settings.about.messages.checkError'));
}
} finally {
checking.value = false;
}
};
const openReleasePage = () => {
if (isElectron) {
settingsStore.setShowUpdateModal(true);
return;
}
window.open(updateInfo.value.releaseInfo?.html_url || setData.value.authorUrl);
};
const openManualUpdatePage = async () => {
if (isElectron) {
await window.api.openAppUpdatePage();
return;
}
window.open(updateInfo.value.releaseInfo?.html_url || setData.value.authorUrl);
};
const openAuthor = () => {
window.open(setData.value.authorUrl);
};
defineExpose({ checkForUpdates });
</script>

View File

@@ -0,0 +1,114 @@
<template>
<setting-section v-if="isElectron" :title="t('settings.sections.application')">
<setting-item
:title="t('settings.application.closeAction')"
:description="t('settings.application.closeActionDesc')"
>
<s-select
v-model="setData.closeAction"
:options="closeActionOptions"
width="w-40 max-md:w-full"
/>
</setting-item>
<setting-item
:title="t('settings.application.shortcut')"
:description="t('settings.application.shortcutDesc')"
>
<s-btn @click="showShortcutModal = true">{{ t('common.configure') }}</s-btn>
</setting-item>
<setting-item v-if="isElectron" :title="t('settings.application.download')">
<template #description>
<n-switch v-model:value="setData.alwaysShowDownloadButton" class="mr-2">
<template #checked>{{ t('common.show') }}</template>
<template #unchecked>{{ t('common.hide') }}</template>
</n-switch>
{{ t('settings.application.downloadDesc') }}
</template>
<s-btn @click="router.push('/downloads')">
{{ t('settings.application.download') }}
</s-btn>
</setting-item>
<setting-item :title="t('settings.application.unlimitedDownload')">
<template #description>
<n-switch v-model:value="setData.unlimitedDownload" class="mr-2">
<template #checked>{{ t('common.on') }}</template>
<template #unchecked>{{ t('common.off') }}</template>
</n-switch>
{{ t('settings.application.unlimitedDownloadDesc') }}
</template>
</setting-item>
<setting-item :title="t('settings.application.downloadPath')">
<template #description>
<span class="break-all">{{
setData.downloadPath || t('settings.application.downloadPathDesc')
}}</span>
</template>
<template #action>
<div class="flex items-center gap-2">
<s-btn @click="openDownloadPath">{{ t('common.open') }}</s-btn>
<s-btn @click="selectDownloadPath">{{ t('common.modify') }}</s-btn>
</div>
</template>
</setting-item>
<setting-item
:title="t('settings.application.remoteControl')"
:description="t('settings.application.remoteControlDesc')"
>
<s-btn @click="showRemoteControlModal = true">{{ t('common.configure') }}</s-btn>
</setting-item>
<shortcut-settings v-model:show="showShortcutModal" @change="handleShortcutsChange" />
<remote-control-setting v-model:visible="showRemoteControlModal" />
</setting-section>
</template>
<script setup lang="ts">
import { computed, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import RemoteControlSetting from '@/components/settings/ServerSetting.vue';
import ShortcutSettings from '@/components/settings/ShortcutSettings.vue';
import { isElectron } from '@/utils';
import { openDirectory, selectDirectory } from '@/utils/fileOperation';
import { SETTINGS_DATA_KEY, SETTINGS_MESSAGE_KEY } from '../keys';
import SBtn from '../SBtn.vue';
import SettingItem from '../SettingItem.vue';
import SettingSection from '../SettingSection.vue';
import SSelect from '../SSelect.vue';
const { t } = useI18n();
const router = useRouter();
const setData = inject(SETTINGS_DATA_KEY)!;
const message = inject(SETTINGS_MESSAGE_KEY)!;
const showShortcutModal = ref(false);
const showRemoteControlModal = ref(false);
const closeActionOptions = computed(() => [
{ label: t('settings.application.closeOptions.ask'), value: 'ask' },
{ label: t('settings.application.closeOptions.minimize'), value: 'minimize' },
{ label: t('settings.application.closeOptions.close'), value: 'close' }
]);
const selectDownloadPath = async () => {
const path = await selectDirectory(message);
if (path) {
setData.value = { ...setData.value, downloadPath: path };
}
};
const openDownloadPath = () => {
openDirectory(setData.value.downloadPath, message);
};
const handleShortcutsChange = (shortcuts: any) => {
console.log('快捷键已更新:', shortcuts);
};
</script>

View File

@@ -0,0 +1,353 @@
<template>
<setting-section :title="t('settings.sections.basic')">
<setting-item
:title="t('settings.basic.themeMode')"
:description="t('settings.basic.themeModeDesc')"
>
<template #action>
<div class="flex items-center gap-3 max-md:flex-wrap">
<div class="flex items-center gap-2">
<n-switch v-model:value="setData.autoTheme" @update:value="handleAutoThemeChange">
<template #checked><i class="ri-smartphone-line"></i></template>
<template #unchecked><i class="ri-settings-line"></i></template>
</n-switch>
<span class="text-sm text-gray-500 max-md:hidden">
{{
setData.autoTheme ? t('settings.basic.autoTheme') : t('settings.basic.manualTheme')
}}
</span>
</div>
<n-switch
v-model:value="isDarkTheme"
:disabled="setData.autoTheme"
:class="{ 'opacity-50': setData.autoTheme }"
>
<template #checked><i class="ri-moon-line"></i></template>
<template #unchecked><i class="ri-sun-line"></i></template>
</n-switch>
</div>
</template>
</setting-item>
<setting-item
:title="t('settings.basic.language')"
:description="t('settings.basic.languageDesc')"
>
<language-switcher />
</setting-item>
<setting-item
v-if="!isElectron"
:title="t('settings.basic.tabletMode')"
:description="t('settings.basic.tabletModeDesc')"
>
<n-switch v-model:value="setData.tabletMode">
<template #checked><i class="ri-tablet-line"></i></template>
<template #unchecked><i class="ri-smartphone-line"></i></template>
</n-switch>
</setting-item>
<setting-item
:title="t('settings.translationEngine')"
:description="t('settings.translationEngine')"
>
<s-select
v-model="setData.lyricTranslationEngine"
:options="translationEngineOptions"
width="w-40 max-md:w-full"
/>
</setting-item>
<setting-item
v-if="isElectron"
:title="t('settings.basic.font')"
:description="t('settings.basic.fontDesc')"
>
<template #action>
<div class="flex gap-2 max-md:flex-col max-md:w-full">
<n-radio-group v-model:value="setData.fontScope" class="mt-2">
<n-radio key="global" value="global">{{
t('settings.basic.fontScope.global')
}}</n-radio>
<n-radio key="lyric" value="lyric">{{ t('settings.basic.fontScope.lyric') }}</n-radio>
</n-radio-group>
<n-select
v-model:value="selectedFonts"
:options="systemFonts"
filterable
multiple
placeholder="选择字体"
class="w-[300px] max-md:w-full"
:render-label="renderFontLabel"
/>
</div>
</template>
</setting-item>
<div
v-if="isElectron && selectedFonts.length > 0"
class="p-4 border-b border-gray-100 dark:border-gray-800"
>
<div class="text-base font-bold mb-4 text-gray-900 dark:text-white">
{{ t('settings.basic.fontPreview.title') }}
</div>
<div class="space-y-4" :style="{ fontFamily: setData.fontFamily }">
<div v-for="preview in fontPreviews" :key="preview.key" class="flex flex-col gap-2">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{{ t(`settings.basic.fontPreview.${preview.key}`) }}
</div>
<div
class="text-lg text-gray-900 dark:text-gray-100 p-3 rounded-xl bg-gray-50 dark:bg-black/20"
>
{{ t(`settings.basic.fontPreview.${preview.key}Text`) }}
</div>
</div>
</div>
</div>
<setting-item :title="t('settings.basic.tokenManagement')">
<template #description>
<div class="text-sm text-gray-500 mb-2">
{{ t('settings.basic.tokenStatus') }}:
{{ currentToken ? t('settings.basic.tokenSet') : t('settings.basic.tokenNotSet') }}
</div>
<div v-if="currentToken" class="text-xs text-gray-400 mb-2 font-mono break-all">
{{ currentToken.substring(0, 50) }}...
</div>
</template>
<template #action>
<div class="flex gap-2">
<s-btn @click="showTokenModal = true">
{{ currentToken ? t('settings.basic.modifyToken') : t('settings.basic.setToken') }}
</s-btn>
<s-btn v-if="currentToken" variant="danger" @click="clearToken">
{{ t('settings.basic.clearToken') }}
</s-btn>
</div>
</template>
</setting-item>
<setting-item :title="t('settings.basic.animation')">
<template #description>
<div class="flex items-center gap-2">
<n-switch v-model:value="setData.noAnimate">
<template #checked>{{ t('common.off') }}</template>
<template #unchecked>{{ t('common.on') }}</template>
</n-switch>
<span>{{ t('settings.basic.animationDesc') }}</span>
</div>
</template>
<template #action>
<div class="flex items-center gap-2">
<span v-if="!isMobile" class="text-sm text-gray-400">{{ setData.animationSpeed }}x</span>
<div class="w-40 max-md:w-auto flex justify-end">
<n-slider
v-if="!isMobile"
v-model:value="setData.animationSpeed"
:min="0.1"
:max="3"
:step="0.1"
:marks="animationSpeedMarks"
:disabled="setData.noAnimate"
/>
<s-input
v-else
v-model="setData.animationSpeed"
type="number"
:min="0.1"
:max="3"
:step="0.1"
:disabled="setData.noAnimate"
width="w-[120px]"
/>
</div>
</div>
</template>
</setting-item>
<setting-item v-if="isElectron" :title="t('settings.basic.gpuAcceleration')">
<template #description>
<div class="text-sm text-gray-500 mb-2">
{{ t('settings.basic.gpuAccelerationDesc') }}
</div>
<div v-if="gpuAccelerationChanged" class="text-xs text-amber-500">
<i class="ri-information-line mr-1"></i>
{{ t('settings.basic.gpuAccelerationRestart') }}
</div>
</template>
<n-switch
v-model:value="setData.enableGpuAcceleration"
@update:value="handleGpuAccelerationChange"
>
<template #checked><i class="ri-cpu-line"></i></template>
<template #unchecked><i class="ri-cpu-line"></i></template>
</n-switch>
</setting-item>
<cookie-settings-modal
v-model:show="showTokenModal"
:initial-value="currentToken"
@save="handleTokenSave"
/>
</setting-section>
</template>
<script setup lang="ts">
import { computed, h, inject, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { getUserDetail } from '@/api/login';
import LanguageSwitcher from '@/components/LanguageSwitcher.vue';
import CookieSettingsModal from '@/components/settings/CookieSettingsModal.vue';
import { useSettingsStore } from '@/store/modules/settings';
import { useUserStore } from '@/store/modules/user';
import { isElectron, isMobile } from '@/utils';
import { SETTINGS_DATA_KEY, SETTINGS_MESSAGE_KEY } from '../keys';
import SBtn from '../SBtn.vue';
import SettingItem from '../SettingItem.vue';
import SettingSection from '../SettingSection.vue';
import SInput from '../SInput.vue';
import SSelect from '../SSelect.vue';
const fontPreviews = [
{ key: 'chinese' },
{ key: 'english' },
{ key: 'japanese' },
{ key: 'korean' }
];
const { t } = useI18n();
const settingsStore = useSettingsStore();
const userStore = useUserStore();
const setData = inject(SETTINGS_DATA_KEY)!;
const message = inject(SETTINGS_MESSAGE_KEY)!;
const isDarkTheme = computed({
get: () => settingsStore.theme === 'dark',
set: () => settingsStore.toggleTheme()
});
const handleAutoThemeChange = (value: boolean) => {
settingsStore.setAutoTheme(value);
};
const gpuAccelerationChanged = ref(false);
const handleGpuAccelerationChange = (enabled: boolean) => {
try {
if (window.electron) {
window.electron.ipcRenderer.send('update-gpu-acceleration', enabled);
gpuAccelerationChanged.value = true;
message.info(t('settings.basic.gpuAccelerationChangeSuccess'));
}
} catch (error) {
console.error('GPU加速设置更新失败:', error);
message.error(t('settings.basic.gpuAccelerationChangeError'));
}
};
const translationEngineOptions = computed(() => [
{ label: t('settings.translationEngineOptions.none'), value: 'none' },
{ label: t('settings.translationEngineOptions.opencc'), value: 'opencc' }
]);
const animationSpeedMarks = computed(() => ({
0.1: t('settings.basic.animationSpeed.slow'),
1: t('settings.basic.animationSpeed.normal'),
3: t('settings.basic.animationSpeed.fast')
}));
const systemFonts = computed(() => settingsStore.systemFonts);
const selectedFonts = ref<string[]>([]);
const renderFontLabel = (option: { label: string; value: string }) => {
return h('span', { style: { fontFamily: option.value } }, option.label);
};
watch(
selectedFonts,
(newFonts) => {
setData.value = {
...setData.value,
fontFamily: newFonts.length === 0 ? 'system-ui' : newFonts.join(',')
};
},
{ deep: true }
);
watch(
() => setData.value.fontFamily,
(newFont) => {
if (newFont) {
selectedFonts.value = newFont === 'system-ui' ? [] : newFont.split(',');
}
},
{ immediate: true }
);
const showTokenModal = ref(false);
const currentToken = ref(localStorage.getItem('token') || '');
const handleTokenSave = async (token: string) => {
try {
const originalToken = localStorage.getItem('token');
localStorage.setItem('token', token);
const user = await getUserDetail();
if (user.data && user.data.profile) {
userStore.setUser(user.data.profile);
currentToken.value = token;
message.success(t('settings.cookie.message.saveSuccess'));
setTimeout(() => window.location.reload(), 1000);
} else {
if (originalToken) localStorage.setItem('token', originalToken);
else localStorage.removeItem('token');
message.error(t('settings.cookie.message.saveError'));
}
} catch {
const originalToken = localStorage.getItem('token');
if (originalToken) localStorage.setItem('token', originalToken);
else localStorage.removeItem('token');
message.error(t('settings.cookie.message.saveError'));
}
};
const clearToken = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
currentToken.value = '';
userStore.user = null;
message.success(t('settings.basic.clearToken') + '成功');
setTimeout(() => window.location.reload(), 1000);
};
watch(
() => localStorage.getItem('token'),
(newToken) => {
currentToken.value = newToken || '';
},
{ immediate: true }
);
onMounted(() => {
if (window.electron) {
window.electron.ipcRenderer.on('gpu-acceleration-updated', (_, enabled: boolean) => {
console.log('GPU加速设置已更新:', enabled);
gpuAccelerationChanged.value = true;
});
window.electron.ipcRenderer.on('gpu-acceleration-update-error', (_, errorMessage: string) => {
console.error('GPU加速设置更新错误:', errorMessage);
gpuAccelerationChanged.value = false;
});
}
});
onUnmounted(() => {
if (window.electron) {
window.electron.ipcRenderer.removeAllListeners?.('gpu-acceleration-updated');
window.electron.ipcRenderer.removeAllListeners?.('gpu-acceleration-update-error');
}
});
</script>

View File

@@ -0,0 +1,15 @@
<template>
<setting-section :title="t('settings.sections.donation')">
<donation-list />
</setting-section>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import DonationList from '@/components/common/DonationList.vue';
import SettingSection from '../SettingSection.vue';
const { t } = useI18n();
</script>

View File

@@ -0,0 +1,124 @@
<template>
<setting-section v-if="isElectron" :title="t('settings.sections.network')">
<setting-item
:title="t('settings.network.apiPort')"
:description="t('settings.network.apiPortDesc')"
>
<s-input
v-model="setData.musicApiPort"
type="number"
:min="1024"
:max="65535"
:step="1"
width="w-[140px] max-md:w-32"
/>
</setting-item>
<setting-item
:title="t('settings.network.proxy')"
:description="t('settings.network.proxyDesc')"
>
<template #action>
<div class="flex items-center gap-2">
<n-switch v-model:value="setData.proxyConfig.enable">
<template #checked>{{ t('common.on') }}</template>
<template #unchecked>{{ t('common.off') }}</template>
</n-switch>
<s-btn @click="showProxyModal = true">{{ t('common.configure') }}</s-btn>
</div>
</template>
</setting-item>
<setting-item
:title="t('settings.network.realIP')"
:description="t('settings.network.realIPDesc')"
>
<template #action>
<div class="flex items-center gap-2 max-md:flex-wrap">
<n-switch v-model:value="setData.enableRealIP">
<template #checked>{{ t('common.on') }}</template>
<template #unchecked>{{ t('common.off') }}</template>
</n-switch>
<s-input
v-if="setData.enableRealIP"
v-model="setData.realIP"
placeholder="realIP"
width="w-[200px] max-md:w-full"
@blur="validateAndSaveRealIP"
/>
</div>
</template>
</setting-item>
<proxy-settings
v-model:show="showProxyModal"
:config="proxyForm"
@confirm="handleProxyConfirm"
/>
</setting-section>
</template>
<script setup lang="ts">
import { inject, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import ProxySettings from '@/components/settings/ProxySettings.vue';
import { isElectron } from '@/utils';
import { SETTINGS_DATA_KEY, SETTINGS_MESSAGE_KEY } from '../keys';
import SBtn from '../SBtn.vue';
import SettingItem from '../SettingItem.vue';
import SettingSection from '../SettingSection.vue';
import SInput from '../SInput.vue';
const { t } = useI18n();
const setData = inject(SETTINGS_DATA_KEY)!;
const message = inject(SETTINGS_MESSAGE_KEY)!;
const showProxyModal = ref(false);
const proxyForm = ref({ protocol: 'http', host: '127.0.0.1', port: 7890 });
watch(
() => setData.value.proxyConfig,
(newVal) => {
if (newVal) {
proxyForm.value = {
protocol: newVal.protocol || 'http',
host: newVal.host || '127.0.0.1',
port: newVal.port || 7890
};
}
},
{ immediate: true, deep: true }
);
const handleProxyConfirm = async (proxyConfig: any) => {
setData.value = {
...setData.value,
proxyConfig: { enable: setData.value.proxyConfig?.enable || false, ...proxyConfig }
};
message.success(t('settings.network.messages.proxySuccess'));
};
const validateAndSaveRealIP = () => {
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
if (!setData.value.realIP || ipRegex.test(setData.value.realIP)) {
setData.value = { ...setData.value, realIP: setData.value.realIP, enableRealIP: true };
if (setData.value.realIP) {
message.success(t('settings.network.messages.realIPSuccess'));
}
} else {
message.error(t('settings.network.messages.realIPError'));
setData.value = { ...setData.value, realIP: '' };
}
};
watch(
() => setData.value.enableRealIP,
(newVal) => {
if (!newVal) {
setData.value = { ...setData.value, realIP: '', enableRealIP: false };
}
}
);
</script>

View File

@@ -0,0 +1,148 @@
<template>
<div>
<setting-section :title="t('settings.sections.playback')">
<setting-item
:title="t('settings.playback.quality')"
:description="t('settings.playback.qualityDesc')"
>
<s-select
v-model="setData.musicQuality"
:options="qualityOptions"
width="w-40 max-md:w-full"
/>
</setting-item>
<setting-item v-if="isElectron" :title="t('settings.playback.musicSources')">
<template #description>
<div class="flex items-center gap-2">
<n-switch v-model:value="setData.enableMusicUnblock">
<template #checked>{{ t('common.on') }}</template>
<template #unchecked>{{ t('common.off') }}</template>
</n-switch>
<span>{{ t('settings.playback.musicUnblockEnableDesc') }}</span>
</div>
<div v-if="setData.enableMusicUnblock" class="mt-2 text-sm">
<span class="text-gray-500">{{ t('settings.playback.selectedMusicSources') }}</span>
<span v-if="musicSources.length > 0" class="text-gray-400">{{
musicSources.join(', ')
}}</span>
<span v-else class="text-red-500 text-xs">{{
t('settings.playback.noMusicSources')
}}</span>
</div>
</template>
<s-btn :disabled="!setData.enableMusicUnblock" @click="showMusicSourcesModal = true">
{{ t('settings.playback.configureMusicSources') }}
</s-btn>
</setting-item>
<setting-item
v-if="platform === 'darwin'"
:title="t('settings.playback.showStatusBar')"
:description="t('settings.playback.showStatusBarContent')"
>
<n-switch v-model:value="setData.showTopAction">
<template #checked>{{ t('common.on') }}</template>
<template #unchecked>{{ t('common.off') }}</template>
</n-switch>
</setting-item>
<setting-item
:title="t('settings.playback.autoPlay')"
:description="t('settings.playback.autoPlayDesc')"
>
<n-switch v-model:value="setData.autoPlay">
<template #checked>{{ t('common.on') }}</template>
<template #unchecked>{{ t('common.off') }}</template>
</n-switch>
</setting-item>
<setting-item
v-if="isElectron"
:title="t('settings.playback.audioDevice')"
:description="t('settings.playback.audioDeviceDesc')"
>
<audio-device-settings />
</setting-item>
</setting-section>
<div
class="mt-6 p-4 rounded-2xl bg-white dark:bg-neutral-900 border border-gray-100 dark:border-gray-800"
>
<div class="text-sm font-medium text-gray-500 mb-3">支持正版</div>
<div class="text-base text-gray-900 dark:text-white mb-4">
大家还是需要支持正版本软件只做开源探讨各大音乐会员购买链接
</div>
<div class="flex gap-3 flex-wrap">
<a
v-for="link in memberLinks"
:key="link.url"
class="px-4 py-2 rounded-xl bg-gray-50 dark:bg-black/20 text-primary hover:text-green-500 transition-colors"
:href="link.url"
target="_blank"
>
{{ link.name }} <i class="ri-external-link-line ml-1"></i>
</a>
</div>
</div>
<music-source-settings
v-if="isElectron"
v-model:show="showMusicSourcesModal"
v-model:sources="musicSources"
/>
</div>
</template>
<script setup lang="ts">
import { computed, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import AudioDeviceSettings from '@/components/settings/AudioDeviceSettings.vue';
import MusicSourceSettings from '@/components/settings/MusicSourceSettings.vue';
import { type Platform } from '@/types/music';
import { isElectron } from '@/utils';
import { SETTINGS_DATA_KEY } from '../keys';
import SBtn from '../SBtn.vue';
import SettingItem from '../SettingItem.vue';
import SettingSection from '../SettingSection.vue';
import SSelect from '../SSelect.vue';
const ALL_PLATFORMS: Platform[] = ['migu', 'kugou', 'kuwo', 'pyncmd'];
const memberLinks = [
{ name: '网易云音乐会员', url: 'https://music.163.com/store/vip' },
{ name: 'QQ音乐会员', url: 'https://y.qq.com/portal/vipportal/' },
{ name: '酷狗音乐会员', url: 'https://vip.kugou.com/' }
];
const { t } = useI18n();
const setData = inject(SETTINGS_DATA_KEY)!;
const platform = window.electron ? window.electron.ipcRenderer.sendSync('get-platform') : 'web';
const showMusicSourcesModal = ref(false);
const qualityOptions = computed(() => [
{ label: t('settings.playback.qualityOptions.standard'), value: 'standard' },
{ label: t('settings.playback.qualityOptions.higher'), value: 'higher' },
{ label: t('settings.playback.qualityOptions.exhigh'), value: 'exhigh' },
{ label: t('settings.playback.qualityOptions.lossless'), value: 'lossless' },
{ label: t('settings.playback.qualityOptions.hires'), value: 'hires' },
{ label: t('settings.playback.qualityOptions.jyeffect'), value: 'jyeffect' },
{ label: t('settings.playback.qualityOptions.sky'), value: 'sky' },
{ label: t('settings.playback.qualityOptions.dolby'), value: 'dolby' },
{ label: t('settings.playback.qualityOptions.jymaster'), value: 'jymaster' }
]);
const musicSources = computed({
get: () => {
if (!setData.value.enabledMusicSources) return ALL_PLATFORMS;
return setData.value.enabledMusicSources as Platform[];
},
set: (newValue: Platform[]) => {
const valuesToSet = newValue.length > 0 ? [...new Set(newValue)] : ALL_PLATFORMS;
setData.value = { ...setData.value, enabledMusicSources: valuesToSet };
}
});
</script>

View File

@@ -0,0 +1,499 @@
<template>
<setting-section v-if="isElectron" :title="t('settings.sections.system')">
<setting-item
:title="t('settings.system.diskCache')"
:description="t('settings.system.diskCacheDesc')"
>
<n-switch v-model:value="setData.enableDiskCache">
<template #checked>{{ t('common.on') }}</template>
<template #unchecked>{{ t('common.off') }}</template>
</n-switch>
</setting-item>
<setting-item
:title="t('settings.system.cacheDirectory')"
:description="
setData.diskCacheDir || diskCacheStats.directory || t('settings.system.cacheDirectoryDesc')
"
>
<template #action>
<div class="flex items-center gap-2 max-md:flex-wrap">
<s-btn @click="selectCacheDirectory">
{{ t('settings.system.selectDirectory') }}
</s-btn>
<s-btn @click="openCacheDirectory">
{{ t('settings.system.openDirectory') }}
</s-btn>
</div>
</template>
</setting-item>
<setting-item
:title="t('settings.system.cacheMaxSize')"
:description="t('settings.system.cacheMaxSizeDesc')"
>
<template #action>
<s-input
v-model="setData.diskCacheMaxSizeMB"
type="number"
:min="256"
:max="102400"
:step="256"
suffix="MB"
width="w-[160px] max-md:w-32"
/>
</template>
</setting-item>
<setting-item
:title="t('settings.system.cleanupPolicy')"
:description="t('settings.system.cleanupPolicyDesc')"
>
<s-select
v-model="setData.diskCacheCleanupPolicy"
:options="cleanupPolicyOptions"
width="w-40"
/>
</setting-item>
<setting-item
:title="t('settings.system.cacheStatus')"
:description="
t('settings.system.cacheStatusDesc', {
used: formatBytes(diskCacheStats.totalSizeBytes),
limit: `${setData.diskCacheMaxSizeMB || diskCacheStats.maxSizeMB || 0} MB`
})
"
>
<template #action>
<div class="flex items-center gap-3 max-md:flex-wrap">
<div class="w-40 max-md:w-32">
<n-progress type="line" :percentage="diskCacheUsagePercent" />
</div>
<span class="text-xs text-neutral-500">
{{
t('settings.system.cacheStatusDetail', {
musicCount: diskCacheStats.musicFiles,
lyricCount: diskCacheStats.lyricFiles
})
}}
</span>
<s-btn @click="refreshDiskCacheStats()">{{ t('common.refresh') }}</s-btn>
</div>
</template>
</setting-item>
<setting-item
:title="t('settings.system.manageDiskCache')"
:description="t('settings.system.manageDiskCacheDesc')"
>
<template #action>
<div class="flex items-center gap-2 max-md:flex-wrap">
<s-btn @click="clearDiskCacheByScope('music')">
{{ t('settings.system.clearMusicCache') }}
</s-btn>
<s-btn @click="clearDiskCacheByScope('lyrics')">
{{ t('settings.system.clearLyricCache') }}
</s-btn>
<s-btn variant="danger" @click="clearDiskCacheByScope('all')">
{{ t('settings.system.clearAllCache') }}
</s-btn>
</div>
</template>
</setting-item>
<setting-item :title="t('settings.system.cache')" :description="t('settings.system.cacheDesc')">
<s-btn @click="showClearCacheModal = true">{{ t('settings.system.cacheDesc') }}</s-btn>
</setting-item>
<setting-item
:title="t('settings.system.restart')"
:description="t('settings.system.restartDesc')"
>
<s-btn @click="restartApp">{{ t('settings.system.restart') }}</s-btn>
</setting-item>
<clear-cache-settings v-model:show="showClearCacheModal" @confirm="clearCache" />
</setting-section>
</template>
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core';
import { computed, inject, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import localData from '@/../main/set.json';
import ClearCacheSettings from '@/components/settings/ClearCacheSettings.vue';
import { useUserStore } from '@/store/modules/user';
import { isElectron } from '@/utils';
import { openDirectory, selectDirectory } from '@/utils/fileOperation';
import { SETTINGS_DATA_KEY, SETTINGS_DIALOG_KEY, SETTINGS_MESSAGE_KEY } from '../keys';
import SBtn from '../SBtn.vue';
import SettingItem from '../SettingItem.vue';
import SettingSection from '../SettingSection.vue';
import SInput from '../SInput.vue';
import SSelect from '../SSelect.vue';
type DiskCacheScope = 'all' | 'music' | 'lyrics';
type DiskCacheCleanupPolicy = 'lru' | 'fifo';
type CacheSwitchAction = 'migrate' | 'destroy' | 'keep';
type DiskCacheConfig = {
enabled: boolean;
directory: string;
maxSizeMB: number;
cleanupPolicy: DiskCacheCleanupPolicy;
};
type DiskCacheStats = DiskCacheConfig & {
totalSizeBytes: number;
musicSizeBytes: number;
lyricSizeBytes: number;
totalFiles: number;
musicFiles: number;
lyricFiles: number;
usage: number;
};
type SwitchCacheDirectoryResult = {
success: boolean;
config: DiskCacheConfig;
migratedFiles: number;
destroyedFiles: number;
};
const { t } = useI18n();
const userStore = useUserStore();
const setData = inject(SETTINGS_DATA_KEY)!;
const message = inject(SETTINGS_MESSAGE_KEY)!;
const dialog = inject(SETTINGS_DIALOG_KEY)!;
const showClearCacheModal = ref(false);
const diskCacheStats = ref<DiskCacheStats>({
enabled: true,
directory: '',
maxSizeMB: 4096,
cleanupPolicy: 'lru',
totalSizeBytes: 0,
musicSizeBytes: 0,
lyricSizeBytes: 0,
totalFiles: 0,
musicFiles: 0,
lyricFiles: 0,
usage: 0
});
const applyingDiskCacheConfig = ref(false);
const switchingCacheDirectory = ref(false);
const cleanupPolicyOptions = computed(() => [
{ label: t('settings.system.cleanupPolicyOptions.lru'), value: 'lru' },
{ label: t('settings.system.cleanupPolicyOptions.fifo'), value: 'fifo' }
]);
const diskCacheUsagePercent = computed(() =>
Math.min(100, Math.max(0, Math.round((diskCacheStats.value.usage || 0) * 100)))
);
const formatBytes = (bytes: number) => {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
};
const readDiskCacheConfigFromUI = (): DiskCacheConfig => {
const cleanupPolicy: DiskCacheCleanupPolicy =
setData.value.diskCacheCleanupPolicy === 'fifo' ? 'fifo' : 'lru';
const maxSizeMB = Math.max(256, Math.floor(Number(setData.value.diskCacheMaxSizeMB || 4096)));
return {
enabled: setData.value.enableDiskCache !== false,
directory: String(setData.value.diskCacheDir || ''),
maxSizeMB,
cleanupPolicy
};
};
const refreshDiskCacheStats = async (silent: boolean = true) => {
if (!window.electron) return;
try {
const stats = (await window.electron.ipcRenderer.invoke(
'get-disk-cache-stats'
)) as DiskCacheStats;
if (stats) {
diskCacheStats.value = stats;
}
} catch (error) {
console.error('读取磁盘缓存统计失败:', error);
if (!silent) {
message.error(t('settings.system.messages.diskCacheStatsLoadFailed'));
}
}
};
const loadDiskCacheConfig = async () => {
if (!window.electron) return;
try {
const config = (await window.electron.ipcRenderer.invoke(
'get-disk-cache-config'
)) as DiskCacheConfig;
if (config) {
setData.value = {
...setData.value,
enableDiskCache: config.enabled,
diskCacheDir: config.directory,
diskCacheMaxSizeMB: config.maxSizeMB,
diskCacheCleanupPolicy: config.cleanupPolicy
};
}
} catch (error) {
console.error('读取磁盘缓存配置失败:', error);
}
};
const applyDiskCacheConfig = async () => {
if (!window.electron || applyingDiskCacheConfig.value) return;
applyingDiskCacheConfig.value = true;
try {
const config = readDiskCacheConfigFromUI();
const updated = (await window.electron.ipcRenderer.invoke(
'set-disk-cache-config',
config
)) as DiskCacheConfig;
if (updated) {
setData.value = {
...setData.value,
enableDiskCache: updated.enabled,
diskCacheDir: updated.directory,
diskCacheMaxSizeMB: updated.maxSizeMB,
diskCacheCleanupPolicy: updated.cleanupPolicy
};
}
await refreshDiskCacheStats();
} catch (error) {
console.error('更新磁盘缓存配置失败:', error);
} finally {
applyingDiskCacheConfig.value = false;
}
};
const applyDiskCacheConfigDebounced = useDebounceFn(() => {
void applyDiskCacheConfig();
}, 500);
watch(
() => [
setData.value.enableDiskCache,
setData.value.diskCacheDir,
setData.value.diskCacheMaxSizeMB,
setData.value.diskCacheCleanupPolicy
],
() => {
if (!window.electron || applyingDiskCacheConfig.value || switchingCacheDirectory.value) return;
applyDiskCacheConfigDebounced();
}
);
const askCacheSwitchMigrate = (): Promise<boolean> => {
return new Promise((resolve) => {
let resolved = false;
const finish = (value: boolean) => {
if (resolved) return;
resolved = true;
resolve(value);
};
dialog.warning({
title: t('settings.system.switchDirectoryMigrateTitle'),
content: t('settings.system.switchDirectoryMigrateContent'),
positiveText: t('settings.system.switchDirectoryMigrateConfirm'),
negativeText: t('settings.system.switchDirectoryKeepOld'),
onPositiveClick: () => finish(true),
onNegativeClick: () => finish(false),
onClose: () => finish(false)
});
});
};
const askCacheSwitchDestroy = (): Promise<boolean> => {
return new Promise((resolve) => {
let resolved = false;
const finish = (value: boolean) => {
if (resolved) return;
resolved = true;
resolve(value);
};
dialog.warning({
title: t('settings.system.switchDirectoryDestroyTitle'),
content: t('settings.system.switchDirectoryDestroyContent'),
positiveText: t('settings.system.switchDirectoryDestroyConfirm'),
negativeText: t('settings.system.switchDirectoryKeepOld'),
onPositiveClick: () => finish(true),
onNegativeClick: () => finish(false),
onClose: () => finish(false)
});
});
};
const selectCacheDirectory = async () => {
if (!window.electron) return;
const selectedPath = await selectDirectory(message);
if (!selectedPath) return;
const currentDirectory = setData.value.diskCacheDir || diskCacheStats.value.directory;
if (currentDirectory && selectedPath === currentDirectory) {
return;
}
let action: CacheSwitchAction = 'keep';
if (currentDirectory && diskCacheStats.value.totalFiles > 0) {
const shouldMigrate = await askCacheSwitchMigrate();
if (shouldMigrate) {
action = 'migrate';
} else {
const shouldDestroy = await askCacheSwitchDestroy();
action = shouldDestroy ? 'destroy' : 'keep';
}
}
switchingCacheDirectory.value = true;
try {
const result = (await window.electron.ipcRenderer.invoke('switch-disk-cache-directory', {
directory: selectedPath,
action
})) as SwitchCacheDirectoryResult;
if (!result?.success) {
message.error(t('settings.system.messages.switchDirectoryFailed'));
return;
}
setData.value = {
...setData.value,
enableDiskCache: result.config.enabled,
diskCacheDir: result.config.directory,
diskCacheMaxSizeMB: result.config.maxSizeMB,
diskCacheCleanupPolicy: result.config.cleanupPolicy
};
await refreshDiskCacheStats();
if (action === 'migrate') {
message.success(
t('settings.system.messages.switchDirectoryMigrated', { count: result.migratedFiles })
);
return;
}
if (action === 'destroy') {
message.success(
t('settings.system.messages.switchDirectoryDestroyed', { count: result.destroyedFiles })
);
return;
}
message.success(t('settings.system.messages.switchDirectorySuccess'));
} catch (error) {
console.error('切换缓存目录失败:', error);
message.error(t('settings.system.messages.switchDirectoryFailed'));
} finally {
switchingCacheDirectory.value = false;
}
};
const openCacheDirectory = () => {
const targetPath = setData.value.diskCacheDir || diskCacheStats.value.directory;
openDirectory(targetPath, message);
};
const clearDiskCacheByScope = async (scope: DiskCacheScope) => {
if (!window.electron) return;
try {
const success = await window.electron.ipcRenderer.invoke('clear-disk-cache', scope);
if (success) {
await refreshDiskCacheStats();
message.success(t('settings.system.messages.diskCacheClearSuccess'));
return;
}
message.error(t('settings.system.messages.diskCacheClearFailed'));
} catch (error) {
console.error('手动清理磁盘缓存失败:', error);
message.error(t('settings.system.messages.diskCacheClearFailed'));
}
};
const clearCache = async (selectedCacheTypes: string[]) => {
const clearTasks = selectedCacheTypes.map(async (type) => {
switch (type) {
case 'history':
localStorage.removeItem('musicHistory');
break;
case 'favorite':
localStorage.removeItem('favoriteList');
break;
case 'user':
userStore.handleLogout();
break;
case 'settings':
if (window.electron) {
window.electron.ipcRenderer.send('set-store-value', 'set', localData);
}
localStorage.removeItem('appSettings');
localStorage.removeItem('theme');
localStorage.removeItem('lyricData');
localStorage.removeItem('lyricFontSize');
localStorage.removeItem('playMode');
break;
case 'downloads':
if (window.electron) {
window.electron.ipcRenderer.send('clear-downloads-history');
}
break;
case 'resources':
if (window.electron) {
window.electron.ipcRenderer.send('clear-audio-cache');
await window.electron.ipcRenderer.invoke('clear-disk-cache', 'music');
}
localStorage.removeItem('lyricCache');
localStorage.removeItem('musicUrlCache');
if (window.caches) {
try {
const cache = await window.caches.open('music-images');
const keys = await cache.keys();
keys.forEach((key) => cache.delete(key));
} catch (error) {
console.error('清除图片缓存失败:', error);
}
}
break;
case 'lyrics':
if (window.electron) {
await window.electron.ipcRenderer.invoke('clear-disk-cache', 'lyrics');
}
await window.api.invoke('clear-lyrics-cache');
break;
}
});
await Promise.all(clearTasks);
await refreshDiskCacheStats();
message.success(t('settings.system.messages.clearSuccess'));
};
const restartApp = () => {
window.electron.ipcRenderer.send('restart');
};
onMounted(async () => {
await loadDiskCacheConfig();
await refreshDiskCacheStats();
});
</script>