feat: implement TagSelector component for improved tag management across dashboard features

- Added TagSelector component to streamline tag selection in various components.
- Refactored existing tag handling in DashboardConversationsPage, ConversationTagPicker, EditDialog, and TicketsPage to utilize the new TagSelector.
- Removed redundant tag handling functions and optimized state management for tags.
- Updated tests to ensure proper functionality of the new tag handling logic.
This commit is contained in:
mlogclub
2026-05-30 11:23:05 +08:00
parent d2da9de641
commit 8398cd6f91
9 changed files with 653 additions and 354 deletions
+104
View File
@@ -0,0 +1,104 @@
import assert from "node:assert/strict"
import { readFile } from "node:fs/promises"
import test from "node:test"
import ts from "typescript"
import vm from "node:vm"
async function loadModule() {
const source = await readFile(new URL("./tag-tree.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.CommonJS,
},
fileName: "tag-tree.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
const tags = [
{
id: 1,
parentId: 0,
name: "产品",
remark: "",
sortNo: 1,
status: 0,
createdAt: "",
updatedAt: "",
children: [
{
id: 2,
parentId: 1,
name: "退款",
remark: "售后",
sortNo: 1,
status: 0,
createdAt: "",
updatedAt: "",
children: [],
},
],
},
{
id: 3,
parentId: 0,
name: "技术",
remark: "",
sortNo: 2,
status: 0,
createdAt: "",
updatedAt: "",
children: [],
},
]
test("flattens tag tree with depth and full path", async () => {
const { flattenTagTree } = await loadModule()
assert.equal(
JSON.stringify(flattenTagTree(tags).map((item) => ({
id: item.id,
depth: item.depth,
path: item.path,
searchableText: item.searchableText,
}))),
JSON.stringify([
{ id: 1, depth: 0, path: "产品", searchableText: "产品 1 " },
{ id: 2, depth: 1, path: "产品 / 退款", searchableText: "产品 / 退款 2 售后" },
{ id: 3, depth: 0, path: "技术", searchableText: "技术 3 " },
])
)
})
test("excludes a tag and its descendants for parent selection", async () => {
const { flattenTagTree } = await loadModule()
assert.equal(
JSON.stringify(flattenTagTree(tags, { excludeIds: [1] }).map((item) => item.id)),
JSON.stringify([3])
)
})
test("builds full-path map for selected tag badges", async () => {
const { buildTagPathMap } = await loadModule()
assert.equal(buildTagPathMap(tags).get(2), "产品 / 退款")
})
test("flattens only visible branches when a parent is collapsed", async () => {
const { flattenVisibleTagTree } = await loadModule()
assert.equal(
JSON.stringify(
flattenVisibleTagTree(tags, { collapsedIds: [1] }).map((item) => item.id)
),
JSON.stringify([1, 3])
)
})
+89
View File
@@ -0,0 +1,89 @@
import type { TagTree } from "@/lib/api/admin"
export type FlatTagNode = TagTree & {
depth: number
path: string
searchableText: string
}
type FlattenTagTreeOptions = {
excludeIds?: number[]
}
type FlattenVisibleTagTreeOptions = FlattenTagTreeOptions & {
collapsedIds?: number[]
}
export function flattenTagTree(
nodes: TagTree[] | null | undefined,
options: FlattenTagTreeOptions = {}
): FlatTagNode[] {
const excluded = new Set(options.excludeIds ?? [])
const result: FlatTagNode[] = []
function walk(items: TagTree[] | null | undefined, depth: number, parentPath: string) {
const safeItems = Array.isArray(items) ? items : []
safeItems.forEach((item) => {
if (excluded.has(item.id)) {
return
}
const path = parentPath ? `${parentPath} / ${item.name}` : item.name
result.push({
...item,
depth,
path,
searchableText: `${path} ${item.id} ${item.remark ?? ""}`,
})
walk(item.children, depth + 1, path)
})
}
walk(nodes, 0, "")
return result
}
export function buildTagPathMap(
nodes: TagTree[] | null | undefined
): Map<number, string> {
const result = new Map<number, string>()
flattenTagTree(nodes).forEach((item) => {
result.set(item.id, item.path)
})
return result
}
export function flattenVisibleTagTree(
nodes: TagTree[] | null | undefined,
options: FlattenVisibleTagTreeOptions = {}
): FlatTagNode[] {
const excluded = new Set(options.excludeIds ?? [])
const collapsed = new Set(options.collapsedIds ?? [])
const result: FlatTagNode[] = []
function walk(items: TagTree[] | null | undefined, depth: number, parentPath: string) {
const safeItems = Array.isArray(items) ? items : []
safeItems.forEach((item) => {
if (excluded.has(item.id)) {
return
}
const path = parentPath ? `${parentPath} / ${item.name}` : item.name
result.push({
...item,
depth,
path,
searchableText: `${path} ${item.id} ${item.remark ?? ""}`,
})
if (!collapsed.has(item.id)) {
walk(item.children, depth + 1, path)
}
})
}
walk(nodes, 0, "")
return result
}