update at 2026-02-07 14:10:02

This commit is contained in:
douboer
2026-02-07 14:10:02 +08:00
parent 593027578a
commit dcaac46f65
6 changed files with 265 additions and 27 deletions

View File

@@ -1,14 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import selectAllIcon from '../assets/icons/selectall.svg' import selectAllIcon from '../assets/icons/selectall.svg'
import unselectAllIcon from '../assets/icons/unselectall.svg' import unselectAllIcon from '../assets/icons/unselectall.svg'
import type { FontTreeNode } from '../types/font' import { deleteFontFile, renameFontFile } from '../services/font-file-api'
import type { FontInfo, FontTreeNode } from '../types/font'
import { useFontStore } from '../stores/fontStore' import { useFontStore } from '../stores/fontStore'
import { toFontInfoList } from '../utils/font-list'
const props = defineProps<{ const props = defineProps<{
nodes: FontTreeNode[] nodes: FontTreeNode[]
}>() }>()
const fontStore = useFontStore() const fontStore = useFontStore()
const contextMenu = ref<{
visible: boolean
x: number
y: number
fontInfo: FontInfo | null
}>({
visible: false,
x: 0,
y: 0,
fontInfo: null,
})
function toggleExpand(node: FontTreeNode) { function toggleExpand(node: FontTreeNode) {
const next = !node.expanded const next = !node.expanded
@@ -67,6 +81,105 @@ function handleCategorySelectAll(node: FontTreeNode, event: Event) {
ids.forEach(id => fontStore.addToPreview(id)) ids.forEach(id => fontStore.addToPreview(id))
} }
} }
function closeContextMenu() {
contextMenu.value.visible = false
contextMenu.value.fontInfo = null
}
function openFontContextMenu(node: FontTreeNode, event: MouseEvent) {
event.preventDefault()
event.stopPropagation()
if (node.type !== 'font' || !node.fontInfo) {
closeContextMenu()
return
}
const menuWidth = 140
const menuHeight = 80
const safeX = Math.min(event.clientX, window.innerWidth - menuWidth - 8)
const safeY = Math.min(event.clientY, window.innerHeight - menuHeight - 8)
contextMenu.value = {
visible: true,
x: Math.max(8, safeX),
y: Math.max(8, safeY),
fontInfo: node.fontInfo,
}
}
async function handleRenameFromContextMenu() {
const targetFont = contextMenu.value.fontInfo
if (!targetFont) {
return
}
closeContextMenu()
const renamed = window.prompt('请输入新的字体名称', targetFont.name)
if (renamed === null) {
return
}
const nextName = renamed.trim()
if (!nextName || nextName === targetFont.name) {
return
}
try {
const fontList = await renameFontFile(targetFont.path, nextName)
fontStore.replaceFonts(toFontInfoList(fontList))
} catch (error) {
const message = error instanceof Error ? error.message : '字体重命名失败'
window.alert(message)
}
}
async function handleDeleteFromContextMenu() {
const targetFont = contextMenu.value.fontInfo
if (!targetFont) {
return
}
closeContextMenu()
const confirmed = window.confirm(`确认删除字体“${targetFont.name}”?此操作不可恢复。`)
if (!confirmed) {
return
}
try {
const fontList = await deleteFontFile(targetFont.path)
fontStore.replaceFonts(toFontInfoList(fontList))
} catch (error) {
const message = error instanceof Error ? error.message : '字体删除失败'
window.alert(message)
}
}
function handleGlobalPointerDown() {
if (!contextMenu.value.visible) {
return
}
closeContextMenu()
}
function handleGlobalKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeContextMenu()
}
}
onMounted(() => {
window.addEventListener('pointerdown', handleGlobalPointerDown)
window.addEventListener('keydown', handleGlobalKeyDown)
})
onBeforeUnmount(() => {
window.removeEventListener('pointerdown', handleGlobalPointerDown)
window.removeEventListener('keydown', handleGlobalKeyDown)
})
</script> </script>
<template> <template>
@@ -129,6 +242,7 @@ function handleCategorySelectAll(node: FontTreeNode, event: Event) {
v-for="child in node.children" v-for="child in node.children"
:key="child.name" :key="child.name"
class="flex items-center gap-2 border-b border-[#c9cdd4] pb-2 relative" class="flex items-center gap-2 border-b border-[#c9cdd4] pb-2 relative"
@contextmenu.prevent="openFontContextMenu(child, $event)"
> >
<!-- 水平连接线 --> <!-- 水平连接线 -->
<div class="tree-horizontal-line"></div> <div class="tree-horizontal-line"></div>
@@ -169,6 +283,29 @@ function handleCategorySelectAll(node: FontTreeNode, event: Event) {
</div> </div>
</div> </div>
</div> </div>
<teleport to="body">
<div
v-if="contextMenu.visible"
class="fixed z-[2000] min-w-[140px] rounded-md border border-[#d9d9d9] bg-white shadow-md overflow-hidden"
:style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
@pointerdown.stop
@contextmenu.prevent
>
<button
class="w-full h-9 px-3 text-left text-sm text-[#1f2329] hover:bg-[#f2f3f5] border-0 bg-transparent cursor-pointer"
@click="handleRenameFromContextMenu"
>
重命名
</button>
<button
class="w-full h-9 px-3 text-left text-sm text-[#f53f3f] hover:bg-[#fff2f0] border-0 bg-transparent cursor-pointer"
@click="handleDeleteFromContextMenu"
>
删除
</button>
</div>
</teleport>
</template> </template>
<style scoped> <style scoped>

View File

@@ -1,14 +1,7 @@
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { useFontStore } from '../stores/fontStore' import { useFontStore } from '../stores/fontStore'
import type { FontInfo } from '../types/font' import type { FontListItem } from '../types/font'
import { toFontInfoList } from '../utils/font-list'
interface FontListItem {
id: string
name: string
filename: string
category: string
path: string
}
export function useFontLoader() { export function useFontLoader() {
const fontStore = useFontStore() const fontStore = useFontStore()
@@ -29,23 +22,8 @@ export function useFontLoader() {
const fontList: FontListItem[] = await response.json() const fontList: FontListItem[] = await response.json()
console.log('Loaded font list:', fontList.length, 'fonts') console.log('Loaded font list:', fontList.length, 'fonts')
// 转换为 FontInfo const nextFonts = toFontInfoList(fontList)
for (const item of fontList) { fontStore.replaceFonts(nextFonts)
const fontInfo: FontInfo = {
id: item.id,
name: item.name,
path: item.path,
category: item.category,
isFavorite: false,
loaded: false,
progress: 0,
}
fontStore.addFont(fontInfo)
}
// 更新字体树
fontStore.updateFontTree()
console.log(`Successfully loaded ${fontList.length} fonts`) console.log(`Successfully loaded ${fontList.length} fonts`)
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,62 @@
import type { FontListItem } from '../types/font'
interface FontMutationSuccessResponse {
success: true
fontList: FontListItem[]
}
interface FontMutationErrorResponse {
success: false
message?: string
}
type FontMutationResponse = FontMutationSuccessResponse | FontMutationErrorResponse
async function requestFontMutation(
endpoint: string,
payload: Record<string, string>,
): Promise<FontListItem[]> {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
let body: FontMutationResponse | null = null
try {
body = (await response.json()) as FontMutationResponse
} catch {
// ignore parse errors and fallback to status text
}
if (!response.ok || !body || body.success !== true) {
const message =
body && body.success === false && body.message
? body.message
: `请求失败(${response.status}`
throw new Error(message)
}
return body.fontList
}
/**
* 重命名字体文件并返回最新字体列表。
*/
export function renameFontFile(fontPath: string, newName: string): Promise<FontListItem[]> {
return requestFontMutation('/api/fonts/rename', {
path: fontPath,
newName,
})
}
/**
* 删除字体文件并返回最新字体列表。
*/
export function deleteFontFile(fontPath: string): Promise<FontListItem[]> {
return requestFontMutation('/api/fonts/delete', {
path: fontPath,
})
}

View File

@@ -23,6 +23,10 @@ export const useFontStore = defineStore('font', () => {
} }
} }
function filterSetByValidIds(source: Set<string>, validIds: Set<string>): Set<string> {
return new Set(Array.from(source).filter(id => validIds.has(id)))
}
// 状态 // 状态
const fonts = ref<FontInfo[]>([]) const fonts = ref<FontInfo[]>([])
const selectedFontIds = ref<Set<string>>(new Set()) const selectedFontIds = ref<Set<string>>(new Set())
@@ -54,6 +58,23 @@ export const useFontStore = defineStore('font', () => {
fonts.value.push(fontInfo) fonts.value.push(fontInfo)
} }
function replaceFonts(nextFonts: FontInfo[]) {
const validIds = new Set(nextFonts.map(font => font.id))
selectedFontIds.value = filterSetByValidIds(selectedFontIds.value, validIds)
favoriteFontIds.value = filterSetByValidIds(favoriteFontIds.value, validIds)
previewFontIds.value = filterSetByValidIds(previewFontIds.value, validIds)
writeSet('font.favoriteFontIds', favoriteFontIds.value)
writeSet('font.previewFontIds', previewFontIds.value)
fonts.value = nextFonts.map(font => ({
...font,
isFavorite: favoriteFontIds.value.has(font.id),
}))
updateFontTree()
}
function removeFont(fontId: string) { function removeFont(fontId: string) {
const index = fonts.value.findIndex(f => f.id === fontId) const index = fonts.value.findIndex(f => f.id === fontId)
if (index !== -1) { if (index !== -1) {
@@ -229,6 +250,7 @@ export const useFontStore = defineStore('font', () => {
// 方法 // 方法
addFont, addFont,
replaceFonts,
removeFont, removeFont,
selectFont, selectFont,
unselectFont, unselectFont,

View File

@@ -22,6 +22,22 @@ export interface FontInfo {
progress: number progress: number
} }
/**
* fonts.json 字体清单项
*/
export interface FontListItem {
/** 字体唯一标识 */
id: string
/** 字体文件名(不含扩展名) */
name: string
/** 字体文件名(含扩展名) */
filename: string
/** 字体分类(目录名) */
category: string
/** 静态访问路径 */
path: string
}
/** /**
* 字体树节点 * 字体树节点
*/ */

View File

@@ -0,0 +1,23 @@
import type { FontInfo, FontListItem } from '../types/font'
/**
* 将 fonts.json 项转换为 FontInfo 运行时结构。
*/
export function toFontInfo(item: FontListItem): FontInfo {
return {
id: item.id,
name: item.name,
path: item.path,
category: item.category,
isFavorite: false,
loaded: false,
progress: 0,
}
}
/**
* 批量转换字体清单。
*/
export function toFontInfoList(items: FontListItem[]): FontInfo[] {
return items.map(toFontInfo)
}