|
|
@@ -0,0 +1,448 @@
|
|
|
+/**
|
|
|
+ * 标注维度选择器:在 `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;
|
|
|
+}
|