mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 03:36:37 +08:00
t添加标签功能
This commit is contained in:
@@ -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')
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user