| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448 |
- /**
- * 标注维度选择器:在 `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<AnnotPickerOptions>): Promise<string[]>;
- }
- /**
- * 标注字典维度选择。
- *
- * @param dict - 完整标注树;`ref` / getter 均可。
- * @param defaults - 默认选项;`open` 第三参可覆盖。
- * @returns `[Wrapper, api]` — Wrapper 须挂模板;`api.open` → `Promise<string[]>`。
- */
- export function useAnnotatorPicker(dict: MaybeRefOrGetter<AnnotatorTreeNode[]>, defaults?: AnnotPickerOptions) {
- let pending: PromiseWithResolvers<string[]> | undefined;
- const show = ref(false);
- const title = ref<string>();
- const selected = ref<string[]>([]);
- const displayRoots = shallowRef<AnnotatorTreeNode[]>([]);
- const session = shallowRef<AnnotPickerOptions>(defaults ?? {});
- const uiKind = shallowRef<AnnotPickerKind>('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<AnnotPickerOptions>) {
- 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<string[]>();
- 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;
- }
|