/** * 标注维度选择器:在 `Dialog` 内挂载 Vant `Picker` / `PickerGroup` / `Cascader`。 * * @remarks * - **分流**:对维度**原始** `children` 用 `depthRange(roots, n => n.leaf).max`(`@/tools/tree.tool`):`max≤1` → Picker,`max=2` → PickerGroup,`max>2` → Cascader;`AnnotPickerOptions.component` 可与深度冲突时降级为推断。 * - **`open(values)`**:Pick/Cascader 按数组顺序取首个可命中;PickerGroup 与 tab 下标无关,按 tab 消费候选池。 * - **`withDefault`**:无维度 `standardValue` 时插「正常」;有维度 `standardValue` 时 Pick/Cascader 只填标准叶;**PickerGroup** 下各分类 tab 若**本分类**无标准叶,仍对该 tab 插「正常」。 * - **resolve**:去掉占位 id;PickerGroup 为紧凑 id 数组。 * - **`dynamicTabs`**(仅 PickerGroup):为 `true` 时 tab 标题随选中变:非占位叶用其 `label`;选「正常」或空则恢复分类原 `label`。 * * @module modules/monitor/composables/useAnnotatorPicker */ import { defineComponent, h, ref, shallowRef, toValue, watch, type MaybeRefOrGetter } from 'vue'; import { ANNOTATOR_PLACEHOLDER_PREFIX, ANNOTATOR_PLACEHOLDER_ID_CASCADE, ANNOTATOR_PLACEHOLDER_ID_PICK, annotatorDefaultOptionLeaf, annotatorPlaceholderIdGroupTab, type AnnotatorTreeNode, findAnnotatorSubtreeNode, getAnnotatorNodePath, isAnnotatorTreeBranchMeta, isAnnotatorTreeLeafMeta, } from '@/request/model/annotator.model'; import { cloneChildren, depthRange, findPreorder } from '@/tools/tree.tool'; import { Dialog, Cascader, Picker, PickerGroup, showFailToast } from 'vant'; import { clamp } from 'es-toolkit'; import 'vant/es/dialog/style'; import 'vant/es/cascader/style'; import 'vant/es/picker/style'; // --------------------------------------------------------------------------- // 导出 // --------------------------------------------------------------------------- /** Picker / PickerGroup / Cascader 三种 UI,对应 {@link inferPickerKind}。 */ export type AnnotPickerKind = 'pick' | 'pickerGroup' | 'cascader'; /** `useAnnotatorPicker` 与 `open` 的配置。 */ export interface AnnotPickerOptions { title?: string; /** Cascader 选叶后是否不经 Dialog「确定」直接 resolve。 */ autoComplete?: boolean; /** 是否插「正常」占位、空选中是否填标准项(默认 `true`)。 */ withDefault?: boolean; /** 强制 UI;与树深度矛盾时内部降级。 */ component?: AnnotPickerKind; /** 仅 PickerGroup:tab 标题是否随选中更新(非占位叶用叶名,占位/空用分类名)。 */ dynamicTabs?: boolean; } function isPlaceholderId(id: string | undefined): boolean { return !!id && id.startsWith(ANNOTATOR_PLACEHOLDER_PREFIX); } /** 按子树最大深度 `max` 推断 UI;`override` 与深度不符时用推断值。 */ function inferPickerKind(max: number, override?: AnnotPickerKind): AnnotPickerKind { const d: AnnotPickerKind = max <= 1 ? 'pick' : max === 2 ? 'pickerGroup' : 'cascader'; if (!override) return d; if (override === 'pick' && max > 1) return d; if (override === 'pickerGroup' && (max <= 1 || max > 2)) return d; if (override === 'cascader' && max <= 2) return d; return override; } function firstStandardLeaf(roots: readonly AnnotatorTreeNode[]): AnnotatorTreeNode | undefined { return findPreorder(roots, (n) => !!(n.leaf && isAnnotatorTreeLeafMeta(n.meta) && n.meta.standard)); } /** `open` 初始 `selected`:PickerGroup 按 tab 消费候选池;否则取首个可命中叶。 */ function initPickerSelection(kind: AnnotPickerKind, roots: readonly AnnotatorTreeNode[], values?: string[]): string[] { if (kind === 'pickerGroup') { const n = roots.length; const row: string[] = Array.from({ length: n }, () => ''); if (!values?.length || n === 0) return row; const pool = values.map((v) => v?.trim() ?? '').filter(Boolean); for (let ti = 0; ti < n; ti++) { const cols = roots[ti]?.children ?? []; let j = -1; let id: string | undefined; for (let k = 0; k < pool.length; k++) { const hit = findAnnotatorSubtreeNode(cols, pool[k]!)?.id; if (hit) { j = k; id = hit; break; } } if (j >= 0 && id) { row[ti] = id; pool.splice(j, 1); } } return row; } if (!values?.length) return []; for (const raw of values) { const t = raw?.trim(); if (!t) continue; const id = findAnnotatorSubtreeNode(roots, t)?.id; if (id) return [id]; } return []; } /** * 弹窗用 options 副本(可插「正常」);`withDefault` 且存在 `standardValue` 时写回 `selected`。 * 不修改入参 `roots` 引用。 */ function buildDisplayTree(kind: AnnotPickerKind, dim: AnnotatorTreeNode, roots: AnnotatorTreeNode[], withDefault: boolean, selected: { value: string[] }): AnnotatorTreeNode[] { const cloneAll = () => roots.map(cloneChildren); if (!withDefault || !dim.meta || !isAnnotatorTreeBranchMeta(dim.meta)) { return cloneAll(); } const { meta } = dim; if (meta.standardValue === undefined) { if (kind === 'pick') return [annotatorDefaultOptionLeaf(ANNOTATOR_PLACEHOLDER_ID_PICK), ...cloneAll()]; if (kind === 'cascader') return [annotatorDefaultOptionLeaf(ANNOTATOR_PLACEHOLDER_ID_CASCADE), ...cloneAll()]; if (kind === 'pickerGroup') { return roots.map((branch, i) => ({ ...branch, children: [annotatorDefaultOptionLeaf(annotatorPlaceholderIdGroupTab(i)), ...(branch.children ?? [])], })); } } else { if (kind === 'pick' || kind === 'cascader') { if (!lastPickSegment(selected.value)) { const leaf = firstStandardLeaf(roots); if (leaf) selected.value = [leaf.id]; } } else if (kind === 'pickerGroup') { const next = [...selected.value]; let dirty = false; for (let i = 0; i < roots.length; i++) { if (!next[i]) { const leaf = firstStandardLeaf(roots[i]?.children ?? []); if (leaf) { while (next.length <= i) next.push(''); next[i] = leaf.id; dirty = true; } } } if (dirty) selected.value = next; return roots.map((branch, ti) => firstStandardLeaf(branch.children ?? []) ? cloneChildren(branch) : { ...branch, children: [annotatorDefaultOptionLeaf(annotatorPlaceholderIdGroupTab(ti)), ...(branch.children ?? [])], } ); } } return cloneAll(); } /** Pick/Cascader:自右向左首个非空;PickerGroup:前 `nTabs` 项全非空。 */ function canConfirm(kind: AnnotPickerKind, selected: readonly string[], nTabs: number, isLeaf: boolean): boolean { if (kind === 'pick') return !!lastPickSegment(selected); if (kind === 'cascader') return isLeaf && !!lastPickSegment(selected); if (nTabs === 0) return false; if (selected.length < nTabs) return false; return selected.slice(0, nTabs).every(Boolean); } function lastPickSegment(sel: readonly string[]): string { for (let i = sel.length - 1; i >= 0; i--) { const id = sel[i]?.trim(); if (id) return id; } return ''; } export interface AnnotatorPickerApi { /** * @param key - 维度 key / id({@link findAnnotatorSubtreeNode}) * @param seed - 初始候选(简写可匹配完整 id) * @param overrides - 覆盖本次会话配置 */ open(key: string, seed?: string[], overrides?: Partial): Promise; } /** * 标注字典维度选择。 * * @param dict - 完整标注树;`ref` / getter 均可。 * @param defaults - 默认选项;`open` 第三参可覆盖。 * @returns `[Wrapper, api]` — Wrapper 须挂模板;`api.open` → `Promise`。 */ export function useAnnotatorPicker(dict: MaybeRefOrGetter, defaults?: AnnotPickerOptions) { let pending: PromiseWithResolvers | undefined; const show = ref(false); const title = ref(); const selected = ref([]); const displayRoots = shallowRef([]); const session = shallowRef(defaults ?? {}); const uiKind = shallowRef('pick'); const nTabs = ref(0); const isLeaf = ref(false); const groupActiveTab = ref(0); watch(show, (v) => { if (v && uiKind.value === 'pickerGroup') groupActiveTab.value = 0; }); const settle = (raw: string[]) => { if (!pending) return; const kind = uiKind.value; const nt = nTabs.value; pending.resolve( kind === 'pickerGroup' ? raw .slice(0, Math.max(0, nt)) .map((id) => (id && !isPlaceholderId(id) ? id : '')) .filter(Boolean) : raw.filter((id) => id && !isPlaceholderId(id)) ); pending = undefined; show.value = false; }; const abortOpen = () => { if (!pending) { show.value = false; return; } const p = pending; pending = undefined; show.value = false; p.reject(new Error('cancelled')); }; const api: AnnotatorPickerApi = { open(key: string, seed?: string[], overrides?: Partial) { pending?.reject(new Error('cancelled')); session.value = Object.assign({ autoComplete: false, withDefault: true }, defaults, overrides); let disabled: string[] = []; if (key === 'faceImg') key = 'face'; else if (key === 'downImg') key = 'tongue'; else if (key === 'upImg') { key = 'tongue'; disabled = ['sublingualVein']; } const tree = toValue(dict); const dim = findAnnotatorSubtreeNode(tree, key); if (!dim) return Promise.reject(new Error('未找到该维度')); const roots = dim.children ?? []; if (!roots.length) return Promise.reject(new Error('暂无选项')); const { max } = depthRange(roots, (n) => n.leaf); const kind = inferPickerKind(max, session.value.component); uiKind.value = kind; nTabs.value = roots.length; selected.value = initPickerSelection(kind, roots, seed); displayRoots.value = buildDisplayTree(kind, dim, roots, session.value.withDefault !== false, selected); for (const key of disabled) { const node = findAnnotatorSubtreeNode(displayRoots.value, key); if (node) node.disabled = true; } title.value = overrides?.title ?? dim.label; pending = Promise.withResolvers(); show.value = true; return pending.promise; }, }; const Wrapper = defineComponent({ name: 'WrapperAnnotatorPicker', setup() { const fieldNames = { text: 'label', value: 'id', children: 'children' }; const applyPickerModel = (v: string[] | undefined) => { selected.value = v?.length ? [...v] : []; }; type PickerVm = { columns: AnnotatorTreeNode[]; modelValue: string[]; title?: string; onChange: (v: string[] | undefined) => void; }; const hPicker = (p: PickerVm) => h(Picker, { confirmButtonText: '', cancelButtonText: '', columnsFieldNames: fieldNames, title: p.title ?? title.value, columns: p.columns, modelValue: p.modelValue, 'onUpdate:modelValue'(v: string[]) { p.onChange(v); }, }); const hPickerGroup = () => { const roots = displayRoots.value ?? []; const reflect = session.value.dynamicTabs === true; const tabs = reflect ? roots.map((branch, ti) => { const id = selected.value[ti]?.trim(); const def = branch.label ?? ''; if (!id || isPlaceholderId(id)) return def; const path = getAnnotatorNodePath(branch.children ?? [], id); return path[path.length - 1]?.label ?? def; }) : roots.map((r) => r.label); const k = roots.length; const at = Number(groupActiveTab.value); const prevDisabled = at <= 0; const nextDisabled = k === 0 || at >= k - 1; const children = roots.map((branch, ti) => { const cols = branch.children ?? []; const leaf = selected.value[ti] ?? ''; const path = leaf ? getAnnotatorNodePath(cols, leaf).map((n) => n.id) : []; return hPicker({ columns: cols, modelValue: path, onChange: (mv) => { const row = [...selected.value]; while (row.length < k) row.push(''); row[ti] = mv?.length ? mv[mv.length - 1]! : ''; selected.value = row; }, }); }); return h( PickerGroup, { style: { '--van-picker-cancel-action-color': prevDisabled ? 'var(--van-text-color-2)' : 'var(--van-primary-color)', '--van-picker-confirm-action-color': nextDisabled ? 'var(--van-text-color-2)' : 'var(--van-primary-color)', }, title: title.value ?? '', tabs, activeTab: groupActiveTab.value, cancelButtonText: '上一项', confirmButtonText: '下一项', onCancel() { if (!prevDisabled) groupActiveTab.value = at - 1; }, onConfirm() { if (!nextDisabled) groupActiveTab.value = at + 1; }, 'onUpdate:activeTab'(v: number | string) { groupActiveTab.value = Number(v); }, }, { default: () => children, } ); }; const hCascader = () => { const roots = displayRoots.value ?? []; return h(Cascader, { title: title.value, closeable: false, showHeader: true, fieldNames: fieldNames, options: roots, modelValue: lastPickSegment(selected.value) || undefined, 'onUpdate:modelValue'(v: string) { selected.value = [v]; isLeaf.value = false; }, onFinish({ value }: { value: string }) { if (session.value.autoComplete) settle([value]); isLeaf.value = true; }, }); }; const hBody = () => { const kind = uiKind.value; const roots = displayRoots.value ?? []; if (kind === 'pick') { const leaf = lastPickSegment(selected.value); const path = leaf ? getAnnotatorNodePath(roots, leaf) : []; return hPicker({ columns: roots, modelValue: path.map((n) => n.id), onChange: applyPickerModel, }); } if (kind === 'pickerGroup') return hPickerGroup(); if (kind === 'cascader') return hCascader(); return h('div', { class: 'p-4 text-center text-gray-500' }, '暂无选项'); }; return () => h( Dialog, { show: show.value, 'onUpdate:show'(v: boolean) { show.value = v; }, showCancelButton: true, width: typeof window === 'undefined' ? 480 : clamp(480, window.innerWidth * 0.9), beforeClose(action: string) { if (action !== 'confirm') return true; if (!canConfirm(uiKind.value, selected.value, nTabs.value, isLeaf.value)) { showFailToast('请选择一项'); return false; } return true; }, onClosed() { abortOpen(); }, onCancel: () => abortOpen(), onConfirm() { if (canConfirm(uiKind.value, selected.value, nTabs.value, isLeaf.value)) settle([...selected.value]); }, }, { default: () => hBody() } ); }, }); return [Wrapper, api] as const; }