优化标签

This commit is contained in:
truewhile
2026-09-16 13:50:06 +08:00
parent cbfae0252e
commit 5faa6975d0
7 changed files with 381 additions and 99 deletions
+44
View File
@@ -1,10 +1,14 @@
import {
ALL_TAG_ID,
assignLibrariesToTag,
attachLibrariesToTag,
attachLibraryToTag,
buildLibraryTagTabs,
dedupeLibraryTags,
detachLibrariesFromTag,
detachLibraryFromTag,
filterLibrariesByTag,
filterLibrariesForTagging,
normalizeLibraryTags,
normalizeTagName,
resolveSelectedTagId,
@@ -84,6 +88,46 @@ const detached = detachLibraryFromTag(
)
check('detach removes only the requested library', detached[0].library_ids.join(',') === 'b')
const batchAttached = attachLibrariesToTag(
[
{ name: '动画', library_ids: ['a', 'x'] },
{ name: '电影', library_ids: ['b'] },
],
['b', 'x', 'b', ' missing '],
'动画',
)
check('batch attach moves every id out of its old tag', batchAttached[1].library_ids.length === 0)
check('batch attach keeps order and dedupes ids', batchAttached[0].library_ids.join(',') === 'a,b,x,missing')
check('batch attach ignores unknown target tag', attachLibrariesToTag([{ name: '动画', library_ids: [] }], ['a'], '不存在')[0].library_ids.length === 0)
check('batch attach with no ids is a no-op', attachLibrariesToTag([{ name: '动画', library_ids: ['a'] }], [], '动画')[0].library_ids.join(',') === 'a')
const batchDetached = detachLibrariesFromTag(
[
{ name: '动画', library_ids: ['a', 'b'] },
{ name: '电影', library_ids: ['b', 'c'] },
],
['b', 'c'],
)
check('batch detach removes ids from every tag', batchDetached[0].library_ids.join(',') === 'a')
check('batch detach clears emptied tags', batchDetached[1].library_ids.length === 0)
check(
'assign with empty tag name clears everything',
assignLibrariesToTag([{ name: '动画', library_ids: ['a'] }], ['a'], '')[0].library_ids.length === 0,
)
check(
'assign with a tag routes to attach',
assignLibrariesToTag([{ name: '动画', library_ids: [] }], ['a'], '动画')[0].library_ids.join(',') === 'a',
)
const searchable = [
{ id: 'lib-a', name: '电影库', path: '/media/movies', type: 'movie' },
{ id: 'lib-b', name: 'TV Shows', path: '/media/tv', type: 'tv' },
]
check('tagging search matches name', filterLibrariesForTagging(searchable as never, '电影').length === 1)
check('tagging search matches path', filterLibrariesForTagging(searchable as never, 'media/tv').length === 1)
check('tagging search matches type case-insensitively', filterLibrariesForTagging(searchable as never, 'MOVIE').length === 1)
check('tagging search with blank keyword returns all', filterLibrariesForTagging(searchable as never, ' ').length === 2)
check('blank name is rejected', tagNameError(' ', []) !== '')
check('duplicate name is rejected', tagNameError('动画', [{ name: '动画', library_ids: [] }]) !== '')
check(
+64
View File
@@ -232,3 +232,67 @@ export function detachLibraryFromTag(tags: LibraryTag[], libraryId: string, tagN
tag.name === tagName ? { ...tag, library_ids: tag.library_ids.filter((id) => id !== libraryId) } : tag,
)
}
/**
* 批量把一个或多个媒体库挂到标签下:先从各自原标签移除,再按传入顺序追加,
* 保证「一库一标签」。整批只产生一次写入。
*/
export function attachLibrariesToTag(
tags: LibraryTag[],
libraryIds: string[],
tagName: string,
): LibraryTag[] {
const target = findTagIdByName(tags, tagName)
const moving = normalizeLibraryIds(libraryIds)
if (!target || moving.length === 0) return tags
const movingSet = new Set(moving)
const next = tags.map((tag) => ({
...tag,
library_ids: tag.library_ids.filter((id) => !movingSet.has(id)),
}))
return next.map((tag) =>
tag.name === target ? { ...tag, library_ids: [...tag.library_ids, ...moving] } : tag,
)
}
/** 批量把媒体库移出所有标签(即变为「未分类」)。 */
export function detachLibrariesFromTag(tags: LibraryTag[], libraryIds: string[]): LibraryTag[] {
const moving = new Set(normalizeLibraryIds(libraryIds))
if (moving.size === 0) return tags
return tags.map((tag) => ({
...tag,
library_ids: tag.library_ids.filter((id) => !moving.has(id)),
}))
}
/** 批量归类:tagName 为空表示移出所有标签。 */
export function assignLibrariesToTag(
tags: LibraryTag[],
libraryIds: string[],
tagName: string,
): LibraryTag[] {
return tagName ? attachLibrariesToTag(tags, libraryIds, tagName) : detachLibrariesFromTag(tags, libraryIds)
}
/** 去掉空白与重复,保留首次出现的顺序。 */
export function normalizeLibraryIds(libraryIds: unknown): string[] {
if (!Array.isArray(libraryIds)) return []
const seen = new Set<string>()
const out: string[] = []
for (const raw of libraryIds) {
const trimmed = String(raw ?? '').trim()
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
out.push(trimmed)
}
return out
}
/** 媒体库列表的模糊匹配(名称 / 路径 / 类型),供标签管理里的搜索框使用。 */
export function filterLibrariesForTagging(libraries: Library[], keyword: string): Library[] {
const needle = keyword.trim().toLowerCase()
if (!needle) return libraries
return libraries.filter((lib) =>
`${lib.name} ${lib.path} ${lib.type}`.toLowerCase().includes(needle),
)
}