Files
WYF-koubo/src/components/Setting/SettingFontManager.vue
T
2026-06-20 18:37:46 +08:00

710 lines
26 KiB
Vue

<template>
<div class="font-manager-setting">
<a-space direction="vertical" size="large" class="w-full">
<!-- 字体统计卡片 -->
<a-card :title="t('字体统计')" :bordered="false">
<a-row :gutter="16">
<a-col :span="6">
<a-statistic
:title="t('总字体数')"
:value="fontStats.total"
:value-style="{ color: '#3f8600' }"
>
<template #prefix>
<icon-font-colors />
</template>
</a-statistic>
</a-col>
<a-col :span="6">
<a-statistic
:title="t('预置字体')"
:value="fontStats.bundled"
>
<template #prefix>
<icon-check-circle />
</template>
</a-statistic>
</a-col>
<a-col :span="6">
<a-statistic
:title="t('系统字体')"
:value="fontStats.system"
>
<template #prefix>
<icon-desktop />
</template>
</a-statistic>
</a-col>
<a-col :span="6">
<a-statistic
:title="t('ziti字体')"
:value="fontStats.ziti"
>
<template #prefix>
<icon-folder />
</template>
</a-statistic>
</a-col>
<a-col :span="6">
<a-statistic
:title="t('自定义字体')"
:value="fontStats.custom"
>
<template #prefix>
<icon-user />
</template>
</a-statistic>
</a-col>
</a-row>
</a-card>
<!-- 快速操作 -->
<a-card :title="t('快速操作')" :bordered="false">
<a-space>
<a-button @click="showUploadDialog = true">
<template #icon>
<icon-upload />
</template>
{{ t('上传字体') }}
</a-button>
<a-button @click="refreshFonts">
<template #icon>
<icon-refresh />
</template>
{{ t('刷新字体列表') }}
</a-button>
<a-input-search
:model-value="searchText"
@input="(value) => { searchText = value; handleSearchInput(value); }"
:placeholder="t('搜索字体名称')"
style="width: 300px"
allow-clear
/>
</a-space>
</a-card>
<!-- 所有字体列表 -->
<a-card :title="t('所有字体')" :bordered="false">
<!-- 🔧 加载进度提示 -->
<a-alert v-if="loading" type="info" closable style="margin-bottom: 16px;">
<template #title>
{{ loadingMessage || t('正在加载字体列表...') }}
</template>
</a-alert>
<a-spin :loading="loading">
<div v-if="filteredFonts.length > 0" class="space-y-2">
<div
v-for="font in paginatedFonts"
:key="font.value"
class="flex items-center justify-between p-3 bg-gray-50 rounded hover:bg-gray-100 transition-colors"
>
<div class="flex items-center gap-3 flex-1">
<icon-font-colors
class="text-2xl"
:class="{
'text-blue-500': font.source === 'bundled',
'text-green-500': font.source === 'ziti',
'text-purple-500': font.source === 'custom',
'text-gray-500': font.source === 'system'
}"
/>
<div class="flex-1">
<div class="font-medium">{{ font.label }}</div>
<div class="text-sm text-gray-500 flex items-center gap-2">
<span>{{ font.value }}</span>
<a-tag size="small" :color="getSourceColor(font.source)">
{{ getSourceLabel(font.source) }}
</a-tag>
<a-tag v-if="font.fontType" size="small" :color="getFontTypeColor(font.fontType)">
{{ getFontTypeLabel(font.fontType) }}
</a-tag>
</div>
</div>
</div>
<a-space>
<a-button size="small" @click="previewFont(font)">
<template #icon>
<icon-eye />
</template>
{{ t('预览') }}
</a-button>
<a-button
v-if="font.source === 'custom' || font.source === 'ziti'"
size="small"
status="danger"
@click="deleteFont(font)"
>
<template #icon>
<icon-delete />
</template>
{{ t('删除') }}
</a-button>
</a-space>
</div>
<!-- 分页 -->
<div class="flex justify-center mt-4">
<a-pagination
v-model:current="currentPage"
:total="filteredFonts.length"
:page-size="pageSize"
show-total
show-jumper
/>
</div>
</div>
<a-empty v-else :description="t('未找到字体')" />
</a-spin>
</a-card>
<!-- 字体目录信息 -->
<a-card :title="t('字体目录')" :bordered="false">
<a-descriptions :column="1" bordered>
<a-descriptions-item :label="t('预置字体目录')">
<code class="text-xs">packaging/vendor/fonts/bundled/</code>
</a-descriptions-item>
<a-descriptions-item :label="t('ziti字体目录')">
<code class="text-xs">packaging/vendor/ziti/</code>
<a-button size="mini" type="text" @click="openZitiDir" class="ml-2">
<template #icon>
<icon-folder />
</template>
{{ t('打开') }}
</a-button>
</a-descriptions-item>
<a-descriptions-item :label="t('自定义字体目录')">
<code class="text-xs">{{ customFontDir }}</code>
<a-button size="mini" type="text" @click="openFontDir" class="ml-2">
<template #icon>
<icon-folder />
</template>
{{ t('打开') }}
</a-button>
</a-descriptions-item>
<a-descriptions-item :label="t('支持格式')">
<a-space>
<a-tag>TTF</a-tag>
<a-tag>OTF</a-tag>
<a-tag>WOFF</a-tag>
<a-tag>WOFF2</a-tag>
<a-tag>TTC</a-tag>
</a-space>
</a-descriptions-item>
</a-descriptions>
</a-card>
</a-space>
<!-- 字体预览对话框 -->
<a-modal
v-model:visible="showPreviewDialog"
:title="t('字体预览')"
:footer="false"
width="600px"
>
<div v-if="previewingFont" class="space-y-4">
<div class="text-center">
<div class="text-lg font-medium mb-2">{{ previewingFont.label }}</div>
<div class="text-sm text-gray-500">{{ previewingFont.value }}</div>
</div>
<div
class="p-6 bg-gray-50 rounded text-center"
:style="{ fontFamily: `'${previewingFont.value}', sans-serif`, fontSize: previewFontSize + 'px' }"
>
{{ previewText || previewingFont.label }}
</div>
<div>
<div class="text-sm mb-2">{{ t('字号') }}: {{ previewFontSize }}px</div>
<a-slider v-model="previewFontSize" :min="12" :max="72" />
</div>
<a-textarea
v-model="previewText"
:placeholder="t('留空则显示字体名称')"
:auto-size="{ minRows: 2, maxRows: 4 }"
/>
<div class="flex gap-2 flex-wrap mt-2">
<a-button size="small" @click="previewText = ''">{{ t("字体名称") }}</a-button>
<a-button size="small" @click="previewText = '快速的棕色狐狸跳过懒狗'">中文</a-button>
<a-button size="small" @click="previewText = 'The quick brown fox jumps over the lazy dog'">English</a-button>
<a-button size="small" @click="previewText = '0123456789'">数字</a-button>
</div>
</div>
</a-modal>
<!-- 字体上传对话框 -->
<FontUploadDialog
v-model:visible="showUploadDialog"
@success="handleUploadSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useI18n } from 'vue-i18n';
import { Message, Modal } from '@arco-design/web-vue';
import { useRouter } from 'vue-router';
import FontUploadDialog from '../../pages/Video/components/FontUploadDialog.vue';
const { t } = useI18n();
const router = useRouter();
// 🔧 防抖函数
const debounce = (fn: Function, delay: number) => {
let timeoutId: NodeJS.Timeout | null = null;
return (...args: any[]) => {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
};
const loading = ref(false);
const loadingProgress = ref(0);
const loadingMessage = ref('');
const fonts = ref<any[]>([]);
const showUploadDialog = ref(false);
const showPreviewDialog = ref(false);
const previewingFont = ref<any>(null);
const previewFontSize = ref(24);
const previewText = ref('');
const customFontDir = ref('userData/fonts/custom');
// 搜索文本
const searchText = ref('');
const debouncedSearchText = ref('');
// 当前页码
const currentPage = ref(1);
// 每页显示数量
const pageSize = ref(20);
// 🔧 缓存字体统计结果,避免重复计算
const fontStatsCache = ref<any>(null);
// 字体统计(使用缓存)
const fontStats = computed(() => {
// 只在 fonts 数组变化时重新计算
const stats = {
total: fonts.value.length,
bundled: fonts.value.filter(f => f.source === 'bundled').length,
system: fonts.value.filter(f => f.source === 'system').length,
ziti: fonts.value.filter(f => f.source === 'ziti').length,
custom: fonts.value.filter(f => f.source === 'custom').length,
};
fontStatsCache.value = stats;
return stats;
});
// 🔧 防抖搜索处理(避免每次搜索都重新过滤)
const handleSearchInput = debounce((value: string) => {
debouncedSearchText.value = value;
currentPage.value = 1; // 搜索时重置分页
}, 300);
// 过滤后的字体列表
const filteredFonts = computed(() => {
if (!debouncedSearchText.value) {
return fonts.value;
}
const search = debouncedSearchText.value.toLowerCase();
return fonts.value.filter(f =>
f.label.toLowerCase().includes(search) ||
f.value.toLowerCase().includes(search)
);
});
// 分页后的字体列表
const paginatedFonts = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
return filteredFonts.value.slice(start, end);
});
// 获取来源标签
const getSourceLabel = (source: string) => {
const labels: Record<string, string> = {
'bundled': t('预置'),
'ziti': t('ziti'),
'custom': t('自定义'),
'system': t('系统')
};
return labels[source] || source;
};
// 获取来源颜色
const getSourceColor = (source: string) => {
const colors: Record<string, string> = {
'bundled': 'blue',
'ziti': 'green',
'custom': 'purple',
'system': 'gray'
};
return colors[source] || 'gray';
};
// 获取字体类型标签
const getFontTypeLabel = (fontType: string) => {
const labels: Record<string, string> = {
'Chinese': t('中文字体'),
'English': t('英文字体'),
'Mixed': t('混合字体')
};
return labels[fontType] || fontType;
};
// 获取字体类型颜色
const getFontTypeColor = (fontType: string) => {
const colors: Record<string, string> = {
'Chinese': 'red',
'English': 'cyan',
'Mixed': 'orange'
};
return colors[fontType] || 'gray';
};
// 加载字体列表
const loadFonts = async () => {
loading.value = true;
loadingProgress.value = 0;
loadingMessage.value = t('正在加载字体列表...');
try {
console.log('开始加载字体列表...');
// 🔧 模拟进度更新(字体加载是异步的,不会立即完成)
const progressInterval = setInterval(() => {
if (loadingProgress.value < 90) {
loadingProgress.value += Math.random() * 30;
}
}, 200);
const result = await window.$mapi.fontManager.getAllFonts();
clearInterval(progressInterval);
loadingProgress.value = 100;
console.log('字体加载结果:', result);
if (result.success) {
fonts.value = result.data || [];
debouncedSearchText.value = ''; // 清空搜索
currentPage.value = 1; // 重置分页
console.log('成功加载字体数量:', fonts.value.length);
loadingMessage.value = t('字体列表加载成功,共 {count} 个字体', { count: fonts.value.length });
// 延迟1秒后隐藏加载提示
setTimeout(() => {
loading.value = false;
loadingProgress.value = 0;
}, 1000);
} else {
console.error('加载字体失败:', result.message);
loading.value = false;
Message.error(result.message || t('加载字体列表失败'));
}
} catch (error: any) {
console.error('加载字体异常:', error);
loading.value = false;
Message.error(t('加载字体列表失败: {error}', { error: error.message || error }));
}
};
// 刷新字体列表
const refreshFonts = () => {
Message.info(t('正在刷新字体列表...'));
currentPage.value = 1;
loadFonts();
};
// 打开字体目录
const openFontDir = async () => {
try {
await window.$mapi.shell.openPath(customFontDir.value);
} catch (error) {
Message.error(t('打开目录失败'));
}
};
// 打开ziti目录
const openZitiDir = async () => {
try {
const path = require('path');
const zitiPath = path.join(process.cwd(), 'packaging', 'vendor', 'ziti');
console.log('打开 ziti 目录:', zitiPath);
// 🔧 使用 $mapi 代替直接 require('electron')
if (window.$mapi && window.$mapi.app) {
await window.$mapi.app.openPath(zitiPath);
Message.success(t('已打开目录'));
} else {
console.warn('⚠️ $mapi.app 不可用,无法打开目录');
Message.error(t('应用接口不可用'));
}
} catch (error: any) {
console.error('打开 ziti 目录失败:', error);
Message.error(t('打开目录失败: {error}', { error: error.message || error }));
}
};
// 预览字体
const previewFont = async (font: any) => {
previewingFont.value = font;
previewText.value = ''; // 重置为空,显示字体名称
// 如果是 ziti 字体,需要动态加载
if (font.source === 'ziti' && font.path) {
try {
console.log('[SettingFontManager] Loading font:', font.value, 'from:', font.path);
// 使用自定义 font:// 协议
const fontUrl = `font://${encodeURIComponent(font.path)}`;
// 创建 style 标签注入 @font-face
const styleId = `font-face-preview-${font.value.replace(/\s+/g, '-')}`;
let styleEl = document.getElementById(styleId) as HTMLStyleElement;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = styleId;
document.head.appendChild(styleEl);
}
// 使用自定义协议 URL
styleEl.textContent = `
@font-face {
font-family: "${font.value}";
src: url("${fontUrl}");
font-display: swap;
}
`;
console.log('[SettingFontManager] Font face created with URL:', fontUrl);
// 等待字体加载
await document.fonts.ready;
console.log('[SettingFontManager] Font loaded successfully:', font.value);
} catch (error) {
console.error('[SettingFontManager] Failed to load font:', font.value, error);
}
}
showPreviewDialog.value = true;
};
// 删除字体
const deleteFont = (font: any) => {
const isZitiFont = font.source === 'ziti';
const warningMessage = isZitiFont
? t('确定要删除 ziti 目录中的字体 "{name}" 吗?文件将被永久删除!', { name: font.label })
: t('确定要删除字体 "{name}" 吗?此操作不可恢复。', { name: font.label });
Modal.confirm({
title: t('确认删除'),
content: warningMessage,
onOk: async () => {
try {
if (isZitiFont) {
// ziti 目录的字体直接删除文件
if (!font.path) {
Message.error(t('字体文件路径不存在'));
return;
}
const fs = require('fs');
console.log('删除 ziti 字体文件:', font.path);
// 检查文件是否存在
if (!fs.existsSync(font.path)) {
Message.error(t('字体文件不存在'));
return;
}
// 删除文件
fs.unlinkSync(font.path);
Message.success(t('字体删除成功'));
loadFonts();
return;
}
const result = await window.$mapi.fontManager.deleteFont(font.value);
if (result.success) {
Message.success(t('字体删除成功'));
loadFonts();
} else {
Message.error(result.message || t('删除失败'));
}
} catch (error: any) {
console.error('删除字体失败:', error);
Message.error(t('删除字体失败: {error}', { error: error.message || error }));
}
},
});
};
// 处理上传成功
const handleUploadSuccess = () => {
Message.success(t('字体上传成功'));
loadFonts();
};
onMounted(async () => {
console.log('字体管理页面已挂载,开始加载字体...');
// 🔧 异步加载字体(不阻塞组件初始化)
// 使用 Promise 而不是 await,让组件先渲染,再加载字体
loadFonts().catch(error => {
console.error('字体加载失败:', error);
});
// 监听字体列表变更事件
const unsubscribe = window.$mapi.fontManager.onFontsChanged(() => {
console.log('[SettingFontManager] Fonts changed, reloading...');
refreshFonts();
});
// 组件卸载时取消监听
onBeforeUnmount(() => {
unsubscribe();
});
});
</script>
<style scoped>
/* 深色主题样式 */
.font-manager-setting {
color: #f3f4f6;
}
/* 卡片内容背景 */
:deep(.arco-card) {
background-color: rgba(30, 35, 45, 0.7) !important;
border-color: rgba(59, 130, 246, 0.2) !important;
color: #f3f4f6;
}
:deep(.arco-card-header) {
background-color: transparent !important;
border-color: rgba(59, 130, 246, 0.2) !important;
color: #f3f4f6 !important;
}
:deep(.arco-card-body) {
background-color: transparent !important;
color: #f3f4f6;
}
/* 字体列表项背景 */
:deep(.bg-gray-50) {
background-color: rgba(40, 45, 55, 0.7) !important;
color: #f3f4f6 !important;
}
:deep(.hover\:bg-gray-100:hover) {
background-color: rgba(59, 130, 246, 0.2) !important;
}
/* 文字颜色 */
:deep(.text-gray-500) {
color: #d1d5db !important;
}
:deep(.font-medium) {
color: #f3f4f6 !important;
}
:deep(.text-sm) {
color: #e5e7eb !important;
}
:deep(.text-lg) {
color: #f3f4f6 !important;
}
/* 统计数据 */
:deep(.arco-statistic-title) {
color: #d1d5db !important;
}
:deep(.arco-statistic-value) {
color: #f3f4f6 !important;
}
/* 输入框 */
:deep(.arco-input-search) {
background-color: #ffffff !important;
}
/* Code标签 */
code {
font-family: 'Courier New', monospace;
background-color: rgba(40, 45, 55, 0.8) !important;
color: #60a5fa !important;
padding: 2px 6px;
border-radius: 3px;
}
/* 描述列表 */
:deep(.arco-descriptions-item-label) {
color: #d1d5db !important;
background-color: rgba(30, 35, 45, 0.6) !important;
}
:deep(.arco-descriptions-item-value) {
color: #f3f4f6 !important;
background-color: rgba(30, 35, 45, 0.6) !important;
}
:global(body[data-theme="soft-light"]) .font-manager-setting {
color: #1f2749 !important;
}
:global(body[data-theme="soft-light"]) .font-manager-setting :deep(.arco-card) {
background: linear-gradient(180deg, rgba(255,255,255,0.95) 0%, rgba(244,245,255,0.98) 100%) !important;
border-color: rgba(132, 142, 220, 0.22) !important;
color: #1f2749 !important;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.84), 0 10px 24px rgba(104,110,170,0.08) !important;
}
:global(body[data-theme="soft-light"]) .font-manager-setting :deep(.arco-card-header),
:global(body[data-theme="soft-light"]) .font-manager-setting :deep(.arco-card-body) {
background: transparent !important;
color: #1f2749 !important;
border-color: rgba(132, 142, 220, 0.22) !important;
}
:global(body[data-theme="soft-light"]) .font-manager-setting :deep(.bg-gray-50) {
background: linear-gradient(180deg, rgba(249,250,255,0.98) 0%, rgba(239,242,255,1) 100%) !important;
color: #1f2749 !important;
border-color: rgba(132, 142, 220, 0.22) !important;
}
:global(body[data-theme="soft-light"]) .font-manager-setting :deep(.hover\:bg-gray-100:hover) {
background: rgba(106, 116, 247, 0.08) !important;
}
:global(body[data-theme="soft-light"]) .font-manager-setting :deep(.text-gray-500),
:global(body[data-theme="soft-light"]) .font-manager-setting :deep(.arco-statistic-title) {
color: #617098 !important;
}
:global(body[data-theme="soft-light"]) .font-manager-setting :deep(.font-medium),
:global(body[data-theme="soft-light"]) .font-manager-setting :deep(.text-sm),
:global(body[data-theme="soft-light"]) .font-manager-setting :deep(.text-lg),
:global(body[data-theme="soft-light"]) .font-manager-setting :deep(.arco-statistic-value),
:global(body[data-theme="soft-light"]) .font-manager-setting :deep(.arco-card-header-title),
:global(body[data-theme="soft-light"]) .font-manager-setting :deep(.arco-descriptions-item-value) {
color: #1f2749 !important;
}
:global(body[data-theme="soft-light"]) .font-manager-setting :deep(.arco-descriptions-item-label) {
color: #617098 !important;
background: rgba(244,245,255,0.98) !important;
}
:global(body[data-theme="soft-light"]) .font-manager-setting code {
background-color: rgba(106, 116, 247, 0.08) !important;
color: #4966e6 !important;
}
</style>