web-font/src/FontSelector.vue
崮生(子虚) 23728ba02b refactor: 回退内存闸门为固定并发(保留分组调度) + 前端改进
后端:
- subset_queue.ts: 移除基于内存的门控(LLRT GC 不释放 RSS),
  回退到固定并发模型,保留分组调度优化(相同字体批处理重用缓存)
- config.ts: 恢复 subsetConcurrency / subsetQueueTimeoutSeconds,
  移除 subsetMemSoftLimitMB
- routes/subset.ts, app.ts, routes/config.ts: 同步恢复
- api.ts, README: 同步更新

前端:
- FontSelector.vue: 下拉项和触发器标记临时字体(橙色徽章)
- CodeBlock.vue: 新增轻量代码块组件,内置 CSS/HTML/JS 分词高亮(0依赖)
- Home.vue: 集成 CodeBlock 替换原始 <pre> 代码块
2026-07-30 23:11:54 +08:00

200 lines
8.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
import { pinyin } from "pinyin-pro";
import type { FontInfo } from "./api";
import { t } from "./i18n";
const props = defineProps<{
fonts: FontInfo[];
selectedFont: string;
onFontChange: (font: string) => void;
supportedOutTypes: ("woff2" | "ttf")[];
outType: "woff2" | "ttf";
onOutTypeChange: (v: "woff2" | "ttf") => void;
}>();
// ── 搜索下拉状态 ──
/** 下拉是否展开 */
const dropdownOpen = ref(false);
/** 搜索关键词 */
const query = ref("");
/** 搜索输入框 ref展开时自动聚焦 */
const searchInputRef = ref<HTMLInputElement | null>(null);
/** 整个字体选择器根元素 ref用于点击外部关闭检测 */
const selectorRef = ref<HTMLDivElement | null>(null);
/**
* 过滤后的字体列表
*
* 支持空格分隔多关键词AND 关系),每个关键词同时匹配:
* 原始文件名(中文/英文)+ 中文部分的拼音(无声调、无空格)
* 例如搜 "siyuan hei" 能匹配 "思源黑体.ttf",搜 "令东" 能匹配 "令东齐伋复刻体.ttf"
*/
const filteredFonts = computed(() => {
const raw = query.value.trim().toLowerCase();
if (!raw) return props.fonts;
/** 空格拆分为多个关键词,全部命中才匹配 */
const keywords = raw.split(/\s+/);
return props.fonts.filter((f) => {
const name = f.name.toLowerCase();
/** 中文转拼音:去掉声调和空格,"思源黑体" → "siyuanheiti" */
const pinyinStr = pinyin(f.name, { toneType: "none", type: "array", nonZh: "consecutive" }).join("").toLowerCase();
return keywords.every((kw) => name.includes(kw) || pinyinStr.includes(kw));
});
});
/** 当前选中的字体对象(用于判断是否临时字体) */
const selectedFontInfo = computed(() => props.fonts.find((f) => f.name === props.selectedFont));
/** 当前选中字体的显示名(无选中时显示占位文字) */
const selectedLabel = computed(() => props.selectedFont || t("pleaseSelect"));
/** 点击触发器:已展开则关闭,未展开则打开 */
function toggleDropdown(e: MouseEvent) {
/** 展开状态下点击输入框本身不切换(保持打字),只有点击非 input 区域才收起 */
if (dropdownOpen.value && e.target instanceof HTMLInputElement) return;
if (dropdownOpen.value) {
closeDropdown();
} else {
openDropdown();
}
}
/** 打开下拉,清空搜索词并聚焦输入框 */
function openDropdown() {
dropdownOpen.value = true;
query.value = "";
nextTick(() => searchInputRef.value?.focus());
}
/** 关闭下拉 */
function closeDropdown() {
dropdownOpen.value = false;
}
/** 选中某个字体 */
function selectFont(name: string) {
props.onFontChange(name);
closeDropdown();
}
/** 点击外部关闭下拉 */
function handleClickOutside(e: MouseEvent) {
if (selectorRef.value && !selectorRef.value.contains(e.target as Node)) {
closeDropdown();
}
}
/** 字体列表变化时,如果当前选中的字体不在列表中了,清空选择 */
watch(
() => props.fonts,
(fonts) => {
if (props.selectedFont && !fonts.some((f) => f.name === props.selectedFont)) {
props.onFontChange("");
}
},
);
onMounted(() => document.addEventListener("click", handleClickOutside));
onUnmounted(() => document.removeEventListener("click", handleClickOutside));
const outTypeLabels: Record<string, () => string> = {
woff2: () => t("woff2Label"),
ttf: () => t("ttfLabel"),
};
const outTypeDescs: Record<string, () => string> = {
woff2: () => t("woff2Desc"),
ttf: () => t("ttfDesc"),
};
/** 输出格式 change 事件处理:从 select 取值回调父组件 */
function handleOutTypeChange(e: Event) {
const target = e.target as HTMLSelectElement;
if (target) props.onOutTypeChange(target.value as "woff2" | "ttf");
}
</script>
<template>
<div style="display: flex; gap: 12px">
<!-- 可搜索的字体选择器 -->
<div ref="selectorRef" style="flex: 1; position: relative">
<label style="display: block; font-size: 13px; color: #555; margin-bottom: 6px">{{ t('selectFont') }}</label>
<!-- 触发器 + 搜索输入二合一关闭时显示选中字体名展开时变成搜索输入框 -->
<div
@click="toggleDropdown"
style="position: relative; width: 100%; padding: 8px 28px 8px 12px; font-size: 14px; border: 1px solid #d9d9d9; border-radius: 6px; box-sizing: border-box; cursor: pointer; background: #fff; min-height: 34px; display: flex; align-items: center; user-select: none"
:style="{ borderColor: dropdownOpen ? '#4096ff' : '#d9d9d9' }"
>
<!-- 展开时搜索输入框 -->
<input
v-if="dropdownOpen"
ref="searchInputRef"
v-model="query"
:placeholder="t('searchFontPlaceholder')"
@keydown.enter="filteredFonts.length > 0 && selectFont(filteredFonts[0].name)"
@keydown.esc="closeDropdown"
style="width: 100%; border: none; outline: none; font-size: 14px; background: transparent; padding: 0; color: #000"
/>
<!-- 关闭时:显示选中的字体名 -->
<span v-else :style="{ color: selectedFont ? '#000' : '#bbb', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: '6px' }">
<span style="overflow: hidden; text-overflow: ellipsis">{{ selectedLabel }}</span>
<span
v-if="selectedFontInfo?.temporary"
style="flex-shrink: 0; font-size: 10px; padding: 1px 5px; border-radius: 3px; background: #fff7e6; color: #fa8c16; border: 1px solid #ffd591"
>临时</span>
</span>
<!-- 下拉箭头 -->
<svg style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); transition: transform 0.2s" :style="{ transform: dropdownOpen ? 'translateY(-50%) rotate(180deg)' : 'translateY(-50%)' }" width="12" height="12" viewBox="0 0 12 12">
<path d="M2 4l4 4 4-4" fill="none" stroke="#999" stroke-width="1.5" stroke-linecap="round" />
</svg>
<!-- 下拉面板相对于触发器定位top: 100% 精确等于触发器高度 -->
<div
v-if="dropdownOpen"
style="position: absolute; top: calc(100% + 2px); left: 0; right: 0; background: #fff; border: 1px solid #d9d9d9; border-radius: 6px; box-shadow: 0 6px 16px rgba(0,0,0,0.08); z-index: 1000; overflow: hidden"
>
<!-- 字体列表(可滚动) -->
<div style="max-height: 240px; overflow-y: auto">
<div
v-if="filteredFonts.length === 0"
style="padding: 16px; text-align: center; color: #bbb; font-size: 13px"
>
{{ t('noFontFound') }}
</div>
<div
v-for="f in filteredFonts"
:key="f.name"
@click="selectFont(f.name)"
@mouseenter="($event.currentTarget as HTMLElement).style.background = '#f5f5f5'"
@mouseleave="($event.currentTarget as HTMLElement).style.background = f.name === selectedFont ? '#e6f4ff' : '#fff'"
style="padding: 8px 12px; font-size: 14px; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; gap: 6px"
:style="{ background: f.name === selectedFont ? '#e6f4ff' : '#fff', color: f.name === selectedFont ? '#1677ff' : '#333', fontWeight: f.name === selectedFont ? '500' : 'normal' }"
>
<span style="flex: 1; overflow: hidden; text-overflow: ellipsis">{{ f.name }}</span>
<span
v-if="f.temporary"
style="flex-shrink: 0; font-size: 10px; padding: 1px 5px; border-radius: 3px; background: #fff7e6; color: #fa8c16; border: 1px solid #ffd591"
>临时</span>
</div>
</div>
</div>
</div>
</div>
<!-- 输出格式(保持原生 select 不变) -->
<div style="width: 160px">
<label style="display: block; font-size: 13px; color: #555; margin-bottom: 6px">{{ t('outputFormat') }}</label>
<select
:value="outType"
@change="handleOutTypeChange"
style="width: 100%; padding: 8px 12px; font-size: 14px; border: 1px solid #d9d9d9; border-radius: 6px; outline: none; box-sizing: border-box; cursor: pointer; appearance: none; background-image: url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2 4l4 4 4-4' fill='none' stroke='%23999' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E&quot;); background-repeat: no-repeat; background-position: right 10px center; padding-right: 28px"
>
<option v-for="ot in supportedOutTypes" :key="ot" :value="ot">{{ outTypeLabels[ot]() }}</option>
</select>
<p style="font-size: 11px; color: #bbb; margin-top: 4px">{{ outTypeDescs[outType]() }}</p>
</div>
</div>
</template>