useAnnotatorPicker.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. /**
  2. * 标注维度选择器:在 `Dialog` 内挂载 Vant `Picker` / `PickerGroup` / `Cascader`。
  3. *
  4. * @remarks
  5. * - **分流**:对维度**原始** `children` 用 `depthRange(roots, n => n.leaf).max`(`@/tools/tree.tool`):`max≤1` → Picker,`max=2` → PickerGroup,`max>2` → Cascader;`AnnotPickerOptions.component` 可与深度冲突时降级为推断。
  6. * - **`open(values)`**:Pick/Cascader 按数组顺序取首个可命中;PickerGroup 与 tab 下标无关,按 tab 消费候选池。
  7. * - **`withDefault`**:无维度 `standardValue` 时插「正常」;有维度 `standardValue` 时 Pick/Cascader 只填标准叶;**PickerGroup** 下各分类 tab 若**本分类**无标准叶,仍对该 tab 插「正常」。
  8. * - **resolve**:去掉占位 id;PickerGroup 为紧凑 id 数组。
  9. * - **`dynamicTabs`**(仅 PickerGroup):为 `true` 时 tab 标题随选中变:非占位叶用其 `label`;选「正常」或空则恢复分类原 `label`。
  10. *
  11. * @module modules/monitor/composables/useAnnotatorPicker
  12. */
  13. import { defineComponent, h, ref, shallowRef, toValue, watch, type MaybeRefOrGetter } from 'vue';
  14. import {
  15. ANNOTATOR_PLACEHOLDER_PREFIX,
  16. ANNOTATOR_PLACEHOLDER_ID_CASCADE,
  17. ANNOTATOR_PLACEHOLDER_ID_PICK,
  18. annotatorDefaultOptionLeaf,
  19. annotatorPlaceholderIdGroupTab,
  20. type AnnotatorTreeNode,
  21. findAnnotatorSubtreeNode,
  22. getAnnotatorNodePath,
  23. isAnnotatorTreeBranchMeta,
  24. isAnnotatorTreeLeafMeta,
  25. } from '@/request/model/annotator.model';
  26. import { cloneChildren, depthRange, findPreorder } from '@/tools/tree.tool';
  27. import { Dialog, Cascader, Picker, PickerGroup, showFailToast } from 'vant';
  28. import { clamp } from 'es-toolkit';
  29. import 'vant/es/dialog/style';
  30. import 'vant/es/cascader/style';
  31. import 'vant/es/picker/style';
  32. // ---------------------------------------------------------------------------
  33. // 导出
  34. // ---------------------------------------------------------------------------
  35. /** Picker / PickerGroup / Cascader 三种 UI,对应 {@link inferPickerKind}。 */
  36. export type AnnotPickerKind = 'pick' | 'pickerGroup' | 'cascader';
  37. /** `useAnnotatorPicker` 与 `open` 的配置。 */
  38. export interface AnnotPickerOptions {
  39. title?: string;
  40. /** Cascader 选叶后是否不经 Dialog「确定」直接 resolve。 */
  41. autoComplete?: boolean;
  42. /** 是否插「正常」占位、空选中是否填标准项(默认 `true`)。 */
  43. withDefault?: boolean;
  44. /** 强制 UI;与树深度矛盾时内部降级。 */
  45. component?: AnnotPickerKind;
  46. /** 仅 PickerGroup:tab 标题是否随选中更新(非占位叶用叶名,占位/空用分类名)。 */
  47. dynamicTabs?: boolean;
  48. }
  49. function isPlaceholderId(id: string | undefined): boolean {
  50. return !!id && id.startsWith(ANNOTATOR_PLACEHOLDER_PREFIX);
  51. }
  52. /** 按子树最大深度 `max` 推断 UI;`override` 与深度不符时用推断值。 */
  53. function inferPickerKind(max: number, override?: AnnotPickerKind): AnnotPickerKind {
  54. const d: AnnotPickerKind = max <= 1 ? 'pick' : max === 2 ? 'pickerGroup' : 'cascader';
  55. if (!override) return d;
  56. if (override === 'pick' && max > 1) return d;
  57. if (override === 'pickerGroup' && (max <= 1 || max > 2)) return d;
  58. if (override === 'cascader' && max <= 2) return d;
  59. return override;
  60. }
  61. function firstStandardLeaf(roots: readonly AnnotatorTreeNode[]): AnnotatorTreeNode | undefined {
  62. return findPreorder(roots, (n) => !!(n.leaf && isAnnotatorTreeLeafMeta(n.meta) && n.meta.standard));
  63. }
  64. /** `open` 初始 `selected`:PickerGroup 按 tab 消费候选池;否则取首个可命中叶。 */
  65. function initPickerSelection(kind: AnnotPickerKind, roots: readonly AnnotatorTreeNode[], values?: string[]): string[] {
  66. if (kind === 'pickerGroup') {
  67. const n = roots.length;
  68. const row: string[] = Array.from({ length: n }, () => '');
  69. if (!values?.length || n === 0) return row;
  70. const pool = values.map((v) => v?.trim() ?? '').filter(Boolean);
  71. for (let ti = 0; ti < n; ti++) {
  72. const cols = roots[ti]?.children ?? [];
  73. let j = -1;
  74. let id: string | undefined;
  75. for (let k = 0; k < pool.length; k++) {
  76. const hit = findAnnotatorSubtreeNode(cols, pool[k]!)?.id;
  77. if (hit) {
  78. j = k;
  79. id = hit;
  80. break;
  81. }
  82. }
  83. if (j >= 0 && id) {
  84. row[ti] = id;
  85. pool.splice(j, 1);
  86. }
  87. }
  88. return row;
  89. }
  90. if (!values?.length) return [];
  91. for (const raw of values) {
  92. const t = raw?.trim();
  93. if (!t) continue;
  94. const id = findAnnotatorSubtreeNode(roots, t)?.id;
  95. if (id) return [id];
  96. }
  97. return [];
  98. }
  99. /**
  100. * 弹窗用 options 副本(可插「正常」);`withDefault` 且存在 `standardValue` 时写回 `selected`。
  101. * 不修改入参 `roots` 引用。
  102. */
  103. function buildDisplayTree(kind: AnnotPickerKind, dim: AnnotatorTreeNode, roots: AnnotatorTreeNode[], withDefault: boolean, selected: { value: string[] }): AnnotatorTreeNode[] {
  104. const cloneAll = () => roots.map(cloneChildren);
  105. if (!withDefault || !dim.meta || !isAnnotatorTreeBranchMeta(dim.meta)) {
  106. return cloneAll();
  107. }
  108. const { meta } = dim;
  109. if (meta.standardValue === undefined) {
  110. if (kind === 'pick') return [annotatorDefaultOptionLeaf(ANNOTATOR_PLACEHOLDER_ID_PICK), ...cloneAll()];
  111. if (kind === 'cascader') return [annotatorDefaultOptionLeaf(ANNOTATOR_PLACEHOLDER_ID_CASCADE), ...cloneAll()];
  112. if (kind === 'pickerGroup') {
  113. return roots.map((branch, i) => ({
  114. ...branch,
  115. children: [annotatorDefaultOptionLeaf(annotatorPlaceholderIdGroupTab(i)), ...(branch.children ?? [])],
  116. }));
  117. }
  118. } else {
  119. if (kind === 'pick' || kind === 'cascader') {
  120. if (!lastPickSegment(selected.value)) {
  121. const leaf = firstStandardLeaf(roots);
  122. if (leaf) selected.value = [leaf.id];
  123. }
  124. } else if (kind === 'pickerGroup') {
  125. const next = [...selected.value];
  126. let dirty = false;
  127. for (let i = 0; i < roots.length; i++) {
  128. if (!next[i]) {
  129. const leaf = firstStandardLeaf(roots[i]?.children ?? []);
  130. if (leaf) {
  131. while (next.length <= i) next.push('');
  132. next[i] = leaf.id;
  133. dirty = true;
  134. }
  135. }
  136. }
  137. if (dirty) selected.value = next;
  138. return roots.map((branch, ti) =>
  139. firstStandardLeaf(branch.children ?? [])
  140. ? cloneChildren(branch)
  141. : {
  142. ...branch,
  143. children: [annotatorDefaultOptionLeaf(annotatorPlaceholderIdGroupTab(ti)), ...(branch.children ?? [])],
  144. }
  145. );
  146. }
  147. }
  148. return cloneAll();
  149. }
  150. /** Pick/Cascader:自右向左首个非空;PickerGroup:前 `nTabs` 项全非空。 */
  151. function canConfirm(kind: AnnotPickerKind, selected: readonly string[], nTabs: number, isLeaf: boolean): boolean {
  152. if (kind === 'pick') return !!lastPickSegment(selected);
  153. if (kind === 'cascader') return isLeaf && !!lastPickSegment(selected);
  154. if (nTabs === 0) return false;
  155. if (selected.length < nTabs) return false;
  156. return selected.slice(0, nTabs).every(Boolean);
  157. }
  158. function lastPickSegment(sel: readonly string[]): string {
  159. for (let i = sel.length - 1; i >= 0; i--) {
  160. const id = sel[i]?.trim();
  161. if (id) return id;
  162. }
  163. return '';
  164. }
  165. export interface AnnotatorPickerApi {
  166. /**
  167. * @param key - 维度 key / id({@link findAnnotatorSubtreeNode})
  168. * @param seed - 初始候选(简写可匹配完整 id)
  169. * @param overrides - 覆盖本次会话配置
  170. */
  171. open(key: string, seed?: string[], overrides?: Partial<AnnotPickerOptions>): Promise<string[]>;
  172. }
  173. /**
  174. * 标注字典维度选择。
  175. *
  176. * @param dict - 完整标注树;`ref` / getter 均可。
  177. * @param defaults - 默认选项;`open` 第三参可覆盖。
  178. * @returns `[Wrapper, api]` — Wrapper 须挂模板;`api.open` → `Promise<string[]>`。
  179. */
  180. export function useAnnotatorPicker(dict: MaybeRefOrGetter<AnnotatorTreeNode[]>, defaults?: AnnotPickerOptions) {
  181. let pending: PromiseWithResolvers<string[]> | undefined;
  182. const show = ref(false);
  183. const title = ref<string>();
  184. const selected = ref<string[]>([]);
  185. const displayRoots = shallowRef<AnnotatorTreeNode[]>([]);
  186. const session = shallowRef<AnnotPickerOptions>(defaults ?? {});
  187. const uiKind = shallowRef<AnnotPickerKind>('pick');
  188. const nTabs = ref(0);
  189. const isLeaf = ref(false);
  190. const groupActiveTab = ref(0);
  191. watch(show, (v) => {
  192. if (v && uiKind.value === 'pickerGroup') groupActiveTab.value = 0;
  193. });
  194. const settle = (raw: string[]) => {
  195. if (!pending) return;
  196. const kind = uiKind.value;
  197. const nt = nTabs.value;
  198. pending.resolve(
  199. kind === 'pickerGroup'
  200. ? raw
  201. .slice(0, Math.max(0, nt))
  202. .map((id) => (id && !isPlaceholderId(id) ? id : ''))
  203. .filter(Boolean)
  204. : raw.filter((id) => id && !isPlaceholderId(id))
  205. );
  206. pending = undefined;
  207. show.value = false;
  208. };
  209. const abortOpen = () => {
  210. if (!pending) {
  211. show.value = false;
  212. return;
  213. }
  214. const p = pending;
  215. pending = undefined;
  216. show.value = false;
  217. p.reject(new Error('cancelled'));
  218. };
  219. const api: AnnotatorPickerApi = {
  220. open(key: string, seed?: string[], overrides?: Partial<AnnotPickerOptions>) {
  221. pending?.reject(new Error('cancelled'));
  222. session.value = Object.assign({ autoComplete: false, withDefault: true }, defaults, overrides);
  223. let disabled: string[] = [];
  224. if (key === 'faceImg') key = 'face';
  225. else if (key === 'downImg') key = 'tongue';
  226. else if (key === 'upImg') {
  227. key = 'tongue';
  228. disabled = ['sublingualVein'];
  229. }
  230. const tree = toValue(dict);
  231. const dim = findAnnotatorSubtreeNode(tree, key);
  232. if (!dim) return Promise.reject(new Error('未找到该维度'));
  233. const roots = dim.children ?? [];
  234. if (!roots.length) return Promise.reject(new Error('暂无选项'));
  235. const { max } = depthRange(roots, (n) => n.leaf);
  236. const kind = inferPickerKind(max, session.value.component);
  237. uiKind.value = kind;
  238. nTabs.value = roots.length;
  239. selected.value = initPickerSelection(kind, roots, seed);
  240. displayRoots.value = buildDisplayTree(kind, dim, roots, session.value.withDefault !== false, selected);
  241. for (const key of disabled) {
  242. const node = findAnnotatorSubtreeNode(displayRoots.value, key);
  243. if (node) node.disabled = true;
  244. }
  245. title.value = overrides?.title ?? dim.label;
  246. pending = Promise.withResolvers<string[]>();
  247. show.value = true;
  248. return pending.promise;
  249. },
  250. };
  251. const Wrapper = defineComponent({
  252. name: 'WrapperAnnotatorPicker',
  253. setup() {
  254. const fieldNames = { text: 'label', value: 'id', children: 'children' };
  255. const applyPickerModel = (v: string[] | undefined) => {
  256. selected.value = v?.length ? [...v] : [];
  257. };
  258. type PickerVm = {
  259. columns: AnnotatorTreeNode[];
  260. modelValue: string[];
  261. title?: string;
  262. onChange: (v: string[] | undefined) => void;
  263. };
  264. const hPicker = (p: PickerVm) =>
  265. h(Picker, {
  266. confirmButtonText: '',
  267. cancelButtonText: '',
  268. columnsFieldNames: fieldNames,
  269. title: p.title ?? title.value,
  270. columns: p.columns,
  271. modelValue: p.modelValue,
  272. 'onUpdate:modelValue'(v: string[]) {
  273. p.onChange(v);
  274. },
  275. });
  276. const hPickerGroup = () => {
  277. const roots = displayRoots.value ?? [];
  278. const reflect = session.value.dynamicTabs === true;
  279. const tabs = reflect
  280. ? roots.map((branch, ti) => {
  281. const id = selected.value[ti]?.trim();
  282. const def = branch.label ?? '';
  283. if (!id || isPlaceholderId(id)) return def;
  284. const path = getAnnotatorNodePath(branch.children ?? [], id);
  285. return path[path.length - 1]?.label ?? def;
  286. })
  287. : roots.map((r) => r.label);
  288. const k = roots.length;
  289. const at = Number(groupActiveTab.value);
  290. const prevDisabled = at <= 0;
  291. const nextDisabled = k === 0 || at >= k - 1;
  292. const children = roots.map((branch, ti) => {
  293. const cols = branch.children ?? [];
  294. const leaf = selected.value[ti] ?? '';
  295. const path = leaf ? getAnnotatorNodePath(cols, leaf).map((n) => n.id) : [];
  296. return hPicker({
  297. columns: cols,
  298. modelValue: path,
  299. onChange: (mv) => {
  300. const row = [...selected.value];
  301. while (row.length < k) row.push('');
  302. row[ti] = mv?.length ? mv[mv.length - 1]! : '';
  303. selected.value = row;
  304. },
  305. });
  306. });
  307. return h(
  308. PickerGroup,
  309. {
  310. style: {
  311. '--van-picker-cancel-action-color': prevDisabled ? 'var(--van-text-color-2)' : 'var(--van-primary-color)',
  312. '--van-picker-confirm-action-color': nextDisabled ? 'var(--van-text-color-2)' : 'var(--van-primary-color)',
  313. },
  314. title: title.value ?? '',
  315. tabs,
  316. activeTab: groupActiveTab.value,
  317. cancelButtonText: '上一项',
  318. confirmButtonText: '下一项',
  319. onCancel() {
  320. if (!prevDisabled) groupActiveTab.value = at - 1;
  321. },
  322. onConfirm() {
  323. if (!nextDisabled) groupActiveTab.value = at + 1;
  324. },
  325. 'onUpdate:activeTab'(v: number | string) {
  326. groupActiveTab.value = Number(v);
  327. },
  328. },
  329. {
  330. default: () => children,
  331. }
  332. );
  333. };
  334. const hCascader = () => {
  335. const roots = displayRoots.value ?? [];
  336. return h(Cascader, {
  337. title: title.value,
  338. closeable: false,
  339. showHeader: true,
  340. fieldNames: fieldNames,
  341. options: roots,
  342. modelValue: lastPickSegment(selected.value) || undefined,
  343. 'onUpdate:modelValue'(v: string) {
  344. selected.value = [v];
  345. isLeaf.value = false;
  346. },
  347. onFinish({ value }: { value: string }) {
  348. if (session.value.autoComplete) settle([value]);
  349. isLeaf.value = true;
  350. },
  351. });
  352. };
  353. const hBody = () => {
  354. const kind = uiKind.value;
  355. const roots = displayRoots.value ?? [];
  356. if (kind === 'pick') {
  357. const leaf = lastPickSegment(selected.value);
  358. const path = leaf ? getAnnotatorNodePath(roots, leaf) : [];
  359. return hPicker({
  360. columns: roots,
  361. modelValue: path.map((n) => n.id),
  362. onChange: applyPickerModel,
  363. });
  364. }
  365. if (kind === 'pickerGroup') return hPickerGroup();
  366. if (kind === 'cascader') return hCascader();
  367. return h('div', { class: 'p-4 text-center text-gray-500' }, '暂无选项');
  368. };
  369. return () =>
  370. h(
  371. Dialog,
  372. {
  373. show: show.value,
  374. 'onUpdate:show'(v: boolean) {
  375. show.value = v;
  376. },
  377. showCancelButton: true,
  378. width: typeof window === 'undefined' ? 480 : clamp(480, window.innerWidth * 0.9),
  379. beforeClose(action: string) {
  380. if (action !== 'confirm') return true;
  381. if (!canConfirm(uiKind.value, selected.value, nTabs.value, isLeaf.value)) {
  382. showFailToast('请选择一项');
  383. return false;
  384. }
  385. return true;
  386. },
  387. onClosed() {
  388. abortOpen();
  389. },
  390. onCancel: () => abortOpen(),
  391. onConfirm() {
  392. if (canConfirm(uiKind.value, selected.value, nTabs.value, isLeaf.value)) settle([...selected.value]);
  393. },
  394. },
  395. { default: () => hBody() }
  396. );
  397. },
  398. });
  399. return [Wrapper, api] as const;
  400. }