mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-03 14:20:50 +08:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
45
src/renderer/views/set/SBtn.vue
Normal file
45
src/renderer/views/set/SBtn.vue
Normal 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>
|
||||
145
src/renderer/views/set/SInput.vue
Normal file
145
src/renderer/views/set/SInput.vue
Normal 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>
|
||||
129
src/renderer/views/set/SSelect.vue
Normal file
129
src/renderer/views/set/SSelect.vue
Normal 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
6
src/renderer/views/set/keys.ts
Normal file
6
src/renderer/views/set/keys.ts
Normal 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>;
|
||||
160
src/renderer/views/set/tabs/AboutTab.vue
Normal file
160
src/renderer/views/set/tabs/AboutTab.vue
Normal 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>
|
||||
114
src/renderer/views/set/tabs/ApplicationTab.vue
Normal file
114
src/renderer/views/set/tabs/ApplicationTab.vue
Normal 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>
|
||||
353
src/renderer/views/set/tabs/BasicTab.vue
Normal file
353
src/renderer/views/set/tabs/BasicTab.vue
Normal 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>
|
||||
15
src/renderer/views/set/tabs/DonationTab.vue
Normal file
15
src/renderer/views/set/tabs/DonationTab.vue
Normal 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>
|
||||
124
src/renderer/views/set/tabs/NetworkTab.vue
Normal file
124
src/renderer/views/set/tabs/NetworkTab.vue
Normal 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>
|
||||
148
src/renderer/views/set/tabs/PlaybackTab.vue
Normal file
148
src/renderer/views/set/tabs/PlaybackTab.vue
Normal 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>
|
||||
499
src/renderer/views/set/tabs/SystemTab.vue
Normal file
499
src/renderer/views/set/tabs/SystemTab.vue
Normal 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>
|
||||
Reference in New Issue
Block a user