t添加标签功能

This commit is contained in:
truewhile
2026-09-16 12:30:34 +08:00
parent 57ee482146
commit cbfae0252e
19 changed files with 1696 additions and 29 deletions
+94
View File
@@ -0,0 +1,94 @@
import {
ALL_TAG_ID,
attachLibraryToTag,
buildLibraryTagTabs,
dedupeLibraryTags,
detachLibraryFromTag,
filterLibrariesByTag,
normalizeLibraryTags,
normalizeTagName,
resolveSelectedTagId,
tagNameError,
} from './libraryTags.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`libraryTags: ${name}`)
}
const libs = [
{ id: 'lib-a', total: 10 },
{ id: 'lib-b', total: 5 },
{ id: 'lib-c', total: 0 },
]
check('empty input normalizes to empty list', normalizeLibraryTags(null).length === 0)
check('trims and drops blank names', normalizeLibraryTags([{ name: ' ', library_ids: [] }]).length === 0)
check(
'merges duplicate tag names case-insensitively',
normalizeLibraryTags([
{ name: 'Anime', library_ids: ['a'] },
{ name: 'anime', library_ids: ['b', 'a'] },
]).length === 1,
)
check(
'dedupes ids inside one tag',
normalizeLibraryTags([{ name: '动画', library_ids: ['a', '', 'a', 'b'] }])[0].library_ids.join(',') === 'a,b',
)
check('truncates over-long names to 24 chars', Array.from(normalizeTagName('x'.repeat(40))).length === 24)
const tabs = buildLibraryTagTabs(libs, [
{ name: '动画', library_ids: ['lib-b', 'missing'] },
{ name: '电影', library_ids: [] },
])
check('tab bar always starts with 全部', tabs[0].id === ALL_TAG_ID && tabs[0].count === 3)
check('tag tab keeps creation order', tabs[1].name === '动画' && tabs[2].name === '电影')
check('tag count ignores unavailable libraries', tabs[1].count === 1)
check(
'filtering keeps tag order and drops unknown ids',
filterLibrariesByTag(libs, [{ name: '动画', library_ids: ['lib-c', 'missing', 'lib-a'] }], '动画')
.map((lib) => lib.id)
.join(',') === 'lib-c,lib-a',
)
check('全部 returns every library in original order', filterLibrariesByTag(libs, [], ALL_TAG_ID).length === 3)
check('unknown tag yields no libraries', filterLibrariesByTag(libs, [], 'nope').length === 0)
check(
'selected tag falls back to 全部 when the tag is gone',
resolveSelectedTagId([{ name: '动画', library_ids: [] }], '动画') === '动画' &&
resolveSelectedTagId([{ name: '动画', library_ids: [] }], '电影') === ALL_TAG_ID,
)
const claimed = dedupeLibraryTags([
{ name: '动画', library_ids: ['a', 'b'] },
{ name: '电影', library_ids: ['b', 'c'] },
])
check('one library belongs to a single tag', claimed[0].library_ids.join(',') === 'a,b')
check('later tag loses the shared library', claimed[1].library_ids.join(',') === 'c')
const attached = attachLibraryToTag(
[
{ name: '动画', library_ids: ['a'] },
{ name: '电影', library_ids: ['b'] },
],
'a',
'电影',
)
check('attach moves the library out of its old tag', attached[0].library_ids.length === 0)
check('attach appends to the target tag', attached[1].library_ids.join(',') === 'b,a')
const detached = detachLibraryFromTag(
[{ name: '电影', library_ids: ['b', 'a'] }],
'a',
'电影',
)
check('detach removes only the requested library', detached[0].library_ids.join(',') === 'b')
check('blank name is rejected', tagNameError(' ', []) !== '')
check('duplicate name is rejected', tagNameError('动画', [{ name: '动画', library_ids: [] }]) !== '')
check(
'renaming a tag to itself is allowed',
tagNameError('动画', [{ name: '动画', library_ids: [] }], '动画') === '',
)
console.log('libraryTags.test.ts ok')
+234
View File
@@ -0,0 +1,234 @@
import type { Library, LibraryTagSet } from '../types'
/** 与后端 model.MaxLibraryTagNameLen 保持一致。 */
export const MAX_TAG_NAME_LENGTH = 24
/** 与后端 model.MaxLibraryTags 保持一致。 */
export const MAX_LIBRARY_TAGS = 50
/** 「全部」伪标签:不参与持久化,仅用于标签栏选中项。 */
export const ALL_TAG_ID = '__all__'
export type LibraryTag = LibraryTagSet
export type LibraryTagTab = {
/** 选中的稳定标识:ALL_TAG_ID 或标签名。 */
id: string
name: string
count: number
/** 是否为「全部」标签栏 */
isAll: boolean
}
const STORAGE_KEY = 'mebox_library_tags'
const SELECTED_KEY = 'mebox_library_tag_selected'
/** 标签名去空白并按字符数截断,与后端清洗规则一致。 */
export function normalizeTagName(name: string): string {
return Array.from(name.trim()).slice(0, MAX_TAG_NAME_LENGTH).join('').trim()
}
export function normalizeLibraryTags(tags: unknown): LibraryTag[] {
if (!Array.isArray(tags)) return []
const out: LibraryTag[] = []
const indexByName = new Map<string, number>()
for (const raw of tags) {
if (!raw || typeof raw !== 'object') continue
const name = normalizeTagName(String((raw as LibraryTagSet).name ?? ''))
if (!name) continue
const key = name.toLowerCase()
let pos = indexByName.get(key)
if (pos === undefined) {
if (out.length >= MAX_LIBRARY_TAGS) break
out.push({ name, library_ids: [] })
pos = out.length - 1
indexByName.set(key, pos)
}
const ids = (raw as LibraryTagSet).library_ids
if (!Array.isArray(ids)) continue
const seen = new Set(out[pos].library_ids)
for (const id of ids) {
const trimmed = String(id ?? '').trim()
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
out[pos].library_ids.push(trimmed)
}
}
return out
}
/** 读取本地兜底缓存:接口不可用时标签栏仍然可用。 */
export function readCachedLibraryTags(): LibraryTag[] {
if (typeof window === 'undefined') return []
try {
return normalizeLibraryTags(JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '[]'))
} catch {
return []
}
}
export function writeCachedLibraryTags(tags: LibraryTag[]): void {
if (typeof window === 'undefined') return
try {
if (tags.length === 0) {
window.localStorage.removeItem(STORAGE_KEY)
return
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(tags))
} catch {
// 私密模式等场景忽略存储失败。
}
}
export function readSelectedTagId(): string {
if (typeof window === 'undefined') return ALL_TAG_ID
try {
return window.localStorage.getItem(SELECTED_KEY)?.trim() || ALL_TAG_ID
} catch {
return ALL_TAG_ID
}
}
export function writeSelectedTagId(tagId: string): void {
if (typeof window === 'undefined') return
try {
if (!tagId || tagId === ALL_TAG_ID) {
window.localStorage.removeItem(SELECTED_KEY)
return
}
window.localStorage.setItem(SELECTED_KEY, tagId)
} catch {
// 同上。
}
}
export async function loadLibraryTags(): Promise<LibraryTag[]> {
const { profileAPI } = await import('../api/profile')
const remote = normalizeLibraryTags(await profileAPI.getLibraryTags())
writeCachedLibraryTags(remote)
return remote
}
export async function saveLibraryTags(tags: LibraryTag[]): Promise<LibraryTag[]> {
const { profileAPI } = await import('../api/profile')
const saved = normalizeLibraryTags(await profileAPI.setLibraryTags(normalizeLibraryTags(tags)))
writeCachedLibraryTags(saved)
return saved
}
/**
* 把标签集合中的媒体库 ID 收敛为「每个库只归属一个标签」:越靠前的标签优先,
* 后面的标签里重复出现的库会被移除。与后端 SetLibraryTags 的语义一致。
*/
export function dedupeLibraryTags(tags: LibraryTag[]): LibraryTag[] {
const claimed = new Set<string>()
return tags.map((tag) => {
const libraryIds: string[] = []
for (const id of tag.library_ids) {
if (claimed.has(id)) continue
claimed.add(id)
libraryIds.push(id)
}
return { ...tag, library_ids: libraryIds }
})
}
/** 标签名是否可用:非空、未超长、且(除了自身以外)没有重名。 */
export function tagNameError(name: string, tags: LibraryTag[], exceptName?: string): string {
const trimmed = normalizeTagName(name)
if (!trimmed) return '标签名不能为空'
if (Array.from(name.trim()).length > MAX_TAG_NAME_LENGTH) {
return `标签名最多 ${MAX_TAG_NAME_LENGTH} 个字`
}
const key = trimmed.toLowerCase()
const selfKey = exceptName ? normalizeTagName(exceptName).toLowerCase() : ''
if (key !== selfKey && tags.some((tag) => tag.name.toLowerCase() === key)) {
return '标签名已存在'
}
return ''
}
export function findTagIdByName(tags: LibraryTag[], name: string): string {
const key = normalizeTagName(name).toLowerCase()
if (!key) return ''
return tags.find((tag) => tag.name.toLowerCase() === key)?.name ?? ''
}
export function tagLibraryIds(tags: LibraryTag[], tagId: string): string[] {
if (!tagId || tagId === ALL_TAG_ID) return []
const tag = tags.find((item) => item.name === tagId)
return tag ? tag.library_ids : []
}
export function isLibraryInTag(tags: LibraryTag[], tagId: string, libraryId: string): boolean {
return tagLibraryIds(tags, tagId).includes(libraryId)
}
/**
* 按标签过滤媒体库:`tagId` 为「全部」时原样返回(保持原有置顶/手动排序),
* 否则只保留该标签下的库,并保持标签里记录的顺序。
*/
export function filterLibrariesByTag<T extends { id: string }>(
libraries: T[],
tags: LibraryTag[],
tagId: string,
): T[] {
if (!tagId || tagId === ALL_TAG_ID) return libraries
const ids = tagLibraryIds(tags, tagId)
if (ids.length === 0) return []
const byId = new Map(libraries.map((lib) => [lib.id, lib]))
const out: T[] = []
for (const id of ids) {
const lib = byId.get(id)
if (lib) out.push(lib)
}
return out
}
/** 生成标签栏模型:首项固定为「全部」,其后按标签顺序追加。 */
export function buildLibraryTagTabs(libraries: Library[], tags: LibraryTag[]): LibraryTagTab[] {
const tabs: LibraryTagTab[] = [
{ id: ALL_TAG_ID, name: '全部', count: libraries.length, isAll: true },
]
if (tags.length === 0) return tabs
const available = new Set(libraries.map((lib) => lib.id))
for (const tag of tags) {
tabs.push({
id: tag.name,
name: tag.name,
count: tag.library_ids.filter((id) => available.has(id)).length,
isAll: false,
})
}
return tabs
}
/** 选中的标签是否仍存在;被删除或被过滤掉时回落到「全部」。 */
export function resolveSelectedTagId(tags: LibraryTag[], selectedId: string): string {
if (!selectedId || selectedId === ALL_TAG_ID) return ALL_TAG_ID
return tags.some((tag) => tag.name === selectedId) ? selectedId : ALL_TAG_ID
}
/** 标签下的媒体库条目总数(用于标题文案)。 */
export function sumLibraryTotals(libraries: Library[]): number {
return libraries.reduce((sum, lib) => sum + (lib.total ?? 0), 0)
}
/** 把一个媒体库挂到标签下:会先从原标签移除,保证一库一标签。 */
export function attachLibraryToTag(tags: LibraryTag[], libraryId: string, tagName: string): LibraryTag[] {
const target = findTagIdByName(tags, tagName)
if (!target) return tags
const next = tags.map((tag) => ({
...tag,
library_ids: tag.library_ids.filter((id) => id !== libraryId),
}))
return next.map((tag) =>
tag.name === target ? { ...tag, library_ids: [...tag.library_ids, libraryId] } : tag,
)
}
/** 从标签下移除媒体库。 */
export function detachLibraryFromTag(tags: LibraryTag[], libraryId: string, tagName: string): LibraryTag[] {
return tags.map((tag) =>
tag.name === tagName ? { ...tag, library_ids: tag.library_ids.filter((id) => id !== libraryId) } : tag,
)
}