Przeglądaj źródła

添加 病案管理 模块

cc12458 1 miesiąc temu
rodzic
commit
0e01b16266
65 zmienionych plików z 6037 dodań i 8 usunięć
  1. 2 1
      @types/vite-env.d.ts
  2. 105 0
      public/database/annotator.json
  3. 131 0
      src/modules/monitor/Annotator/Annotator.vue
  4. 34 0
      src/modules/monitor/Annotator/actions/annotator.ts
  5. 146 0
      src/modules/monitor/Annotator/actions/export.ts
  6. 2 0
      src/modules/monitor/Annotator/actions/index.ts
  7. 81 0
      src/modules/monitor/Annotator/composables/useActionSheet.ts
  8. 183 0
      src/modules/monitor/Annotator/composables/useClamp/geometry.ts
  9. 134 0
      src/modules/monitor/Annotator/composables/useClamp/handlers.ts
  10. 1 0
      src/modules/monitor/Annotator/composables/useClamp/index.ts
  11. 10 0
      src/modules/monitor/Annotator/composables/useClamp/types.ts
  12. 56 0
      src/modules/monitor/Annotator/composables/useClamp/useClamp.ts
  13. 116 0
      src/modules/monitor/Annotator/composables/usePreview.ts
  14. 70 0
      src/modules/monitor/Annotator/composables/useShape.ts
  15. 170 0
      src/modules/monitor/Annotator/composables/useTools.ts
  16. 15 0
      src/modules/monitor/Annotator/index.ts
  17. 70 0
      src/modules/monitor/Cropper/Cropper.vue
  18. 79 0
      src/modules/monitor/Cropper/actions/export.ts
  19. 3 0
      src/modules/monitor/Cropper/actions/index.ts
  20. 74 0
      src/modules/monitor/Cropper/actions/path.ts
  21. 223 0
      src/modules/monitor/Cropper/composables/useClamp/clampApply.ts
  22. 239 0
      src/modules/monitor/Cropper/composables/useClamp/clampHandlers.ts
  23. 147 0
      src/modules/monitor/Cropper/composables/useClamp/clampMath.ts
  24. 25 0
      src/modules/monitor/Cropper/composables/useClamp/clampRegion.ts
  25. 22 0
      src/modules/monitor/Cropper/composables/useClamp/constants.ts
  26. 18 0
      src/modules/monitor/Cropper/composables/useClamp/fabricSnapshot.ts
  27. 97 0
      src/modules/monitor/Cropper/composables/useClamp/geometry.ts
  28. 3 0
      src/modules/monitor/Cropper/composables/useClamp/index.ts
  29. 43 0
      src/modules/monitor/Cropper/composables/useClamp/object.ts
  30. 23 0
      src/modules/monitor/Cropper/composables/useClamp/refineApply.ts
  31. 342 0
      src/modules/monitor/Cropper/composables/useClamp/refineModel.ts
  32. 11 0
      src/modules/monitor/Cropper/composables/useClamp/types.ts
  33. 51 0
      src/modules/monitor/Cropper/composables/useClamp/useClamp.ts
  34. 43 0
      src/modules/monitor/Cropper/composables/useOverlay.ts
  35. 68 0
      src/modules/monitor/Cropper/composables/useShape.ts
  36. 138 0
      src/modules/monitor/Cropper/composables/useTools.ts
  37. 2 0
      src/modules/monitor/Cropper/index.ts
  38. 62 0
      src/modules/monitor/annotator.page.vue
  39. 58 0
      src/modules/monitor/components/MedicalPatientEdit.vue
  40. 60 0
      src/modules/monitor/components/MedicalRecordPreview.vue
  41. 177 0
      src/modules/monitor/components/MedicalReportEdit.vue
  42. 67 0
      src/modules/monitor/components/MedicalReportPreview.vue
  43. 50 0
      src/modules/monitor/components/PatientInfo.vue
  44. 100 0
      src/modules/monitor/components/PictureUpload.vue
  45. 213 0
      src/modules/monitor/composables/useAnnotatorFlow.ts
  46. 448 0
      src/modules/monitor/composables/useAnnotatorPicker.ts
  47. 22 0
      src/modules/monitor/composables/useFile.ts
  48. 259 0
      src/modules/monitor/composables/usePrint.ts
  49. 254 0
      src/modules/monitor/medical-record.page.vue
  50. 58 0
      src/modules/monitor/tools/color.ts
  51. 7 1
      src/pages/register.page.vue
  52. 9 2
      src/platform/dialog.ui.ts
  53. 89 0
      src/platform/file.ts
  54. 8 0
      src/platform/index.ts
  55. 98 0
      src/request/api/medical.api.ts
  56. 379 0
      src/request/model/annotator.model.ts
  57. 112 0
      src/request/model/medical-patient.model.ts
  58. 84 0
      src/request/model/medical-record.model.ts
  59. 210 0
      src/request/model/medical-report.model.ts
  60. 3 0
      src/router/index.ts
  61. 4 2
      src/stores/visitor.store.ts
  62. 1 0
      src/tools/index.ts
  63. 221 0
      src/tools/tree.tool.ts
  64. 6 2
      src/tools/url.tool.ts
  65. 1 0
      vite.config.ts

+ 2 - 1
@types/vite-env.d.ts

@@ -1,2 +1,3 @@
 declare const __FORBID_AUTO_PROCESS_PULSE_AGENCY__: boolean
-declare const __APP_VERSION__: string
+declare const __APP_VERSION__: string
+declare const __APP_URL__: string

+ 105 - 0
public/database/annotator.json

@@ -0,0 +1,105 @@
+[
+  {"dimension": "舌色", "value": "淡白舌"},
+  {"dimension": "舌色", "value": "淡红舌", "standard": true},
+  {"dimension": "舌色", "value": "红舌"},
+  {"dimension": "舌色", "value": "青紫舌"},
+  {"dimension": "舌色", "value": "绛舌"},
+  {"dimension": "舌色", "value": "舌质淡"},
+
+  {"dimension": "苔色", "value": "黄苔"},
+  {"dimension": "苔色", "value": "淡黄苔"},
+  {"dimension": "苔色", "value": "淡黄苔夹黑苔"},
+  {"dimension": "苔色", "value": "白苔", "standard": true},
+  {"dimension": "苔色", "value": "白苔夹淡黄苔"},
+  {"dimension": "苔色", "value": "白苔夹黄苔"},
+  {"dimension": "苔色", "value": "白苔夹黑苔"},
+  {"dimension": "苔色", "value": "灰黑苔"},
+
+  {"dimension": "舌形", "category": "裂纹程度", "value": "重度裂纹"},
+  {"dimension": "舌形", "category": "裂纹程度", "value": "中度裂纹"},
+  {"dimension": "舌形", "category": "裂纹程度", "value": "轻度裂纹"},
+  {"dimension": "舌形", "category": "裂纹程度", "value": "有舌中沟"},
+  {"dimension": "舌形", "category": "舌形瘀斑", "value": "瘀斑"},
+  {"dimension": "舌形", "category": "点刺程度", "value": "全舌大量点刺"},
+  {"dimension": "舌形", "category": "点刺程度", "value": "全舌较多点刺"},
+  {"dimension": "舌形", "category": "点刺程度", "value": "全舌少量点刺"},
+  {"dimension": "舌形", "category": "瘀点程度", "value": "全舌大量瘀点"},
+  {"dimension": "舌形", "category": "瘀点程度", "value": "全舌较多瘀点"},
+  {"dimension": "舌形", "category": "瘀点程度", "value": "全舌少量瘀点"},
+  {"dimension": "舌形", "category": "异常舌形", "value": "三角舌"},
+  {"dimension": "舌形", "category": "异常舌形", "value": "舌中根凸起"},
+  {"dimension": "舌形", "category": "异常舌形", "value": "舌面凹陷"},
+  {"dimension": "舌形", "category": "异常舌形", "value": "中焦凹陷"},
+  {"dimension": "舌形", "category": "异常舌形", "value": "上焦凹陷"},
+  {"dimension": "舌形", "category": "异常舌形", "value": "菱形舌"},
+  {"dimension": "舌形", "category": "异常舌形", "value": "舌尖平直"},
+  {"dimension": "舌形", "category": "异常舌形", "value": "舌尖凹陷"},
+  {"dimension": "舌形", "category": "异常舌形", "value": "下焦凹陷"},
+  {"dimension": "舌形", "category": "异常舌形", "value": "舌前凸中根凹陷"},
+  {"dimension": "舌形", "category": "异常舌形", "value": "舌两边凸起"},
+  {"dimension": "舌形", "category": "异常舌形", "value": "舌尖凸出"},
+  {"dimension": "舌形", "category": "齿痕程度", "value": "重度齿痕"},
+  {"dimension": "舌形", "category": "齿痕程度", "value": "中度齿痕"},
+  {"dimension": "舌形", "category": "齿痕程度", "value": "轻度齿痕"},
+  {"dimension": "舌形", "category": "舌形胖瘦", "value": "胖"},
+  {"dimension": "舌形", "category": "舌形胖瘦", "value": "瘦"},
+  {"dimension": "舌形", "category": "舌形老嫩", "value": "老"},
+  {"dimension": "舌形", "category": "舌形老嫩", "value": "嫩"},
+
+  {"dimension": "苔质", "category": "苔质腐", "value": "腐"},
+  {"dimension": "苔质", "category": "苔厚程度", "value": "苔重度厚"},
+  {"dimension": "苔质", "category": "苔厚程度", "value": "苔中度厚"},
+  {"dimension": "苔质", "category": "苔厚程度", "value": "苔轻度厚"},
+  {"dimension": "苔质", "category": "苔厚程度", "value": "薄苔", "standard": true},
+  {"dimension": "苔质", "category": "苔面积", "value": "少苔"},
+  {"dimension": "苔质", "category": "苔面积", "value": "无苔"},
+  {"dimension": "苔质", "category": "腻苔", "value": "重度腻"},
+  {"dimension": "苔质", "category": "腻苔", "value": "中度腻"},
+  {"dimension": "苔质", "category": "腻苔", "value": "轻度腻"},
+
+  {"dimension": "津液", "value": "糙"},
+  {"dimension": "津液", "value": "燥"},
+  {"dimension": "津液", "value": "滑"},
+  {"dimension": "津液", "value": "润", "standard": true},
+
+  {"dimension": "舌下", "value": "舌下瘀象"},
+  {"dimension": "舌下", "value": "舌下正常", "standard": true},
+
+  {"dimension": "面色", "value": "红黄隐隐,明润含蓄", "standard": true},
+  {"dimension": "面色", "value": "晦暗枯槁或暴露浮现"},
+
+  {"dimension": "主色", "value": "面黑"},
+  {"dimension": "主色", "value": "面青"},
+  {"dimension": "主色", "value": "面白"},
+  {"dimension": "主色", "value": "面赤"},
+  {"dimension": "主色", "value": "面黄"},
+
+  {"dimension": "光泽", "value": "少量"},
+  {"dimension": "光泽", "value": "无"},
+
+  {"dimension": "黑眼圈", "value": "重度"},
+  {"dimension": "黑眼圈", "value": "轻度"},
+
+  {"dimension": "唇色", "value": "红"},
+  {"dimension": "唇色", "value": "暗红"},
+  {"dimension": "唇色", "value": "白"},
+  {"dimension": "唇色", "value": "青紫"},
+  {"dimension": "唇色", "value": "黑色或斑点"},
+
+  {"dimension": "眼神", "value": "少神"},
+  {"dimension": "眼神", "value": "有神", "standard": true},
+
+  {"dimension": "目色", "value": "目赤肿胀"},
+
+  {"dimension": "两颧红", "value": "轻度"},
+
+  {"dimension": "鼻褶", "value": "重度"},
+  {"dimension": "鼻褶", "value": "轻度"},
+  {"dimension": "鼻褶", "value": "无", "standard": true},
+
+  {"dimension": "眉间/鼻柱青色", "value": "眉间有青色"},
+
+  {"dimension": "面部皮损", "value": "疑似皮肤病,需进一步检测"},
+  {"dimension": "面部皮损", "value": "疑似色素痣"},
+  {"dimension": "面部皮损", "value": "皮下颗粒物"}
+]

+ 131 - 0
src/modules/monitor/Annotator/Annotator.vue

@@ -0,0 +1,131 @@
+<script setup lang="ts">
+import type { Shape } from '@/lib/fabric/brush';
+import type { AnnotatorExportObject, AnnotatorFn, AnnotatorObject } from './index';
+import { Icon } from '@iconify/vue';
+import { useFabric, useImage, useToolbar, withContext } from '@/lib/fabric';
+import { useShape } from './composables/useShape';
+import { useTools } from './composables/useTools';
+import { useClamp } from './composables/useClamp/useClamp';
+import { useActionSheet } from './composables/useActionSheet';
+import { usePreview } from './composables/usePreview';
+import { importAnnotator, exportAnnotator, startAnnotator } from './actions';
+import { showLoadingToast } from 'vant';
+interface Props {
+  title?: string;
+  url?: string;
+  annotator?: AnnotatorObject[];
+  picker?: AnnotatorFn;
+  upload(blob: Blob, name?: string): Promise<string>;
+}
+interface Emits {
+  complete: [AnnotatorExportObject];
+  cancel: [];
+}
+
+const props = defineProps<Props>();
+const emits = defineEmits<Emits>();
+const url = toRef(() => props.url ?? '');
+
+const [Fabric, fabric] = useFabric({ params: { selection: false, renderOnAddRemove: false } });
+
+const previewable = ref(false);
+const selectable = ref<boolean | void>(false);
+const shape = ref<Shape | null>(null);
+const image = useImage(withContext(fabric, { url }), { minScaleLimit: 0.5, lockScaleUniform: true, lockRotation: true });
+useShape(withContext(fabric, { shape, selectable, annotator: picker }));
+useClamp(withContext(fabric, { image }), update);
+
+const tools = useTools(withContext(fabric, { image, shape, selectable, previewable }), complete);
+const [Toolbar] = useToolbar({ params: { tools } });
+
+const [list, previewApi] = usePreview(withContext(fabric, { image, previewable, annotator: picker }));
+const actionSheetProps = useActionSheet(withContext(fabric, { image, shape, selectable, annotator: picker }));
+
+function update() {
+  const canvas = toValue(fabric.canvas);
+  const annotator = props.annotator ?? [];
+  if (!canvas || !image.value || !annotator?.length) return;
+  importAnnotator(canvas, image.value, ...annotator);
+}
+
+async function complete() {
+  const canvas = toValue(fabric.canvas);
+  if (!canvas || !image.value) return;
+
+  const toast = showLoadingToast({
+    message: '处理中...',
+    duration: 0,
+    forbidClick: true,
+  });
+  try {
+    const data: AnnotatorExportObject = { annotator: [] as AnnotatorObject[], relation: {} };
+    const results = await exportAnnotator(canvas, image.value, { format: 'png', groupBy: 'annotatorId', allowCollision: false });
+    const total = results.length;
+    let index = 0;
+    for (const { blob, list, relation } of results) {
+      if (total >= 3) toast.message = `处理中 (${++index} / ${total})`;
+      const src = await props.upload(blob, list[0]?.annotatorId);
+      for (const object of list) data.annotator.push({ ...object, src } as AnnotatorObject);
+      data.relation[src] = relation;
+    }
+    emits('complete', data);
+  } finally {
+    toast.close();
+  }
+}
+async function picker(object: AnnotatorObject) {
+  if (!object) return false;
+  const result = await startAnnotator(object, props.picker);
+  if (typeof result === 'string') object.annotatorId = result;
+  const canvas = toValue(fabric.canvas);
+  if (canvas) {
+    canvas.remove(object);
+    if (result !== false) {
+      canvas.add(object);
+      canvas.bringObjectToFront(object);
+    }
+    canvas.requestRenderAll();
+  }
+  object.visible = result !== false;
+  return object.visible;
+}
+</script>
+
+<template>
+  <div class="cropper-wrapper">
+    <Toolbar />
+    <div class="content">
+      <Fabric />
+    </div>
+    <teleport defer to="body">
+      <van-action-sheet v-bind="actionSheetProps" />
+    </teleport>
+    <Teleport defer to=".float-panel-footer-root">
+      <van-cell-group class="py-4">
+        <van-cell v-for="item in list" :key="item.uid" clickable :title="item.title" @click="previewApi.highlightObject(item)">
+          <template #icon>
+            <div class="mr-2 size-4 border-2 border-solid" :style="{ borderColor: item.object.stroke as string }"></div>
+          </template>
+          <template #value>
+            <div class="flex justify-end gap-2">
+              <Icon class="text-2xl text-indigo-500" :icon="item.object.visible ? 'mdi:eye-outline' : 'mdi:eye-off-outline'" @click.stop="previewApi.toggleVisible(item)" />
+              <Icon class="text-2xl text-amber-500" icon="mdi:file-edit-outline" @click.stop="previewApi.editObject(item)" />
+              <Icon class="text-2xl text-pink-500" icon="mdi:delete-outline" @click.stop="previewApi.deleteObject(item)" />
+            </div>
+          </template>
+        </van-cell>
+      </van-cell-group>
+    </Teleport>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.content {
+  $size: clamp(300px, 100vw, 100vmin);
+  width: $size;
+  height: $size;
+}
+.van-cell {
+  align-items: center;
+}
+</style>

+ 34 - 0
src/modules/monitor/Annotator/actions/annotator.ts

@@ -0,0 +1,34 @@
+import type { FabricObject } from 'fabric';
+import type { AnnotatorFn, AnnotatorObject } from '../index';
+import { isPromise } from 'es-toolkit';
+import { FabricCanvas } from '@/lib/fabric';
+import { randomUUID } from '@/tools';
+
+export function startAnnotator(object: AnnotatorObject, fn?: AnnotatorFn) {
+  const result = fn?.(object) ?? true;
+
+  if (isPromise(result)) {
+    object.visible = false;
+    result.then((visible) => {
+      object.visible = visible !== false;
+      if (!object.visible) removeAnnotator(object);
+    });
+  } else object.visible = result !== false;
+
+  return result;
+}
+
+export function removeAnnotator(object: AnnotatorObject) {
+  if (object.canvas) object.canvas.remove(object);
+}
+
+export function getAnnotatorTitle(object: AnnotatorObject) {
+  return object.annotatorId?.split(':')?.pop() ?? '标注对象';
+}
+
+export function getAnnotatorObject(canvas: FabricCanvas, excluded?: FabricObject[]): AnnotatorObject[] {
+  return canvas.getObjects().filter((object) => {
+    object.id ??= randomUUID();
+    return !object.excludeFromExport && !excluded?.includes(object);
+  }) as AnnotatorObject[];
+}

+ 146 - 0
src/modules/monitor/Annotator/actions/export.ts

@@ -0,0 +1,146 @@
+import type { Canvas, FabricImage, FabricObject } from 'fabric';
+import type { BlobOptions } from '@/lib/fabric/common';
+import type { AnnotatorObject } from '../index';
+import { pick, mapAsync } from 'es-toolkit';
+import { util } from 'fabric';
+import { getBlob, getObjectMultiplier, getReferMatrix, setDecompose, transformObjectMatrix } from '@/lib/fabric/common';
+import { getAnnotatorObject } from './annotator';
+
+const { enlivenObjects, multiplyTransformMatrices, qrDecompose } = util;
+
+interface Options extends ExportObjectOptions, BlobOptions {}
+
+interface ExportObjectOptions {
+  groupBy?: keyof AnnotatorObject | GroupByFn;
+  allowCollision?: boolean;
+
+  included?: string[];
+  excluded?: string[];
+  properties?: keyof AnnotatorObject[];
+}
+
+export interface AnnotatorResult {
+  blob: Blob;
+  list: AnnotatorObject[];
+  relation: AnnotatorObject['id'][];
+}
+
+/**
+ * 存储
+ * @param canvas
+ * @param image
+ * @param options
+ *
+ * @description
+ * 1. referMatrix         = image.calcTransformMatrix()
+ * 2. scaleMatrix         = [multiplier, 0, 0, multiplier, 0, 0]
+ * 3. scaledReferMatrix   = referMatrix × scaleMatrix
+ * 4. invertedScaledReferMatrix = invert(scaledReferMatrix)
+ * 5. objectMatrix        = object.calcTransformMatrix()
+ * 6. relativeMatrix      = invertedScaledReferMatrix × objectMatrix
+ * 7. decomposed          = qrDecompose(relativeMatrix)
+ * 8. result              = absorbScaleIntoGeometry({ ...toObject(), ...decomposed })
+ */
+export async function exportAnnotator(canvas: Canvas, image: FabricImage, options?: Options): Promise<AnnotatorResult[]> {
+  const bucket = getExportObjectBucket(canvas, image, options);
+
+  const properties = unifyProperties(options?.properties);
+  const multiplier = getObjectMultiplier(image);
+
+  const bounds = image.getBoundingRect();
+  const matrix = getReferMatrix(image, multiplier);
+
+  const result = await mapAsync(
+    bucket,
+    async (group) => {
+      const filter = (object: unknown) => object === image || group.includes(object as AnnotatorObject);
+      try {
+        const blob = await getBlob(canvas, { ...bounds, ...pick(options ?? {}, ['format', 'quality']), filter, multiplier });
+        const list = group.map((object) => transformObjectMatrix(object, matrix, { properties, absorbScale: true }));
+        return { blob, list, relation: group.map((object) => object.id) };
+      } catch {}
+    },
+    { concurrency: 2 }
+  );
+  return result.filter(Boolean) as AnnotatorResult[];
+}
+
+/**
+ * 恢复
+ * @param canvas
+ * @param image
+ * @param object
+ *
+ * @description
+ * 1. referMatrix         = image.calcTransformMatrix()
+ * 2. scaleMatrix         = [multiplier, 0, 0, multiplier, 0, 0]
+ * 3. scaledReferMatrix   = referMatrix × scaleMatrix
+ * 4. object              = enlivenObjects(data)
+ * 5. relativeMatrix      = object.calcTransformMatrix()
+ * 6. worldMatrix         = scaledReferMatrix × relativeMatrix
+ * 7. decomposed          = qrDecompose(worldMatrix)
+ * 8. object.set({ ...decomposed, originX: 'center', originY: 'center' })
+ */
+export async function importAnnotator(canvas: Canvas, image: FabricImage, ...object: AnnotatorObject[]) {
+  const multiplier = getObjectMultiplier(image);
+  // 1. 重建当时转换用的 scaledReferMatrix
+  const matrix = getReferMatrix(image, multiplier);
+
+  const objects = await enlivenObjects<FabricObject>(object);
+  for (const object of objects) {
+    const objectMatrix = object.calcTransformMatrix();
+    // 世界矩阵 = scaledReferMatrix * relativeMatrix
+    const worldMatrix = multiplyTransformMatrices(matrix, objectMatrix);
+    setDecompose(object, worldMatrix, { absorbScale: false });
+    canvas.add(object);
+  }
+}
+
+type GroupByFn = (object: AnnotatorObject) => unknown;
+
+function unifyProperties(value: Options['properties']): string[] {
+  return ['id', 'annotatorId', 'annotatorKey', ...(Array.isArray(value) ? (value as string[]) : [])];
+}
+
+function unifyGroupBy(value: Options['groupBy']): GroupByFn {
+  if (value == null) return (object) => object.id;
+  if (typeof value === 'function') return value;
+  return (object) => object[value];
+}
+
+function getExportObjectBucket(canvas: Canvas, image: FabricImage, options?: ExportObjectOptions): AnnotatorObject[][] {
+  const objects = getAnnotatorObject(canvas, [image]);
+
+  const included = options?.included;
+  const excluded = options?.excluded;
+  const allowCollision = options?.allowCollision ?? true;
+  const groupBy = unifyGroupBy(options?.groupBy);
+
+  const bucket = new Map<unknown, AnnotatorObject[][]>();
+
+  for (const object of objects) {
+    if (included?.length && (object.id == null || !included.includes(object.id))) continue;
+    if (excluded?.length && object.id != null && excluded.includes(object.id)) continue;
+
+    object.setCoords();
+    const bucketKey = groupBy(object);
+
+    let bins = bucket.get(bucketKey);
+    if (!bins) bucket.set(bucketKey, [[object]]);
+    else if (allowCollision) bins[0]!.push(object);
+    else {
+      let placed = false;
+      for (const bin of bins) {
+        const disjoint = bin.every((other) => !object.intersectsWithObject(other));
+        if (disjoint) {
+          bin.push(object);
+          placed = true;
+          break;
+        }
+      }
+      if (!placed) bins.push([object]);
+    }
+  }
+
+  return Array.from(bucket.values()).flat();
+}

+ 2 - 0
src/modules/monitor/Annotator/actions/index.ts

@@ -0,0 +1,2 @@
+export * from './annotator'
+export * from './export'

+ 81 - 0
src/modules/monitor/Annotator/composables/useActionSheet.ts

@@ -0,0 +1,81 @@
+import type { MaybeRef } from 'vue';
+import type { ActionSheetProps } from 'vant';
+import type { FabricImage } from 'fabric';
+import type { FabricContext } from '@/lib/fabric';
+import type { Shape } from '@/lib/fabric/brush';
+import type { AnnotatorObject } from '../index';
+import { useFabricEventListener } from '@/lib/fabric';
+import { getAnnotatorTitle } from '../actions';
+
+interface ActionSheetContext extends FabricContext {
+  image: MaybeRef<FabricImage | null>;
+  shape: MaybeRef<Shape | null>;
+  selectable: MaybeRef<boolean | void>;
+  annotator: (object: AnnotatorObject) => Promise<boolean> | boolean;
+}
+
+interface Emits {
+  'onUpdate:show'(value: boolean): void;
+  'onClosed'(): void;
+}
+
+export function useActionSheet(context: ActionSheetContext) {
+  const props = shallowRef<Partial<ActionSheetProps & Emits>>({
+    show: false,
+    cancelText: '取消',
+    closeOnClickAction: true,
+    closeable: false,
+    'onUpdate:show'(value) {
+      if (value !== props.value.show) {
+        props.value.show = value;
+        triggerRef(props);
+      }
+    },
+    onClosed() {
+      props.value.actions = [];
+      triggerRef(props);
+    },
+  });
+
+  let timer: ReturnType<typeof setTimeout>;
+
+  useFabricEventListener(context.canvas, 'mouse:down', (event) => {
+    clearTimeout(timer);
+    if (!event.target || event.target === toValue(context.image)) return;
+    if (toValue(context.selectable) || toValue(context.shape)) return;
+    props.value.actions = [
+      {
+        name: '更改标注文本',
+        callback: () => {
+          context.annotator(event.target as AnnotatorObject);
+        },
+      },
+      {
+        name: '编辑标注对象',
+        callback: () => {
+          if (isRef(context.selectable)) context.selectable.value = true;
+          if (isRef(context.shape)) context.shape.value = null;
+          toValue(context.canvas)?.setActiveObject(event.target!);
+        },
+      },
+      {
+        name: '删除标注对象',
+        color: '#ee0a24',
+        callback: () => {
+          const canvas = toValue(context.canvas);
+          if (canvas) {
+            canvas.remove(event.target!);
+            canvas.requestRenderAll();
+          }
+        },
+      },
+    ].filter((option) => option.name);
+    props.value.title = getAnnotatorTitle(event.target as AnnotatorObject);
+    timer = setTimeout(() => {
+      props.value.show = true;
+      triggerRef(props);
+    }, 300);
+  });
+
+  return props;
+}

+ 183 - 0
src/modules/monitor/Annotator/composables/useClamp/geometry.ts

@@ -0,0 +1,183 @@
+import { type TBBox, util, type XY } from 'fabric';
+
+const { makeBoundingBoxFromPoints } = util;
+/**
+ * 计算将 `bounds` 平移多少 `{ x, y }` 后,能按选定策略与 `container` 对齐。
+ *
+ * - **cover**:在宽、高均不小于容器时,避免出现「容器内侧露空」——保持内容仍盖住整个容器(类似 cover)。
+ * - **contain**:将 `bounds` 完全限制在容器矩形内,防止越界拖出可视区域。
+ * - **auto**:根据 `bounds` 与 `container` 的尺寸关系自动选择:若宽高均不小于容器(在 EPS 容差内)则按 cover,否则按 contain。
+ *
+ * @param bounds - 待调整的轴对齐矩形(如内容、选区等)。
+ * @param container - 轴对齐的裁剪或视口矩形。
+ * @param mode - 夹紧策略。`auto` 为默认,按尺寸推断;`cover` / `contain` 为强制策略。
+ * @returns 需要施加的平移 `{ x, y }`;若无需移动则返回 `undefined`。
+ */
+export function calcTranslationDelta(bounds: TBBox, container: TBBox, mode: 'auto' | 'cover' | 'contain' = 'auto'): XY | void {
+  const EPS = 1e-6;
+  const covers = mode === 'auto' ? bounds.width >= container.width - EPS && bounds.height >= container.height - EPS : mode === 'cover';
+
+  const bl = bounds.left;
+  const bt = bounds.top;
+  const br = bounds.left + bounds.width;
+  const bb = bounds.top + bounds.height;
+
+  const cl = container.left;
+  const ct = container.top;
+  const cr = container.left + container.width;
+  const cb = container.top + container.height;
+
+  const delta = { x: 0, y: 0 };
+
+  if (covers) {
+    if (bl > cl + EPS) delta.x -= bl - cl;
+    if (bt > ct + EPS) delta.y -= bt - ct;
+    if (br < cr - EPS) delta.x += cr - br;
+    if (bb < cb - EPS) delta.y += cb - bb;
+  } else {
+    if (bl < cl - EPS) delta.x += cl - bl;
+    if (bt < ct - EPS) delta.y += ct - bt;
+    if (br > cr + EPS) delta.x -= br - cr;
+    if (bb > cb + EPS) delta.y -= bb - cb;
+  }
+
+  return delta.x === 0 && delta.y === 0 ? void 0 : delta;
+}
+
+/**
+ * 计算将主体四边形约束在容器四边形(带 UV 内边)内所需的一系列修正步骤。
+ *
+ * 在最多 `refineIters` 次迭代中,依次尝试:按轴对齐包围盒做 contain 平移、将越界角点沿容器四边形 UV 拉回(平移有上限)、仍不满足时相对重心等比缩小。
+ * 返回的步骤需按顺序应用;`translate` 的 `x/y` 为平移增量,`scale` 的 `x/y` 为相对重心的缩放系数(当前实现中二者相等)。
+ *
+ * @param corners - 主体四边形顶点(至少 4 点,顺序与 `container` 一致,按平行四边形处理)。
+ * @param container - 容器/视口四边形顶点(至少 4 点)。
+ * @param options - 预留配置(当前未使用)。
+ * @returns 修正步骤数组;若 `corners` 非法(非数组或点数不足)则返回 `undefined`;已满足约束时可能为空数组。
+ */
+export function calcSubjectContainerClampSteps(corners: XY[], container: XY[], options?: {step?: number}) {
+  if (!Array.isArray(corners) || corners.length < 4) return;
+  if (!Array.isArray(container) || container.length < 4) return;
+
+  const result: Array<{ kind: 'translate' | 'scale' } & XY> = [];
+
+  const containerInset = 0.35;
+  const containerNudgeCap = 48;
+  const refineIters = options?.step ?? 16;
+  const shrink = 0.97;
+
+  const { insetU, insetV } = parallelogramWithInset(container, containerInset);
+
+  for (let step = 0; step < refineIters; step++) {
+    if (!subjectViolations(corners, container, insetU, insetV)) break;
+
+    const delta = calcTranslationDelta(makeBoundingBoxFromPoints(corners), makeBoundingBoxFromPoints(container), 'contain');
+    if (delta) {
+      corners = corners.map((p) => ({ x: p.x + delta.x, y: p.y + delta.y }));
+      result.push({ kind: 'translate', ...delta });
+    }
+
+    const pull = subjectPullIntoBackdropQuad(corners, container, { cap: containerNudgeCap, inset: containerInset });
+    if (pull) {
+      corners = corners.map((p) => ({ x: p.x + pull.x, y: p.y + pull.y }));
+      result.push({ kind: 'translate', ...pull });
+    }
+
+    if (!subjectViolations(corners, container, insetU, insetV)) break;
+
+    const centroid = corners.reduce((c, p) => ({ x: c.x + p.x, y: c.y + p.y }), { x: 0, y: 0 });
+    const cx = centroid.x / corners.length;
+    const cy = centroid.y / corners.length;
+    corners = corners.map((p) => ({ x: cx + shrink * (p.x - cx), y: cy + shrink * (p.y - cy) }));
+    result.push({ kind: 'scale', x: shrink, y: shrink });
+  }
+
+  return result;
+}
+
+function parallelogramWithInset(corners: XY[], inset = 0.35) {
+  const [tl, tr, br, bl] = corners;
+  const e1x = tr.x - tl.x;
+  const e1y = tr.y - tl.y;
+  const e2x = bl.x - tl.x;
+  const e2y = bl.y - tl.y;
+  const len1 = Math.hypot(e1x, e1y) || 1;
+  const len2 = Math.hypot(e2x, e2y) || 1;
+  return {
+    tl,
+    tr,
+    br,
+    bl,
+    e1x,
+    e1y,
+    e2x,
+    e2y,
+    insetU: inset / len1,
+    insetV: inset / len2,
+  };
+}
+
+function subjectPullIntoBackdropQuad(corners: XY[], container: XY[], options?: { cap?: number; inset?: number }) {
+  const cap = options?.cap ?? 48;
+  const { e1x, e1y, e2x, e2y, insetV, insetU } = parallelogramWithInset(container, options?.inset);
+
+  let sx = 0;
+  let sy = 0;
+  let n = 0;
+  for (const p of corners) {
+    if (pointInParallelogramInset(container, p, insetU, insetV)) continue;
+    const { u, v } = parallelogramUV(container, p);
+    const u0 = insetU;
+    const u1 = 1 - insetU;
+    const v0 = insetV;
+    const v1 = 1 - insetV;
+    const uc = Math.min(u1, Math.max(u0, u));
+    const vc = Math.min(v1, Math.max(v0, v));
+    const du = uc - u;
+    const dv = vc - v;
+    if (du === 0 && dv === 0) continue;
+    sx += du * e1x + dv * e2x;
+    sy += du * e1y + dv * e2y;
+    n++;
+  }
+  const delta = { x: n === 0 ? 0 : sx / n, y: n === 0 ? 0 : sy / n };
+  const mag = Math.hypot(delta.x, delta.y);
+  if (mag > cap) {
+    const s = cap / mag;
+    delta.x *= s;
+    delta.y *= s;
+  }
+  return delta.x === 0 && delta.y === 0 ? void 0 : delta;
+}
+
+function parallelogramUV(corners: XY[], point: XY): { u: number; v: number } {
+  const [tl, tr, br, bl] = corners;
+  const e1x = tr.x - tl.x;
+  const e1y = tr.y - tl.y;
+  const e2x = bl.x - tl.x;
+  const e2y = bl.y - tl.y;
+  const px = point.x - tl.x;
+  const py = point.y - tl.y;
+  const det = e1x * e2y - e1y * e2x;
+  if (Math.abs(det) < 1e-12) return { u: 0, v: 0 };
+  const u = (px * e2y - py * e2x) / det;
+  const v = (e1x * py - e1y * px) / det;
+  return { u, v };
+}
+
+function pointInParallelogramInset(corners: XY[], point: XY, insetU: number, insetV: number): boolean {
+  const { u, v } = parallelogramUV(corners, point);
+  const u0 = insetU;
+  const u1 = 1 - insetU;
+  const v0 = insetV;
+  const v1 = 1 - insetV;
+  if (!(u0 <= u1 && v0 <= v1)) return u >= 0 && u <= 1 && v >= 0 && v <= 1;
+  return u >= u0 - 1e-9 && u <= u1 + 1e-9 && v >= v0 - 1e-9 && v <= v1 + 1e-9;
+}
+
+function subjectViolations(corners: XY[], container: XY[], backdropInsetU: number, backdropInsetV: number): boolean {
+  for (const p of corners) {
+    if (!pointInParallelogramInset(container, p, backdropInsetU, backdropInsetV)) return true;
+  }
+  return false;
+}

+ 134 - 0
src/modules/monitor/Annotator/composables/useClamp/handlers.ts

@@ -0,0 +1,134 @@
+import { type FabricObject, type ModifiedEvent, Point, Polyline, type TMat2D, util } from 'fabric';
+import type { TransformEvent, UnClamContext } from './types';
+import { calcSubjectContainerClampSteps, calcTranslationDelta } from './geometry';
+import { getAnnotatorObject } from '../../actions';
+
+const { multiplyTransformMatrices, invertTransform, qrDecompose } = util;
+
+/** object:moving 单帧内迭代上限 */
+export const CLAMP_MOVE_ITERS = 12;
+
+/** object:modified 精修迭代上限 */
+export const CLAMP_REFINE_ITERS = 16;
+
+export function clampObjectMoving({ canvas, image }: UnClamContext, event: TransformEvent) {
+  const target = event.target;
+  if (!canvas || !image || !target) return;
+  image.setCoords();
+  const container = image.getBoundingRect();
+  for (let k = 0; k < CLAMP_MOVE_ITERS; k++) {
+    target.setCoords();
+    const delta = calcTranslationDelta(target.getBoundingRect(), container, 'contain');
+    if (!delta) break;
+    target.left += delta.x;
+    target.top += delta.y;
+  }
+
+  if (event && event.transform && event.pointer) {
+    event.transform.offsetX = event.pointer.x - target.left;
+    event.transform.offsetY = event.pointer.y - target.top;
+  }
+}
+export function clampObjectModified({ canvas, image }: UnClamContext, event: ModifiedEvent) {
+  const target = event.target;
+  if (!canvas || !image || !target) return;
+
+  const plan = calcSubjectContainerClampSteps(target.getCoords(), image.getCoords(), { step: CLAMP_REFINE_ITERS });
+  if (!plan) return;
+  for (const { kind, x, y } of plan) {
+    if (kind === 'translate') {
+      target.left += x;
+      target.top += y;
+    } else if (kind === 'scale') {
+      const rawX = target.scaleX ?? 1;
+      const rawY = target.scaleY ?? 1;
+      const flipX = target.flipX ?? false;
+      const flipY = target.flipY ?? false;
+      const scaleX = x * Math.abs(rawX);
+      const scaleY = y * Math.abs(rawY);
+      target.set({
+        scaleX,
+        scaleY,
+        flipX: rawX < 0 ? !flipX : flipX,
+        flipY: rawY < 0 ? !flipY : flipY,
+      });
+    }
+  }
+  target.setCoords();
+  target.dirty = true;
+  canvas.requestRenderAll();
+}
+
+export function clampImageMoving({ canvas, image }: UnClamContext, event?: TransformEvent) {
+  if (!canvas || !image) return;
+  const container = { left: 0, top: 0, width: canvas.width, height: canvas.height };
+
+  for (let k = 0; k < CLAMP_MOVE_ITERS; k++) {
+    image.setCoords();
+    const delta = calcTranslationDelta(image.getBoundingRect(), container, 'contain');
+    if (!delta) break;
+    image.left += delta.x;
+    image.top += delta.y;
+  }
+
+  if (event && event.transform && event.pointer) {
+    event.transform.offsetX = event.pointer.x - image.left;
+    event.transform.offsetY = event.pointer.y - image.top;
+  }
+}
+
+export const clampObjectMatrix = ({ canvas, image }: UnClamContext, snapshot: WeakMap<FabricObject, TMat2D>) => {
+  if (!canvas || !image) return;
+  const from = snapshot.get(image);
+  const to = image.calcTransformMatrix();
+  if (!from || from.every((value, index) => Math.abs(value - to[index]!) <= 1e-6)) return;
+
+  const delta = multiplyTransformMatrices(to, invertTransform(from));
+  for (const object of getAnnotatorObject(canvas, [image])) applyTransformDeltaToObject(object, delta);
+
+  snapshot.set(image, to);
+};
+
+/**
+ * Polyline/Polygon:将顶点变到画布坐标后左乘 delta,再写回点集并 setBoundingBox,与 Rect 等「烘焙」思路一致。
+ */
+function applyTransformDeltaToPolyline(object: Polyline, delta: TMat2D) {
+  const pathOffset = object.pathOffset ?? new Point(0, 0);
+  const matrix = object.calcTransformMatrix();
+  const points = object.points.map((p) => {
+    const local = new Point(p.x - pathOffset.x, p.y - pathOffset.y);
+    const world = local.transform(matrix);
+    return world.transform(delta);
+  });
+
+  object.set({
+    points: points.map((p) => ({ x: p.x, y: p.y })),
+    scaleX: 1,
+    scaleY: 1,
+    angle: 0,
+    skewX: 0,
+    skewY: 0,
+    flipX: false,
+    flipY: false,
+  });
+  object.setBoundingBox(true);
+  object.setCoords();
+  object.dirty = true;
+}
+
+/**
+ * 将增量变换矩阵应用到对象,并把结果回写为对象属性。
+ *
+ * 适用于 image 前后矩阵差驱动的对象联动场景。
+ */
+function applyTransformDeltaToObject(object: FabricObject, delta: TMat2D) {
+  if (object instanceof Polyline) return applyTransformDeltaToPolyline(object, delta);
+
+  const composed = multiplyTransformMatrices(delta, object.calcTransformMatrix());
+  const { translateX, translateY, ...decompose } = qrDecompose(composed);
+  const center = new Point(translateX, translateY);
+  object.set(decompose);
+  object.setXY(center);
+  object.setCoords();
+  object.dirty = true;
+}

+ 1 - 0
src/modules/monitor/Annotator/composables/useClamp/index.ts

@@ -0,0 +1 @@
+export { useClamp } from './useClamp';

+ 10 - 0
src/modules/monitor/Annotator/composables/useClamp/types.ts

@@ -0,0 +1,10 @@
+import type { FabricContext, UnwrapperContext } from '@/lib/fabric';
+import type { MaybeRef } from 'vue';
+import type { BasicTransformEvent, FabricImage, FabricObject } from 'fabric';
+
+export interface ClampContext extends FabricContext {
+  image: MaybeRef<FabricImage | null>;
+}
+
+export type UnClamContext = UnwrapperContext<ClampContext>;
+export type TransformEvent = BasicTransformEvent & { target?: FabricObject };

+ 56 - 0
src/modules/monitor/Annotator/composables/useClamp/useClamp.ts

@@ -0,0 +1,56 @@
+import type { FabricObject, TMat2D } from 'fabric';
+import type { ClampContext } from './types';
+
+import { bindContext, useFabricEventListener } from '@/lib/fabric';
+import { scaleObjectToContainer } from '@/lib/fabric/actions';
+import { isShapeBrush } from '@/lib/fabric/brush';
+import { clampImageMoving, clampObjectMatrix, clampObjectModified, clampObjectMoving } from './handlers';
+
+export function useClamp(context: ClampContext, update: any) {
+  const bind = bindContext(context);
+  const snapshot = new WeakMap<FabricObject, TMat2D>();
+
+  useFabricEventListener(context.image, 'added', () =>
+    bind()(({ canvas, image }) => {
+      if (!canvas || !image) return;
+      scaleObjectToContainer(image, { width: canvas.width, height: canvas.height, center: canvas.getCenterPoint() }, 'contain');
+      image.setControlsVisibility({ mt: false, mb: false, mr: false, ml: false, mtr: false });
+      canvas.requestRenderAll();
+      update();
+    })
+  );
+
+  useFabricEventListener(context.image, ['mousedown', 'snapshot:start'], (event) => {
+    if (event.target) snapshot.set(event.target, event.target.calcTransformMatrix());
+  });
+  useFabricEventListener(context.image, ['mouseup', 'snapshot:end'], (event) => {
+    if (event.target) snapshot.delete(event.target);
+  });
+
+  useFabricEventListener(context.canvas, 'object:moving', (event) =>
+    bind(event)((ctx, event) => {
+      if (event.target === ctx.image) clampObjectMatrix(ctx, snapshot);
+      else clampObjectMoving(ctx, event);
+    })
+  );
+  useFabricEventListener(context.canvas, 'object:scaling', (event) =>
+    bind(event)((ctx, event) => {
+      if (event.target === ctx.image) clampObjectMatrix(ctx, snapshot);
+    })
+  );
+  useFabricEventListener(context.canvas, 'object:modified', (event) =>
+    bind(event)((ctx, event) => {
+      if (event.target === ctx.image) {
+        clampImageMoving(ctx);
+        clampObjectMatrix(ctx, snapshot);
+      } else clampObjectModified(ctx, event);
+    })
+  );
+
+  useFabricEventListener(context.canvas, 'brush:start', (event) =>
+    bind(event)(({ canvas, image }, event) => {
+      if (!canvas || !isShapeBrush(canvas.freeDrawingBrush)) return;
+      canvas.freeDrawingBrush.limited = image;
+    })
+  );
+}

+ 116 - 0
src/modules/monitor/Annotator/composables/usePreview.ts

@@ -0,0 +1,116 @@
+import type { MaybeRef } from 'vue';
+import type { FabricImage, FabricObject } from 'fabric';
+import type { FabricContext, UnwrapperContext } from '@/lib/fabric';
+import type { AnnotatorObject } from '../index';
+import { bindContext, useFabricEventListener } from '@/lib/fabric';
+import { getAnnotatorObject, getAnnotatorTitle } from '../actions';
+
+interface PreviewContext extends FabricContext {
+  previewable: MaybeRef<boolean | void>;
+  image: MaybeRef<FabricImage | null>;
+  annotator: (object: AnnotatorObject) => Promise<boolean> | boolean;
+}
+type UnContext = UnwrapperContext<PreviewContext>;
+
+interface AnnotatorProps {
+  uid: string;
+  title: string;
+  object: AnnotatorObject;
+}
+
+export function usePreview(context: PreviewContext) {
+  const list = shallowRef<AnnotatorProps[]>([]);
+
+  useFabricEventListener(context.canvas, ['object:added', 'object:removed'], (event) => bind()(update));
+
+  const bind = bindContext(context);
+
+  const update = ({ canvas, image }: UnContext) => {
+    if (!canvas || !image) return;
+
+    list.value.length = 0;
+    for (const object of getAnnotatorObject(canvas, [image])) {
+      const uid = object.id;
+      const title = getAnnotatorTitle(object as AnnotatorObject);
+      list.value.push({ uid, title, object });
+    }
+    triggerRef(list);
+  };
+
+  const toggleVisible = (item: AnnotatorProps) => {
+    const object = item.object;
+    const canvas = toValue(context.canvas);
+    if (!canvas || !object) return;
+    object.toggle('visible');
+    canvas.requestRenderAll();
+    triggerRef(list);
+  };
+
+  const deleteObject = (item: AnnotatorProps) => {
+    const object = item.object;
+    const canvas = toValue(context.canvas);
+    if (!canvas || !object) return;
+    showConfirmDialog({
+      title: `确定删除 ${item.title} 标注对象?`,
+      confirmButtonText: '删除',
+    }).then(() => {
+      canvas.remove(object);
+      canvas.requestRenderAll();
+    });
+    object.toggle('visible');
+    triggerRef(list);
+  };
+
+  const editObject = (item: AnnotatorProps) => {
+    context.annotator(item.object);
+  }
+
+  const highlightTimers = new WeakMap<FabricObject, ReturnType<typeof setInterval>>();
+  const highlightEnds = new WeakMap<FabricObject, ReturnType<typeof setTimeout>>();
+  const highlight = new WeakMap<FabricObject, boolean>();
+  const highlightObject = (item: AnnotatorProps) => {
+    const object = item.object;
+    const canvas = toValue(context.canvas);
+    if (!canvas || !object) return;
+
+    if (highlight.get(object)) return;
+    highlight.set(object, true);
+
+    // 清理同一对象上一次高亮,避免叠加
+    const prevTimer = highlightTimers.get(object);
+    if (prevTimer) clearInterval(prevTimer);
+    const prevEnd = highlightEnds.get(object);
+    if (prevEnd) clearTimeout(prevEnd);
+    const originStrokeWidth = object.strokeWidth ?? 1;
+    const originOpacity = object.opacity ?? 1;
+    const originVisible = object.visible ?? true;
+    const duration = 1200; // 持续时间
+    const intervalMs = 140; // 脉冲频率
+    let pulse = false;
+    object.set({ visible: true }); // 避免隐藏状态看不到高亮
+    const timer = setInterval(() => {
+      pulse = !pulse;
+      object.set({
+        strokeWidth: pulse ? originStrokeWidth + 2 : originStrokeWidth,
+        opacity: pulse ? 1 : Math.max(0.6, originOpacity - 0.25),
+      });
+      canvas.requestRenderAll();
+    }, intervalMs);
+    const end = setTimeout(() => {
+      clearInterval(timer);
+      object.set({
+        strokeWidth: originStrokeWidth,
+        opacity: originOpacity,
+        visible: originVisible,
+      });
+      canvas.requestRenderAll();
+      highlightTimers.delete(object);
+      highlightEnds.delete(object);
+      highlight.delete(object);
+    }, duration);
+    highlightTimers.set(object, timer);
+    highlightEnds.set(object, end);
+  };
+
+  return [list, { update, toggleVisible, editObject, deleteObject, highlightObject }] as const;
+}

+ 70 - 0
src/modules/monitor/Annotator/composables/useShape.ts

@@ -0,0 +1,70 @@
+import type { MaybeRef } from 'vue';
+import type { AnnotatorObject } from '../index';
+import type { FabricContext } from '@/lib/fabric';
+import type { Shape } from '@/lib/fabric/brush';
+import { useFabricEventListener } from '@/lib/fabric';
+import { createShapeBrush, isShapeBrush } from '@/lib/fabric/brush';
+
+interface ShapeContext extends FabricContext {
+  selectable: MaybeRef<boolean | void>;
+  shape: MaybeRef<Shape | null>;
+
+  annotator: (object: AnnotatorObject) => Promise<boolean> | boolean;
+}
+
+export function useShape(context: ShapeContext) {
+  const canvas = computed(() => toValue(context.canvas));
+  const shape = computed(() => toValue(context.shape));
+
+  watch(
+    [canvas, shape],
+    ([canvas, shape], oldValue, onCleanup) => {
+      if (!canvas) return;
+
+      canvas.freeDrawingBrush = shape ? createShapeBrush(canvas, shape) : void 0;
+      if (canvas.freeDrawingBrush) canvas.fire('brush:start', { shape: shape ?? void 0 });
+      else canvas.fire('brush:end', { shape: oldValue[1] ?? void 0 });
+
+      onCleanup(() => {
+        if (isShapeBrush(canvas.freeDrawingBrush)) canvas.freeDrawingBrush.destroy();
+      });
+    },
+    { immediate: true, flush: 'post' }
+  );
+
+  useFabricEventListener(canvas, 'brush:start', (event) => {
+    const canvas = toValue(context.canvas);
+    if (!canvas) return;
+    canvas.isDrawingMode = true;
+    canvas.freeDrawingBrush!.color = `#38ff6d`;
+
+    if (isRef(context.selectable)) context.selectable.value = false;
+  });
+  useFabricEventListener(canvas, 'brush:end', () => {
+    const canvas = toValue(context.canvas);
+    if (!canvas) return;
+    canvas.freeDrawingBrush = void 0;
+    canvas.isDrawingMode = false;
+  });
+  useFabricEventListener(canvas, 'before:object:created', (event) => {
+    const box = event.object;
+
+    box.selectable = !!toValue(context.selectable);
+    box.lockRotation = true;
+    box.setControlVisible('mtr', false);
+
+    const canvas = toValue(context.canvas);
+    if (toValue(context.selectable)) canvas?.setActiveObject(event.object);
+
+    context.annotator(box as AnnotatorObject);
+  });
+  useFabricEventListener(canvas, 'object:created', (event) => {
+    const box = event.object;
+    if (box.visible) {
+      const canvas = toValue(context.canvas);
+      canvas?.bringObjectToFront(event.object);
+    } else if (box.canvas) {
+      box.canvas.remove(box);
+    }
+  });
+}

+ 170 - 0
src/modules/monitor/Annotator/composables/useTools.ts

@@ -0,0 +1,170 @@
+import type { MaybeRef } from 'vue';
+import type { FabricImage } from 'fabric';
+import type { FabricContext, UnwrapperContext } from '@/lib/fabric';
+import type { Shape } from '@/lib/fabric/brush';
+import type { ScaleToContainerMode } from '@/lib/fabric/actions';
+import type { ToolbarTool } from '@/lib/fabric/components/toolbar';
+
+import { showFailToast } from 'vant';
+import { bindContext } from '@/lib/fabric';
+import { scaleObject, scaleObjectToContainer } from '@/lib/fabric/actions';
+import { injectFloatPanelContext } from '@/composables/FloatPanel';
+import { getAnnotatorObject } from '../actions';
+
+interface ToolContext extends FabricContext {
+  previewable: MaybeRef<boolean | void>;
+  selectable: MaybeRef<boolean | void>;
+  shape: MaybeRef<Shape | null>;
+  image: MaybeRef<FabricImage | null>;
+}
+
+export type UnContext = UnwrapperContext<ToolContext>;
+
+export function useTools(context: ToolContext, complete: any) {
+  const shape = computed(() => toValue(context.shape) ?? void 0);
+  const selectable = computed(() => toValue(context.selectable) ?? void 0);
+  const tools = computed<ToolbarTool[]>(() => [
+    {
+      type: 'radio-group',
+      key: 'operate',
+      selectedKey: computed(() => (selectable.value ? 'select' : 'move')),
+      options: [
+        { key: 'select', title: '选择', icon: 'mdi:cursor-default-click-outline' },
+        { key: 'move', title: '移动', icon: 'mdi:cursor-move' },
+      ],
+      action(payload) {
+        if (isRef(context.shape)) context.shape.value = null;
+        if (isRef(context.selectable)) context.selectable.value = payload.value === 'select';
+        if (payload.value === payload.oldValue) bind(payload.value === 'select')(updateObjectStatus);
+      },
+    },
+    {
+      type: 'dropdown-button',
+      key: 'shape',
+      label: '标注框',
+      icon: 'mdi:crop',
+      selectedKey: shape,
+      options: [
+        { key: 'rect', label: '矩形', title: '矩形标注框', icon: 'mdi:rectangle-outline' },
+        { key: 'circle', label: '圆形', title: '圆形标注框', icon: 'mdi:circle-outline' },
+        { key: 'ellipse', label: '椭圆', title: '椭圆标注框', icon: 'mdi:ellipse-outline' },
+        { key: 'polygon', label: '多边形', title: '多边形标注', icon: 'mdi:shape-polygon-plus' },
+      ],
+      action(payload) {
+        if (isRef(context.shape)) context.shape.value = payload.value as Shape;
+      },
+      allowClear: true,
+    },
+    {
+      type: 'button',
+      key: 'zoom-in',
+      label: '放大',
+      icon: 'mdi:zoom-in',
+      params: { factor: 1.25 },
+      action(payload) {
+        const params = payload.params as any;
+        if (params?.factor) bind(params.factor)(setImageScale);
+      },
+    },
+    {
+      type: 'button',
+      key: 'zoom-out',
+      label: '缩小',
+      icon: 'mdi:zoom-out',
+      params: { factor: 0.8 },
+      action(payload) {
+        const params = payload.params as any;
+        if (params?.factor) bind(params.factor)(setImageScale);
+      },
+    },
+    {
+      type: 'dropdown-button',
+      key: 'fit',
+      label: '缩放',
+      icon: 'mdi:image-filter-center-focus-weak',
+      options: [
+        // { key: 'zoom-in', label: '放大', icon: 'mdi:zoom-in', params: { factor: 1.25 } },
+        // { key: 'zoom-out', label: '缩小', icon: 'mdi:zoom-out', params: { factor: 0.8 } },
+        { key: 'fit-centre', label: '适应画布', icon: 'mdi:fit-to-page-outline', params: { factor: 'cover' } },
+        { key: 'fit-width', label: '水平适配', icon: 'mdi:arrow-expand-horizontal', params: { factor: 'width' } },
+        { key: 'fit-height', label: '垂直适配', icon: 'mdi:arrow-expand-vertical', params: { factor: 'height' } },
+      ],
+      action(payload) {
+        const params = payload.tool.options.find((option) => option.key === payload.value)?.params as any;
+        if (params?.factor) bind(params.factor)(setImageScale);
+      },
+    },
+    {
+      type: 'toggle',
+      key: 'layer',
+      label: '标注',
+      side: 'right',
+      icon: 'mdi:layers-outline',
+      active: computed(() => toValue(context.previewable) ?? false),
+      action: () => bind()(preview),
+    },
+    {
+      type: 'button',
+      key: 'export',
+      label: '完成',
+      icon: 'mdi:check-circle-outline',
+      side: 'right',
+      style: {
+        '--toolbar-color-hover-bg': '#f0f9ff',
+        '--toolbar-color-control-bg': '#faf5ff',
+        '--toolbar-color-control-border': '#a78bfa',
+        '--toolbar-color-text': '#6d28d9',
+      },
+      action: complete,
+    },
+  ]);
+
+  const bind = bindContext(context);
+  const setImageScale = ({ canvas, image }: UnContext, factor: number | ScaleToContainerMode) => {
+    if (!canvas || !image) return;
+    image.fire('snapshot:start', { target: image });
+
+    const center = canvas.getCenterPoint();
+    const width = canvas.getWidth();
+    const height = canvas.getHeight();
+    if (typeof factor === 'string') scaleObjectToContainer(image, { width, height, center }, factor);
+    else scaleObject(image, factor, { center, uniform: true });
+
+    image.fire('modified', { action: 'tool', target: image });
+    canvas.fire('object:modified', { action: 'tool', target: image });
+    canvas.requestRenderAll();
+  };
+
+  const panelContext = injectFloatPanelContext();
+  const preview = ({ canvas, image }: UnContext) => {
+    if (!canvas || !image) return;
+    const objects = getAnnotatorObject(canvas, [image]);
+
+    if (objects.length) {
+      if (panelContext.value) {
+        const full = panelContext.value.snapFull();
+        if (isRef(context.previewable)) context.previewable.value = full;
+        if (!full) {
+          const anchors = panelContext.value.getAnchors();
+          panelContext.value.setHeight(anchors[1], false);
+        }
+      }
+    } else {
+      showFailToast('请使用标注框工具添加');
+    }
+  };
+  const updateObjectStatus = ({ canvas, image }: UnContext, selectable: boolean) => {
+    if (!canvas || !image) return;
+    for (const object of getAnnotatorObject(canvas, [image])) {
+      object.selectable = selectable;
+      // object.evented = selectable;
+    }
+    if (selectable) canvas.discardActiveObject();
+    else canvas.setActiveObject(image);
+    canvas.requestRenderAll();
+  };
+
+  watch(selectable, (value) => bind(!!value)(updateObjectStatus));
+
+  return tools;
+}

+ 15 - 0
src/modules/monitor/Annotator/index.ts

@@ -0,0 +1,15 @@
+import type { FabricObject } from 'fabric';
+
+export { default as Annotator } from './Annotator.vue';
+
+export interface AnnotatorObject extends FabricObject {
+  annotatorId?: string;
+  src?: string;
+}
+
+export type AnnotatorFn = (object: AnnotatorObject) => Promise<boolean | string> | boolean | string;
+
+export interface AnnotatorExportObject {
+  annotator: AnnotatorObject[];
+  relation: Record<NonNullable<AnnotatorObject['src']>, AnnotatorObject['id'][]>;
+}

+ 70 - 0
src/modules/monitor/Cropper/Cropper.vue

@@ -0,0 +1,70 @@
+<script setup lang="ts">
+import type { Shape } from '@/lib/fabric/brush';
+import { showFailToast, showLoadingToast } from 'vant';
+import { useFabric, useToolbar, useImage, withContext } from '@/lib/fabric';
+import { exportData, type CropperExportObject } from './actions';
+import { useOverlay } from './composables/useOverlay';
+import { useClamp } from './composables/useClamp';
+import { useShape } from './composables/useShape';
+import { useTools } from './composables/useTools';
+
+interface Props {
+  title?: string;
+  upload(blob: Blob, name?: string): Promise<string>;
+}
+interface Emits {
+  complete: [CropperExportObject];
+  cancel: [];
+}
+const props = defineProps<Props>();
+const emits = defineEmits<Emits>();
+const url = defineModel('url', { default: '', required: false });
+
+const [Fabric, fabric] = useFabric({ params: { selection: false, renderOnAddRemove: false } });
+
+const shape = ref<Shape | null>(null);
+const image = useImage(withContext(fabric, { url }));
+const box = useShape(withContext(fabric, { shape }));
+const overlay = useOverlay(withContext(fabric, { box }));
+useClamp(withContext(fabric, { image, box, overlay }));
+
+const tools = useTools(withContext(fabric, { image, box, overlay, shape }), crop);
+const [Toolbar] = useToolbar({ params: { tools } });
+
+async function crop() {
+  const canvas = toValue(fabric.canvas);
+  if (!canvas || !image.value) return;
+  const toast = showLoadingToast({
+    message: box.value ? '裁剪中...' : '上传中...',
+    duration: 0,
+    forbidClick: true,
+  });
+  try {
+    const { blob, object } = await exportData(canvas, image.value, box.value, { format: 'png' });
+    object.src = await props.upload(blob, props.title);
+    url.value = URL.createObjectURL(blob);
+    emits('complete', object);
+  } catch {
+    showFailToast(`裁剪图片失败`);
+  } finally {
+    toast.close();
+  }
+}
+</script>
+
+<template>
+  <div class="cropper-wrapper">
+    <Toolbar />
+    <div class="content">
+      <Fabric />
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.content {
+  $size: clamp(300px, 100vw, 100vmin);
+  width: $size;
+  height: $size;
+}
+</style>

+ 79 - 0
src/modules/monitor/Cropper/actions/export.ts

@@ -0,0 +1,79 @@
+import type { Canvas, FabricImage, FabricObject, XY } from 'fabric';
+import type { BlobOptions } from '@/lib/fabric/common';
+import { pick } from 'es-toolkit';
+import { util } from 'fabric';
+import { getObjectMultiplier, getBlob, getDecompose, calcCoords } from '@/lib/fabric/common';
+import { toClipPathD } from './path';
+
+const { createScaleMatrix, createTranslateMatrix, multiplyTransformMatrices } = util;
+
+export interface ExportDataOptions extends BlobOptions {}
+export interface ExportData {
+  blob: Blob;
+  object: CropperExportObject;
+}
+export interface CropperExportObject {
+  left: number;
+  top: number;
+  width: number;
+  height: number;
+  angle: number;
+  scaleX: number;
+  scaleY: number;
+  points: XY[];
+  src: string;
+}
+
+/**
+ * 导出数据
+ * @param canvas
+ * @param image
+ * @param box
+ * @param options
+ */
+export async function exportData(canvas: Canvas, image: FabricImage, box: FabricObject | null, options?: ExportDataOptions): Promise<ExportData> {
+  const multiplier = getObjectMultiplier(image);
+
+  const target = box ?? image;
+  const bounds = getBounds(target);
+  const before = (canvas: HTMLCanvasElement) => {
+    const context = canvas.getContext('2d');
+    const path = box ? toClipPathD(box) : '';
+    if (context && path) {
+      context.save();
+      context.globalCompositeOperation = 'destination-in';
+      context.scale(multiplier, multiplier);
+      context.translate(-bounds.left, -bounds.top);
+      context.fillStyle = '#000000';
+      try {
+        context.fill(new Path2D(path));
+      } catch {
+      } finally {
+        context.restore();
+      }
+    }
+  };
+  try {
+    const blob = await getBlob(canvas, { ...bounds, ...options, multiplier, filter: (object) => object === image, before });
+    // 0. 提取当前中心点
+    const center = target.getCenterPoint();
+    // 1. 获取对象当前世界变换矩阵
+    const objectMatrix = target.calcTransformMatrix();
+    // 2. 平移: 让中心归零 T_inv: 将中心移到原点
+    const translateMatrix = createTranslateMatrix(-center.x, -center.y);
+    // 3. 缩放
+    const scaleMatrix = createScaleMatrix(multiplier, multiplier);
+    // 4. 组合: 先平移归零,再缩放 scaleMatrix * translateToOrigin * matrix
+    const centered = multiplyTransformMatrices(translateMatrix, objectMatrix);
+    const relativeMatrix = multiplyTransformMatrices(scaleMatrix, centered);
+    const data = getDecompose(target, relativeMatrix, { absorbScale: true });
+    data.points ??= calcCoords(data);
+    return { blob, object: pick(data, ['left', 'top', 'width', 'height', 'angle', 'scaleX', 'scaleY', 'points', 'src']) };
+  } finally {
+  }
+}
+
+function getBounds(object: FabricObject) {
+  object.setCoords();
+  return object.getBoundingRect();
+}

+ 3 - 0
src/modules/monitor/Cropper/actions/index.ts

@@ -0,0 +1,3 @@
+export * from '@/lib/fabric/actions'
+export * from './path'
+export * from './export'

+ 74 - 0
src/modules/monitor/Cropper/actions/path.ts

@@ -0,0 +1,74 @@
+import { Circle, Ellipse, Point, Polygon, Rect, StaticCanvas } from 'fabric';
+
+/** 将画布视口或 Fabric 对象转为场景坐标下的 SVG path `d`(与遮罩/叠加层一致)。 */
+export function toClipPathD(object: any): string {
+  if (object instanceof StaticCanvas) {
+    const { tl, tr, br, bl } = object.calcViewportBoundaries();
+    return `M ${tl.x} ${tl.y} L ${tr.x} ${tr.y} L ${br.x} ${br.y} L ${bl.x} ${bl.y} Z`;
+  }
+  if (object instanceof Rect || object.type === 'rect') {
+    const { tl, tr, br, bl } = object.calcACoords();
+    return `M ${tl.x} ${tl.y} L ${tr.x} ${tr.y} L ${br.x} ${br.y} L ${bl.x} ${bl.y} Z`;
+  }
+  if (object instanceof Ellipse || object.type === 'ellipse') {
+    const localRx = object.get('rx') ?? 0;
+    const localRy = object.get('ry') ?? 0;
+    if (localRx <= 0 || localRy <= 0) return '';
+
+    const center = object.getCenterPoint();
+    const scale = object.getObjectScaling();
+    const rx = Math.abs(localRx * scale.x);
+    const ry = Math.abs(localRy * scale.y);
+    if (rx <= 0 || ry <= 0) return '';
+
+    const rotation = object.getTotalAngle();
+    const phi = (rotation * Math.PI) / 180;
+    const cos = Math.cos(phi);
+    const sin = Math.sin(phi);
+
+    const startX = center.x - rx * cos;
+    const startY = center.y - rx * sin;
+    const endX = center.x + rx * cos;
+    const endY = center.y + rx * sin;
+
+    return [`M ${startX} ${startY}`, `A ${rx} ${ry} ${rotation} 0 1 ${endX} ${endY}`, `A ${rx} ${ry} ${rotation} 0 1 ${startX} ${startY}`, 'Z'].join(' ');
+  }
+  if (object instanceof Circle || object.type === 'circle') {
+    const r = object.get('radius') ?? 0;
+    if (r <= 0) return '';
+
+    const center = object.getCenterPoint();
+    const scale = object.getObjectScaling();
+    const rx = Math.abs(r * scale.x);
+    const ry = Math.abs(r * scale.y);
+    if (rx <= 0 || ry <= 0) return '';
+
+    const rotation = object.getTotalAngle();
+    const phi = (rotation * Math.PI) / 180;
+    const cos = Math.cos(phi);
+    const sin = Math.sin(phi);
+
+    const startX = center.x - rx * cos;
+    const startY = center.y - rx * sin;
+    const endX = center.x + rx * cos;
+    const endY = center.y + rx * sin;
+
+    return [`M ${startX} ${startY}`, `A ${rx} ${ry} ${rotation} 0 1 ${endX} ${endY}`, `A ${rx} ${ry} ${rotation} 0 1 ${startX} ${startY}`, 'Z'].join(' ');
+  }
+  if (object instanceof Polygon || object.type === 'polygon') {
+    const points = (object.get('points') ?? []) as Array<{ x: number; y: number }>;
+    if (points.length < 3) return '';
+
+    const matrix = object.calcTransformMatrix();
+    const offset = object.pathOffset ?? new Point(0, 0);
+    const transformed = points.map((p: { x: number; y: number }) => {
+      const local = new Point(p.x - offset.x, p.y - offset.y);
+      return local.transform(matrix);
+    });
+
+    const [first, ...rest] = transformed;
+    if (!first) return '';
+    return [`M ${first.x} ${first.y}`, ...rest.map((p) => `L ${p.x} ${p.y}`), 'Z'].join(' ');
+  }
+  return '';
+}

+ 223 - 0
src/modules/monitor/Cropper/composables/useClamp/clampApply.ts

@@ -0,0 +1,223 @@
+import type { BasicTransformEvent, FabricImage, FabricObject } from 'fabric';
+import { Point, Polyline, util } from 'fabric';
+import type { Aabb } from './clampMath';
+import {
+  aabbClampDelta,
+  aabbInnerFullyInOuter,
+  aabbIntersection,
+  minimalTranslationToPutCenterInViewport,
+  pointInViewport,
+  translationForImageBoxAndViewport,
+  viewportAabb,
+} from './clampMath';
+
+const { transformPoint } = util;
+
+function toAabb(o: ReturnType<FabricObject['getBoundingRect']>): Aabb {
+  return { left: o.left, top: o.top, width: o.width, height: o.height };
+}
+
+/** 裁剪框合法区域:底图 AABB 与视口的交集(轴对齐近似) */
+function boxClampOuter(image: FabricImage, cw: number, ch: number): Aabb {
+  image.setCoords();
+  const inter = aabbIntersection(toAabb(image.getBoundingRect()), viewportAabb(cw, ch));
+  if (inter) return inter;
+  return viewportAabb(cw, ch);
+}
+
+export function syncTransformOffset(event: BasicTransformEvent, target: FabricObject): void {
+  const p = event.pointer;
+  event.transform.offsetX = p.x - target.left!;
+  event.transform.offsetY = p.y - target.top!;
+}
+
+export function getBoxCanvasVertices(box: FabricObject): Point[] {
+  box.setCoords();
+  if (box instanceof Polyline && box.points?.length) {
+    const m = box.calcTransformMatrix();
+    return box.points.map((pt) => transformPoint(new Point(pt.x - (box.pathOffset?.x ?? 0), pt.y - (box.pathOffset?.y ?? 0)), m));
+  }
+  const { tl, tr, br, bl } = box.aCoords;
+  return [tl, tr, br, bl];
+}
+
+export function isScalingCompliantAabb(image: FabricImage, box: FabricObject | null, cw: number, ch: number): boolean {
+  image.setCoords();
+  const c = image.getCenterPoint();
+  if (!pointInViewport(c, cw, ch)) return false;
+  if (!box) return true;
+  box.setCoords();
+  const outer = boxClampOuter(image, cw, ch);
+  return aabbInnerFullyInOuter(toAabb(box.getBoundingRect()), outer);
+}
+
+export function isBoxVerticesInsideImageAndViewport(box: FabricObject, image: FabricImage, cw: number, ch: number): boolean {
+  image.setCoords();
+  box.setCoords();
+  for (const p of getBoxCanvasVertices(box)) {
+    if (!pointInViewport(p, cw, ch) || !image.containsPoint(p)) return false;
+  }
+  return true;
+}
+
+const MAX_MOVE_ITER = 8;
+const MAX_RESOLVE_ITER = 12;
+/** modified:先反复平移再考虑缩放 */
+const MAX_TRANSLATION_SUBSTEPS = 24;
+
+/** 拖动 box:AABB 在 (image ∩ 视口) 内 + 同步 offset */
+export function clampBoxMoving(event: BasicTransformEvent, box: FabricObject, image: FabricImage, cw: number, ch: number): void {
+  for (let i = 0; i < MAX_MOVE_ITER; i++) {
+    box.setCoords();
+    image.setCoords();
+    const inner = toAabb(box.getBoundingRect());
+    const outer = boxClampOuter(image, cw, ch);
+    if (aabbInnerFullyInOuter(inner, outer)) break;
+    const d = aabbClampDelta(inner, outer);
+    if (d.x === 0 && d.y === 0) break;
+    box.set({ left: box.left! + d.x, top: box.top! + d.y });
+    box.setCoords();
+    syncTransformOffset(event, box);
+  }
+}
+
+/** 拖动 image:中心在视口 + box AABB ⊆ (image AABB ∩ 视口) */
+export function clampImageMoving(event: BasicTransformEvent, image: FabricImage, box: FabricObject | null, cw: number, ch: number): void {
+  for (let i = 0; i < MAX_MOVE_ITER; i++) {
+    image.setCoords();
+    const c = image.getCenterPoint();
+    let moved = false;
+
+    const vp = minimalTranslationToPutCenterInViewport(c.x, c.y, cw, ch);
+    if (vp.x !== 0 || vp.y !== 0) {
+      image.setPositionByOrigin(new Point(c.x + vp.x, c.y + vp.y), 'center', 'center');
+      image.setCoords();
+      syncTransformOffset(event, image);
+      moved = true;
+    }
+
+    if (!box) {
+      if (!moved) break;
+      continue;
+    }
+
+    box.setCoords();
+    image.setCoords();
+    const bb = toAabb(box.getBoundingRect());
+    const outer = boxClampOuter(image, cw, ch);
+    if (aabbInnerFullyInOuter(bb, outer)) {
+      if (!moved) break;
+      continue;
+    }
+
+    const ib = toAabb(image.getBoundingRect());
+    const c2 = image.getCenterPoint();
+    const t = translationForImageBoxAndViewport(ib, bb, { x: c2.x, y: c2.y }, cw, ch);
+    if (t.x !== 0 || t.y !== 0) {
+      image.setPositionByOrigin(new Point(c2.x + t.x, c2.y + t.y), 'center', 'center');
+      image.setCoords();
+      syncTransformOffset(event, image);
+    } else if (!moved) {
+      break;
+    }
+  }
+}
+
+function applyUniformScale(object: FabricObject, factor: number): void {
+  const sx = (object.scaleX ?? 1) * factor;
+  const sy = (object.scaleY ?? 1) * factor;
+  object.set({ scaleX: sx, scaleY: sy });
+  object.setCoords();
+}
+
+/**
+ * 仅通过平移 box 使落入合法区;平移用尽仍不合规则返回 false。
+ */
+function resolveBoxByTranslationOnly(box: FabricObject, image: FabricImage, cw: number, ch: number): boolean {
+  for (let s = 0; s < MAX_TRANSLATION_SUBSTEPS; s++) {
+    if (isBoxVerticesInsideImageAndViewport(box, image, cw, ch)) return true;
+    box.setCoords();
+    image.setCoords();
+    const inner = toAabb(box.getBoundingRect());
+    const outer = boxClampOuter(image, cw, ch);
+    const d = aabbClampDelta(inner, outer);
+    if (d.x === 0 && d.y === 0) return false;
+    box.set({ left: box.left! + d.x, top: box.top! + d.y });
+    box.setCoords();
+  }
+  return isBoxVerticesInsideImageAndViewport(box, image, cw, ch);
+}
+
+/** modified:先平移 box,平移无法放入交集(或旋转导致顶点仍出界)再缩小 box */
+export function resolveBoxInsideImageAndViewportShape(box: FabricObject, image: FabricImage, cw: number, ch: number): void {
+  for (let i = 0; i < MAX_RESOLVE_ITER; i++) {
+    if (resolveBoxByTranslationOnly(box, image, cw, ch)) return;
+
+    box.setCoords();
+    image.setCoords();
+    const inner = toAabb(box.getBoundingRect());
+    const outer = boxClampOuter(image, cw, ch);
+    const tooLargeForOuter = inner.width > outer.width || inner.height > outer.height;
+    applyUniformScale(box, tooLargeForOuter ? 0.92 : 0.96);
+  }
+}
+
+/**
+ * 仅通过平移 image(中心回视口 + 兼顾 box)修正;两步平移在一轮内交替直到不动。
+ */
+function resolveImageByTranslationOnly(box: FabricObject, image: FabricImage, cw: number, ch: number): boolean {
+  for (let s = 0; s < MAX_TRANSLATION_SUBSTEPS; s++) {
+    if (isBoxVerticesInsideImageAndViewport(box, image, cw, ch)) return true;
+
+    image.setCoords();
+    let moved = false;
+    const c0 = image.getCenterPoint();
+    const vp = minimalTranslationToPutCenterInViewport(c0.x, c0.y, cw, ch);
+    if (vp.x !== 0 || vp.y !== 0) {
+      image.setPositionByOrigin(new Point(c0.x + vp.x, c0.y + vp.y), 'center', 'center');
+      image.setCoords();
+      moved = true;
+    }
+
+    image.setCoords();
+    box.setCoords();
+    const ib = toAabb(image.getBoundingRect());
+    const bb = toAabb(box.getBoundingRect());
+    const c2 = image.getCenterPoint();
+    const t = translationForImageBoxAndViewport(ib, bb, { x: c2.x, y: c2.y }, cw, ch);
+    if (t.x !== 0 || t.y !== 0) {
+      image.setPositionByOrigin(new Point(c2.x + t.x, c2.y + t.y), 'center', 'center');
+      image.setCoords();
+      moved = true;
+    }
+
+    if (!moved) return false;
+  }
+  return isBoxVerticesInsideImageAndViewport(box, image, cw, ch);
+}
+
+/** modified:先平移 image,仍无法包住 box∩视口 再放大底图 */
+export function resolveImageContainBoxShape(box: FabricObject, image: FabricImage, cw: number, ch: number): void {
+  for (let i = 0; i < MAX_RESOLVE_ITER; i++) {
+    if (resolveImageByTranslationOnly(box, image, cw, ch)) return;
+    applyUniformScale(image, 1.06);
+  }
+}
+
+export function ensureImageCenterInViewport(image: FabricImage, cw: number, ch: number): void {
+  image.setCoords();
+  const c = image.getCenterPoint();
+  const vp = minimalTranslationToPutCenterInViewport(c.x, c.y, cw, ch);
+  if (vp.x === 0 && vp.y === 0) return;
+  image.setPositionByOrigin(new Point(c.x + vp.x, c.y + vp.y), 'center', 'center');
+  image.setCoords();
+}
+
+export function handleImageModified(image: FabricImage, box: FabricObject | null, cw: number, ch: number): void {
+  ensureImageCenterInViewport(image, cw, ch);
+  if (box) resolveImageContainBoxShape(box, image, cw, ch);
+}
+
+export function handleBoxModified(box: FabricObject, image: FabricImage, cw: number, ch: number): void {
+  resolveBoxInsideImageAndViewportShape(box, image, cw, ch);
+}

+ 239 - 0
src/modules/monitor/Cropper/composables/useClamp/clampHandlers.ts

@@ -0,0 +1,239 @@
+import type { BasicTransformEvent, FabricImage, FabricObject, ModifiedEvent } from 'fabric';
+import {
+  CLAMP_BACKDROP_LOCAL_INSET,
+  CLAMP_BACKDROP_NUDGE_CAP,
+  CLAMP_BACKDROP_REFINE_GROW,
+  CLAMP_MOD_EDGE_TOL,
+  CLAMP_MOVE_ITERS,
+  CLAMP_REFINE_ITERS,
+  CLAMP_SUBJECT_SHRINK,
+} from './constants';
+import { fabricCanvasPoints, fabricQuad } from './fabricSnapshot';
+import {
+  aabbContains,
+  axisAlignedIntersection,
+  isBigOuterVersusViewport,
+  isDegenerateRect,
+  sceneBoundsFromCanvas,
+  translationDeltaClampBigOuterInViewport,
+  translationDeltaClampInnerAabbInside,
+  translationDeltaClampSmallOuterInViewport,
+  translationDeltaOuterAabbMustContain,
+} from './geometry';
+import { applyTransformOriginal, syncDragPointerOffsets, syncTransformAfterScaleRollback } from './object';
+import { applyRefinePlan } from './refineApply';
+import {
+  collapseRefinePlan,
+  computeBackdropRefinePlan,
+  computeSubjectRefinePlan,
+  subjectClampContainer,
+} from './refineModel';
+import { regionIsViewportFallback, regionSatisfiedForIronLaw } from './clampRegion';
+import type { ClampUn } from './types';
+
+export type TransformEvent = BasicTransformEvent & { target: FabricObject };
+export type ScalingSnapshot = (target: FabricObject, check: () => boolean) => 'commit' | 'rollback' | 'skip';
+
+// --- moving:底图 ---
+
+export function clampBackdropMoving(ctx: ClampUn, event: TransformEvent): void {
+  const { canvas, image, box } = ctx;
+  if (!canvas || !image) return;
+  const viewport = sceneBoundsFromCanvas(canvas);
+  const transform = event.transform;
+
+  for (let k = 0; k < CLAMP_MOVE_ITERS; k++) {
+    image.setCoords();
+    let outer = image.getBoundingRect();
+    const big = isBigOuterVersusViewport(outer, viewport);
+    const d1 = big ? translationDeltaClampBigOuterInViewport(outer, viewport) : translationDeltaClampSmallOuterInViewport(outer, viewport);
+    if (d1.dl !== 0 || d1.dt !== 0) {
+      image.left += d1.dl;
+      image.top += d1.dt;
+      image.setCoords();
+      outer = image.getBoundingRect();
+    }
+
+    if (box) {
+      box.setCoords();
+      const B = box.getBoundingRect();
+      if (!aabbContains(B, viewport)) {
+        applyTransformOriginal(image, transform);
+        image.setCoords();
+        syncDragPointerOffsets(transform, image, event.pointer);
+        return;
+      }
+      if (!regionIsViewportFallback(viewport, outer)) {
+        const d2 = translationDeltaOuterAabbMustContain(B, outer);
+        if (d2.dl !== 0 || d2.dt !== 0) {
+          image.left += d2.dl;
+          image.top += d2.dt;
+          image.setCoords();
+        }
+      }
+    }
+
+    const outer2 = image.getBoundingRect();
+    const d1b = isBigOuterVersusViewport(outer2, viewport)
+      ? translationDeltaClampBigOuterInViewport(outer2, viewport)
+      : translationDeltaClampSmallOuterInViewport(outer2, viewport);
+    if (d1b.dl === 0 && d1b.dt === 0 && (!box || regionSatisfiedForIronLaw(viewport, outer2, box))) break;
+  }
+
+  image.setCoords();
+  if (box && !regionSatisfiedForIronLaw(viewport, image.getBoundingRect(), box)) {
+    applyTransformOriginal(image, transform);
+    image.setCoords();
+  }
+
+  syncDragPointerOffsets(transform, image, event.pointer);
+}
+
+// --- moving:裁剪框 ---
+
+export function clampSubjectMoving(ctx: ClampUn, event: TransformEvent): void {
+  const { canvas, image, box } = ctx;
+  if (!canvas || !image || !box) return;
+  const viewport = sceneBoundsFromCanvas(canvas);
+  const transform = event.transform;
+
+  for (let k = 0; k < CLAMP_MOVE_ITERS; k++) {
+    image.setCoords();
+    box.setCoords();
+    const outer = image.getBoundingRect();
+    const container = subjectClampContainer(viewport, outer);
+    const B = box.getBoundingRect();
+    const d = translationDeltaClampInnerAabbInside(B, container);
+    if (d.dl === 0 && d.dt === 0) break;
+    box.left += d.dl;
+    box.top += d.dt;
+    box.setCoords();
+  }
+
+  syncDragPointerOffsets(transform, box, event.pointer);
+}
+
+// --- scaling ---
+
+export function clampBackdropScaling(ctx: ClampUn, event: TransformEvent, snapshot: ScalingSnapshot): void {
+  const { image, box } = ctx;
+  if (!image) return;
+  image.setCoords();
+  const result = snapshot(image, () => {
+    if (!box) return true;
+    box.setCoords();
+    const B = box.getBoundingRect();
+    const I = image.getBoundingRect();
+    return aabbContains(B, I);
+  });
+  if (result === 'rollback' && event.transform) {
+    syncTransformAfterScaleRollback(event.transform, image, event.pointer);
+  }
+}
+
+export function clampSubjectScaling(ctx: ClampUn, event: TransformEvent, snapshot: ScalingSnapshot): void {
+  const { canvas, image, box } = ctx;
+  if (!canvas || !image || !box) return;
+  const viewport = sceneBoundsFromCanvas(canvas);
+  box.setCoords();
+  const result = snapshot(box, () => {
+    image.setCoords();
+    box.setCoords();
+    const outer = image.getBoundingRect();
+    const inter = axisAlignedIntersection(viewport, outer);
+    const B = box.getBoundingRect();
+    if (inter && !isDegenerateRect(inter)) return aabbContains(B, inter);
+    return aabbContains(B, viewport);
+  });
+  if (result === 'rollback' && event.transform) {
+    syncTransformAfterScaleRollback(event.transform, box, event.pointer);
+  }
+}
+
+// --- modified 精修 ---
+
+type RefineRole = 'subject' | 'backdrop';
+
+function refineAfterModified(
+  canvas: NonNullable<ClampUn['canvas']>,
+  operand: FabricObject,
+  sample: FabricObject | null,
+  backdrop: FabricImage | null,
+  role: RefineRole,
+): void {
+  const viewport = sceneBoundsFromCanvas(canvas);
+
+  if (role === 'backdrop') {
+    if (!backdrop || !sample) {
+      canvas.requestRenderAll();
+      return;
+    }
+    backdrop.setCoords();
+    sample.setCoords();
+    const plan = collapseRefinePlan(
+      computeBackdropRefinePlan({
+        operandQuad: fabricQuad(backdrop),
+        fixedCanvasPoints: fabricCanvasPoints(sample),
+        localInset: CLAMP_BACKDROP_LOCAL_INSET,
+        translateCap: CLAMP_BACKDROP_NUDGE_CAP,
+        grow: CLAMP_BACKDROP_REFINE_GROW,
+        maxIters: CLAMP_REFINE_ITERS,
+      }),
+    );
+    applyRefinePlan(operand, plan);
+    canvas.requestRenderAll();
+    return;
+  }
+
+  if (!backdrop) {
+    canvas.requestRenderAll();
+    return;
+  }
+
+  backdrop.setCoords();
+  operand.setCoords();
+  const container = subjectClampContainer(viewport, backdrop.getBoundingRect());
+
+  const plan = collapseRefinePlan(
+    computeSubjectRefinePlan({
+      subjectQuad: fabricQuad(operand),
+      viewport,
+      container,
+      backdropQuad: fabricQuad(backdrop),
+      viewportTol: CLAMP_MOD_EDGE_TOL,
+      backdropInset: CLAMP_BACKDROP_LOCAL_INSET,
+      pullIntoBackdropCap: CLAMP_BACKDROP_NUDGE_CAP,
+      shrink: CLAMP_SUBJECT_SHRINK,
+      maxIters: CLAMP_REFINE_ITERS,
+    }),
+  );
+  applyRefinePlan(operand, plan);
+  canvas.requestRenderAll();
+}
+
+export function clampBackdropModified(ctx: ClampUn, _event: ModifiedEvent): void {
+  const { canvas, image, box } = ctx;
+  if (!canvas || !image) return;
+  if (box) {
+    refineAfterModified(canvas, image, box, image, 'backdrop');
+    return;
+  }
+  const viewport = sceneBoundsFromCanvas(canvas);
+  for (let k = 0; k < CLAMP_REFINE_ITERS; k++) {
+    image.setCoords();
+    const outer = image.getBoundingRect();
+    const big = isBigOuterVersusViewport(outer, viewport);
+    const d = big ? translationDeltaClampBigOuterInViewport(outer, viewport) : translationDeltaClampSmallOuterInViewport(outer, viewport);
+    if (d.dl === 0 && d.dt === 0) break;
+    image.left += d.dl;
+    image.top += d.dt;
+    image.setCoords();
+  }
+  canvas.requestRenderAll();
+}
+
+export function clampSubjectModified(ctx: ClampUn, _event: ModifiedEvent): void {
+  const { canvas, image, box } = ctx;
+  if (!canvas || !image || !box) return;
+  refineAfterModified(canvas, box, box, image, 'subject');
+}

+ 147 - 0
src/modules/monitor/Cropper/composables/useClamp/clampMath.ts

@@ -0,0 +1,147 @@
+/** 轴对齐包围盒(与 Fabric getBoundingRect 一致) */
+export interface Aabb {
+  left: number;
+  top: number;
+  width: number;
+  height: number;
+}
+
+export interface XY {
+  x: number;
+  y: number;
+}
+
+export function pointInViewport(p: XY, cw: number, ch: number): boolean {
+  return p.x >= 0 && p.x <= cw && p.y >= 0 && p.y <= ch;
+}
+
+/** 将中心点平移回视口所需的最小 delta(已在视口内则 0) */
+export function minimalTranslationToPutCenterInViewport(cx: number, cy: number, cw: number, ch: number): XY {
+  let dx = 0;
+  let dy = 0;
+  if (cx < 0) dx = -cx;
+  else if (cx > cw) dx = cw - cx;
+  if (cy < 0) dy = -cy;
+  else if (cy > ch) dy = ch - cy;
+  return { x: dx, y: dy };
+}
+
+export function aabbRight(b: Aabb): number {
+  return b.left + b.width;
+}
+
+export function aabbBottom(b: Aabb): number {
+  return b.top + b.height;
+}
+
+/** 视口轴对齐矩形 [0,cw]×[0,ch] */
+export function viewportAabb(cw: number, ch: number): Aabb {
+  return { left: 0, top: 0, width: cw, height: ch };
+}
+
+/** 两 AABB 的交集;不相交返回 null */
+export function aabbIntersection(a: Aabb, b: Aabb): Aabb | null {
+  const left = Math.max(a.left, b.left);
+  const top = Math.max(a.top, b.top);
+  const right = Math.min(aabbRight(a), aabbRight(b));
+  const bottom = Math.min(aabbBottom(a), aabbBottom(b));
+  const w = right - left;
+  const h = bottom - top;
+  if (w <= 0 || h <= 0) return null;
+  return { left, top, width: w, height: h };
+}
+
+/** inner 是否完全在 outer 内(轴对齐) */
+export function aabbInnerFullyInOuter(inner: Aabb, outer: Aabb): boolean {
+  return (
+    inner.left >= outer.left &&
+    inner.top >= outer.top &&
+    aabbRight(inner) <= aabbRight(outer) &&
+    aabbBottom(inner) <= aabbBottom(outer)
+  );
+}
+
+/**
+ * 平移 inner 使尽量落入 outer 的 delta(作用于 inner 的 left/top)。
+ * inner 宽/高大于 outer 时在该轴上居中。
+ */
+export function aabbClampDelta(inner: Aabb, outer: Aabb): XY {
+  const il = inner.left;
+  const it = inner.top;
+  const iw = inner.width;
+  const ih = inner.height;
+  const ir = il + iw;
+  const ib = it + ih;
+  const ol = outer.left;
+  const ot = outer.top;
+  const ow = outer.width;
+  const oh = outer.height;
+  const or = ol + ow;
+  const ob = ot + oh;
+
+  let dx = 0;
+  let dy = 0;
+
+  if (iw <= ow) {
+    if (il < ol) dx = ol - il;
+    else if (ir > or) dx = or - ir;
+  } else {
+    dx = ol + (ow - iw) / 2 - il;
+  }
+
+  if (ih <= oh) {
+    if (it < ot) dy = ot - it;
+    else if (ib > ob) dy = ob - ib;
+  } else {
+    dy = ot + (oh - ih) / 2 - it;
+  }
+
+  return { x: dx, y: dy };
+}
+
+function intervalIntersection(lo1: number, hi1: number, lo2: number, hi2: number): { lo: number; hi: number } | null {
+  const lo = Math.max(lo1, lo2);
+  const hi = Math.min(hi1, hi2);
+  if (lo > hi) return null;
+  return { lo, hi };
+}
+
+/** 在 [lo,hi] 内取最接近 0 的值 */
+export function closestToZeroInInterval(lo: number, hi: number): number {
+  if (lo > hi) return 0;
+  if (0 >= lo && 0 <= hi) return 0;
+  return Math.abs(lo) < Math.abs(hi) ? lo : hi;
+}
+
+/**
+ * 刚体平移 image:使 box AABB ⊆ image AABB 且 image 中心落在视口内。
+ * 返回应施加在场景平面上的平移 (dx,dy)(与 setPositionByOrigin(center+δ) 一致)。
+ */
+export function translationForImageBoxAndViewport(
+  imageAabb: Aabb,
+  boxAabb: Aabb,
+  center: XY,
+  cw: number,
+  ch: number
+): XY {
+  const I = imageAabb;
+  const B = boxAabb;
+
+  const dxBoxLo = B.left + B.width - (I.left + I.width);
+  const dxBoxHi = B.left - I.left;
+  const dyBoxLo = B.top + B.height - (I.top + I.height);
+  const dyBoxHi = B.top - I.top;
+
+  const dxVpLo = -center.x;
+  const dxVpHi = cw - center.x;
+  const dyVpLo = -center.y;
+  const dyVpHi = ch - center.y;
+
+  const ix = intervalIntersection(dxBoxLo, dxBoxHi, dxVpLo, dxVpHi);
+  const iy = intervalIntersection(dyBoxLo, dyBoxHi, dyVpLo, dyVpHi);
+
+  const dx = ix ? closestToZeroInInterval(ix.lo, ix.hi) : closestToZeroInInterval(dxBoxLo, dxBoxHi);
+  const dy = iy ? closestToZeroInInterval(iy.lo, iy.hi) : closestToZeroInInterval(dyBoxLo, dyBoxHi);
+
+  return { x: dx, y: dy };
+}

+ 25 - 0
src/modules/monitor/Cropper/composables/useClamp/clampRegion.ts

@@ -0,0 +1,25 @@
+import type { FabricObject, TBBox } from 'fabric';
+import { aabbContains, axisAlignedIntersection, isDegenerateRect } from './geometry';
+
+/** 视口与底图 AABB 的交集为空或退化时,铁律 R 退化为视口 */
+export function regionIsViewportFallback(
+  viewport: TBBox,
+  outer: { left: number; top: number; width: number; height: number },
+): boolean {
+  const raw = axisAlignedIntersection(viewport, outer);
+  return !raw || isDegenerateRect(raw);
+}
+
+/** 拖动底图时:box 须在视口内;若 R 有效则 box AABB 还须被底图 AABB 包含 */
+export function regionSatisfiedForIronLaw(
+  viewport: TBBox,
+  outer: { left: number; top: number; width: number; height: number },
+  box: FabricObject | null,
+): boolean {
+  if (!box) return true;
+  box.setCoords();
+  const B = box.getBoundingRect();
+  if (!aabbContains(B, viewport)) return false;
+  if (regionIsViewportFallback(viewport, outer)) return true;
+  return aabbContains(B, outer);
+}

+ 22 - 0
src/modules/monitor/Cropper/composables/useClamp/constants.ts

@@ -0,0 +1,22 @@
+/** useClamp 数值常量(迭代次数、精修因子、容差) */
+
+/** object:moving 单帧内迭代上限 */
+export const CLAMP_MOVE_ITERS = 12;
+
+/** object:modified 精修迭代上限 */
+export const CLAMP_REFINE_ITERS = 16;
+
+/** 精修时视口边判定容差(px) */
+export const CLAMP_MOD_EDGE_TOL = 1.5;
+
+/** 底图精修:均匀放大因子 */
+export const CLAMP_BACKDROP_REFINE_GROW = 1.035;
+
+/** 底图/主体沿平行四边形法向平移的单步上限(px) */
+export const CLAMP_BACKDROP_NUDGE_CAP = 48;
+
+/** 底图平行四边形 UV 内缩对应的画布距离(px),减轻边界数值抖动 */
+export const CLAMP_BACKDROP_LOCAL_INSET = 0.35;
+
+/** 主体精修:均匀缩小因子 */
+export const CLAMP_SUBJECT_SHRINK = 0.97;

+ 18 - 0
src/modules/monitor/Cropper/composables/useClamp/fabricSnapshot.ts

@@ -0,0 +1,18 @@
+/** 从 Fabric 对象读取画布几何快照(getCoords) */
+import type { FabricObject } from 'fabric';
+import type { Quad, XY } from './refineModel';
+
+/** Fabric getCoords() 顺序:tl, tr, br, bl */
+export function fabricQuad(object: FabricObject): Quad {
+  const c = object.getCoords();
+  return [
+    { x: c[0].x, y: c[0].y },
+    { x: c[1].x, y: c[1].y },
+    { x: c[2].x, y: c[2].y },
+    { x: c[3].x, y: c[3].y },
+  ];
+}
+
+export function fabricCanvasPoints(object: FabricObject): XY[] {
+  return object.getCoords().map((p) => ({ x: p.x, y: p.y }));
+}

+ 97 - 0
src/modules/monitor/Cropper/composables/useClamp/geometry.ts

@@ -0,0 +1,97 @@
+/**
+ * 轴对齐矩形工具:视口、交集、AABB 包含与平移增量(配合 Fabric left/top)。
+ */
+import type { Canvas, StaticCanvas, TBBox } from 'fabric';
+
+const EPS = 1e-6;
+
+export function sceneBoundsFromCanvas(canvas: Canvas | StaticCanvas): TBBox {
+  return { left: 0, top: 0, width: canvas.width, height: canvas.height };
+}
+
+export function rectRight(b: TBBox): number {
+  return b.left + b.width;
+}
+
+export function rectBottom(b: TBBox): number {
+  return b.top + b.height;
+}
+
+export function axisAlignedIntersection(viewport: TBBox, outer: TBBox): TBBox | null {
+  const left = Math.max(viewport.left, outer.left);
+  const top = Math.max(viewport.top, outer.top);
+  const right = Math.min(rectRight(viewport), rectRight(outer));
+  const bottom = Math.min(rectBottom(viewport), rectBottom(outer));
+  const width = right - left;
+  const height = bottom - top;
+  if (width <= EPS || height <= EPS) return null;
+  return { left, top, width, height };
+}
+
+export function isDegenerateRect(b: TBBox): boolean {
+  return b.width <= EPS || b.height <= EPS;
+}
+
+/** Intersection of viewport and outer; if empty or degenerate, returns viewport (plan: R := V). */
+export function effectiveClampRegion(viewport: TBBox, outer: TBBox): TBBox {
+  const inter = axisAlignedIntersection(viewport, outer);
+  if (!inter || isDegenerateRect(inter)) return { ...viewport };
+  return inter;
+}
+
+/** True if inner AABB is fully inside outer AABB (axis-aligned, inclusive). */
+export function aabbContains(inner: TBBox, outer: TBBox): boolean {
+  return (
+    inner.left >= outer.left - EPS &&
+    inner.top >= outer.top - EPS &&
+    rectRight(inner) <= rectRight(outer) + EPS &&
+    rectBottom(inner) <= rectBottom(outer) + EPS
+  );
+}
+
+/** Delta (dl, dt) to apply to object left/top so that moving AABB by (dl,dt) pulls `moved` toward satisfying constraints. */
+export function translationDeltaClampBigOuterInViewport(outer: TBBox, viewport: TBBox): { dl: number; dt: number } {
+  let dl = 0;
+  let dt = 0;
+  if (outer.left > viewport.left + EPS) dl -= outer.left - viewport.left;
+  if (outer.top > viewport.top + EPS) dt -= outer.top - viewport.top;
+  if (rectRight(outer) < rectRight(viewport) - EPS) dl += rectRight(viewport) - rectRight(outer);
+  if (rectBottom(outer) < rectBottom(viewport) - EPS) dt += rectBottom(viewport) - rectBottom(outer);
+  return { dl, dt };
+}
+
+export function translationDeltaClampSmallOuterInViewport(outer: TBBox, viewport: TBBox): { dl: number; dt: number } {
+  let dl = 0;
+  let dt = 0;
+  if (outer.left < viewport.left - EPS) dl += viewport.left - outer.left;
+  if (outer.top < viewport.top - EPS) dt += viewport.top - outer.top;
+  if (rectRight(outer) > rectRight(viewport) + EPS) dl -= rectRight(outer) - rectRight(viewport);
+  if (rectBottom(outer) > rectBottom(viewport) + EPS) dt -= rectBottom(outer) - rectBottom(viewport);
+  return { dl, dt };
+}
+
+export function isBigOuterVersusViewport(outer: TBBox, viewport: TBBox): boolean {
+  return outer.width >= viewport.width - EPS && outer.height >= viewport.height - EPS;
+}
+
+/** Clamp inner's top-left (via bbox translation) inside container: returns delta to apply to fabric left/top. */
+export function translationDeltaClampInnerAabbInside(inner: TBBox, container: TBBox): { dl: number; dt: number } {
+  let dl = 0;
+  let dt = 0;
+  if (inner.left < container.left - EPS) dl += container.left - inner.left;
+  if (inner.top < container.top - EPS) dt += container.top - inner.top;
+  if (rectRight(inner) > rectRight(container) + EPS) dl -= rectRight(inner) - rectRight(container);
+  if (rectBottom(inner) > rectBottom(container) + EPS) dt -= rectBottom(inner) - rectBottom(container);
+  return { dl, dt };
+}
+
+/** Delta to apply to `outer`'s origin so its AABB (translating with object) covers `inner` AABB. */
+export function translationDeltaOuterAabbMustContain(inner: TBBox, outer: TBBox): { dl: number; dt: number } {
+  let dl = 0;
+  let dt = 0;
+  if (inner.left < outer.left - EPS) dl += inner.left - outer.left;
+  if (inner.top < outer.top - EPS) dt += inner.top - outer.top;
+  if (rectRight(inner) > rectRight(outer) + EPS) dl += rectRight(inner) - rectRight(outer);
+  if (rectBottom(inner) > rectBottom(outer) + EPS) dt += rectBottom(inner) - rectBottom(outer);
+  return { dl, dt };
+}

+ 3 - 0
src/modules/monitor/Cropper/composables/useClamp/index.ts

@@ -0,0 +1,3 @@
+/** 底图与裁剪框:拖动 / 缩放 / modified 约束 */
+export { useClamp } from './useClamp';
+export type { ClampContext } from './types';

+ 43 - 0
src/modules/monitor/Cropper/composables/useClamp/object.ts

@@ -0,0 +1,43 @@
+/** Fabric 变换回滚与拖拽指针同步(与 useSnapshot 缩放回滚配合) */
+import type { FabricObject, Point, Transform } from 'fabric';
+
+export function syncDragPointerOffsets(transform: Transform, target: FabricObject, pointer: Point): void {
+  transform.offsetX = pointer.x - target.left;
+  transform.offsetY = pointer.y - target.top;
+}
+
+export function applyTransformOriginal(target: FabricObject, transform: Transform): void {
+  const { original } = transform;
+  const next: Record<string, unknown> = {
+    scaleX: original.scaleX,
+    scaleY: original.scaleY,
+    skewX: original.skewX,
+    skewY: original.skewY,
+    angle: original.angle,
+    left: original.left,
+    top: original.top,
+    flipX: original.flipX,
+    flipY: original.flipY,
+    originX: original.originX,
+    originY: original.originY,
+  };
+  if (original.cropX !== undefined) next.cropX = original.cropX;
+  if (original.cropY !== undefined) next.cropY = original.cropY;
+  target.set(next);
+}
+
+export function syncTransformAfterScaleRollback(transform: Transform, target: FabricObject, pointer: Point): void {
+  transform.scaleX = target.scaleX;
+  transform.scaleY = target.scaleY;
+  transform.skewX = target.skewX;
+  transform.skewY = target.skewY;
+  transform.width = target.width;
+  transform.height = target.height;
+  transform.ex = pointer.x;
+  transform.ey = pointer.y;
+  transform.lastX = pointer.x;
+  transform.lastY = pointer.y;
+  const st = transform as Transform & { signX?: number; signY?: number };
+  st.signX = undefined;
+  st.signY = undefined;
+}

+ 23 - 0
src/modules/monitor/Cropper/composables/useClamp/refineApply.ts

@@ -0,0 +1,23 @@
+/** 将 RefineOp 序列应用到 Fabric 对象(末尾统一 setCoords) */
+import type { FabricObject } from 'fabric';
+import { scaleObject } from '../../actions';
+import type { RefineOp } from './refineModel';
+
+/**
+ * 将精修计划应用到 Fabric 对象:中间步骤不 setCoords,末尾统一 setCoords,缩放使用 deferSetCoords 减少重复几何刷新。
+ */
+export function applyRefinePlan(operand: FabricObject, ops: RefineOp[]): void {
+  for (const op of ops) {
+    if (op.kind === 'translate') {
+      operand.left += op.dx;
+      operand.top += op.dy;
+    } else {
+      scaleObject(operand, op.k, {
+        uniform: operand.lockScaleUniform,
+        deferSetCoords: true,
+      });
+    }
+  }
+  operand.setCoords();
+  operand.dirty = true;
+}

+ 342 - 0
src/modules/monitor/Cropper/composables/useClamp/refineModel.ts

@@ -0,0 +1,342 @@
+/**
+ * object:modified 精修的纯几何模型:平行四边形(旋转矩形)与 RefineOp 序列。
+ */
+import type { TBBox } from 'fabric';
+import { util } from 'fabric';
+import {
+  effectiveClampRegion,
+  rectBottom,
+  rectRight,
+  translationDeltaClampInnerAabbInside,
+} from './geometry';
+
+const { makeBoundingBoxFromPoints } = util;
+
+/** 画布上的凸四边形顶点,顺序与 Fabric getCoords() 一致:tl, tr, br, bl(平行四边形) */
+export type Quad = readonly [XY, XY, XY, XY];
+
+export type XY = { x: number; y: number };
+
+export type RefineOp = { kind: 'translate'; dx: number; dy: number } | { kind: 'scale'; k: number };
+
+export type BackdropRefineParams = {
+  operandQuad: Quad;
+  fixedCanvasPoints: XY[];
+  localInset: number;
+  translateCap: number;
+  grow: number;
+  maxIters: number;
+};
+
+export type SubjectRefineParams = {
+  subjectQuad: Quad;
+  viewport: TBBox;
+  /** 视口 ∩ 底图 AABB,用于轴对齐平移(与 moving 一致) */
+  container: TBBox;
+  /** 底图真实区域(旋转后为平行四边形);存在时始终约束 box 顶点 */
+  backdropQuad: Quad | null;
+  viewportTol: number;
+  /** 底图四边形内缩(画布 px),与 backdrop 精修一致减轻边界数值问题 */
+  backdropInset: number;
+  /** 单步把 box 拉入底图时的平移上限(画布 px) */
+  pullIntoBackdropCap: number;
+  shrink: number;
+  maxIters: number;
+};
+
+/** 底图/operand 平行四边形:tl-tr 为 e1,tl-bl 为 e2,inset 为画布 px 换成的 UV 内缩 */
+function parallelogramWithInset(quad: Quad, insetPx: number) {
+  const [tl, tr, , bl] = quad;
+  const e1x = tr.x - tl.x;
+  const e1y = tr.y - tl.y;
+  const e2x = bl.x - tl.x;
+  const e2y = bl.y - tl.y;
+  const len1 = Math.hypot(e1x, e1y) || 1;
+  const len2 = Math.hypot(e2x, e2y) || 1;
+  return {
+    tl,
+    tr,
+    bl,
+    e1x,
+    e1y,
+    e2x,
+    e2y,
+    insetU: insetPx / len1,
+    insetV: insetPx / len2,
+  };
+}
+
+function quadCentroid(q: Quad): XY {
+  return {
+    x: (q[0].x + q[1].x + q[2].x + q[3].x) / 4,
+    y: (q[0].y + q[1].y + q[2].y + q[3].y) / 4,
+  };
+}
+
+function translateQuad(q: Quad, dx: number, dy: number): Quad {
+  const t = (p: XY) => ({ x: p.x + dx, y: p.y + dy });
+  return [t(q[0]), t(q[1]), t(q[2]), t(q[3])];
+}
+
+function scaleQuadUniform(q: Quad, cx: number, cy: number, k: number): Quad {
+  return q.map((p) => ({ x: cx + k * (p.x - cx), y: cy + k * (p.y - cy) })) as unknown as Quad;
+}
+
+/** 平行四边形 tl,tr,bl 参数化:p = tl + u*(tr-tl) + v*(bl-tl) */
+function parallelogramUV(tl: XY, tr: XY, bl: XY, p: XY): { u: number; v: number } {
+  const e1x = tr.x - tl.x;
+  const e1y = tr.y - tl.y;
+  const e2x = bl.x - tl.x;
+  const e2y = bl.y - tl.y;
+  const px = p.x - tl.x;
+  const py = p.y - tl.y;
+  const det = e1x * e2y - e1y * e2x;
+  if (Math.abs(det) < 1e-12) return { u: 0, v: 0 };
+  const u = (px * e2y - py * e2x) / det;
+  const v = (e1x * py - e1y * px) / det;
+  return { u, v };
+}
+
+function pointInParallelogramInset(tl: XY, tr: XY, bl: XY, p: XY, insetU: number, insetV: number): boolean {
+  const { u, v } = parallelogramUV(tl, tr, bl, p);
+  const u0 = insetU;
+  const u1 = 1 - insetU;
+  const v0 = insetV;
+  const v1 = 1 - insetV;
+  if (!(u0 <= u1 && v0 <= v1)) return u >= 0 && u <= 1 && v >= 0 && v <= 1;
+  return u >= u0 - 1e-9 && u <= u1 + 1e-9 && v >= v0 - 1e-9 && v <= v1 + 1e-9;
+}
+
+function pointInViewportTol(p: XY, v: TBBox, tol: number): boolean {
+  return (
+    p.x >= v.left - tol &&
+    p.x <= rectRight(v) + tol &&
+    p.y >= v.top - tol &&
+    p.y <= rectBottom(v) + tol
+  );
+}
+
+/**
+ * 固定场景点需落入 operand 平行四边形内:沿局部轴向(e1,e2)做法向拉回,合成画布平移。
+ * 与「将底图平移使点落入四边形」一致:D = du*e1 + dv*e2。
+ */
+export function backdropTranslateForContainment(
+  fixedPts: XY[],
+  operandQuad: Quad,
+  inset: number,
+  cap: number,
+): { dx: number; dy: number } {
+  const { tl, tr, bl, e1x, e1y, e2x, e2y, insetU, insetV } = parallelogramWithInset(operandQuad, inset);
+
+  let sx = 0;
+  let sy = 0;
+  let n = 0;
+  for (const p of fixedPts) {
+    if (pointInParallelogramInset(tl, tr, bl, p, insetU, insetV)) continue;
+    const { u, v } = parallelogramUV(tl, tr, bl, p);
+    const u0 = insetU;
+    const u1 = 1 - insetU;
+    const v0 = insetV;
+    const v1 = 1 - insetV;
+    const uc = Math.min(u1, Math.max(u0, u));
+    const vc = Math.min(v1, Math.max(v0, v));
+    const du = u - uc;
+    const dv = v - vc;
+    if (du === 0 && dv === 0) continue;
+    sx += du * e1x + dv * e2x;
+    sy += du * e1y + dv * e2y;
+    n++;
+  }
+  if (n === 0) return { dx: 0, dy: 0 };
+  let dx = sx / n;
+  let dy = sy / n;
+  const mag = Math.hypot(dx, dy);
+  if (mag > cap) {
+    const s = cap / mag;
+    dx *= s;
+    dy *= s;
+  }
+  return { dx, dy };
+}
+
+function backdropViolations(fixed: XY[], operandQuad: Quad, inset: number): boolean {
+  const { tl, tr, bl, insetU, insetV } = parallelogramWithInset(operandQuad, inset);
+  for (const p of fixed) {
+    if (!pointInParallelogramInset(tl, tr, bl, p, insetU, insetV)) return true;
+  }
+  return false;
+}
+
+/** 纯模型:底图平行四边形需覆盖固定点,输出平移+缩放操作序列(不触碰 Fabric)。 */
+export function computeBackdropRefinePlan(p: BackdropRefineParams): RefineOp[] {
+  const ops: RefineOp[] = [];
+  let quad: Quad = [
+    { ...p.operandQuad[0] },
+    { ...p.operandQuad[1] },
+    { ...p.operandQuad[2] },
+    { ...p.operandQuad[3] },
+  ];
+
+  for (let step = 0; step < p.maxIters; step++) {
+    if (!backdropViolations(p.fixedCanvasPoints, quad, p.localInset)) break;
+
+    const { dx, dy } = backdropTranslateForContainment(
+      p.fixedCanvasPoints,
+      quad,
+      p.localInset,
+      p.translateCap,
+    );
+    if (dx !== 0 || dy !== 0) {
+      quad = translateQuad(quad, dx, dy);
+      ops.push({ kind: 'translate', dx, dy });
+    }
+
+    if (!backdropViolations(p.fixedCanvasPoints, quad, p.localInset)) break;
+
+    const c = quadCentroid(quad);
+    quad = scaleQuadUniform(quad, c.x, c.y, p.grow);
+    ops.push({ kind: 'scale', k: p.grow });
+  }
+
+  return ops;
+}
+
+function subjectViolations(
+  corners: XY[],
+  viewport: TBBox,
+  backdropQuad: Quad | null,
+  tol: number,
+  backdropInsetU: number,
+  backdropInsetV: number,
+): boolean {
+  for (const p of corners) {
+    if (!pointInViewportTol(p, viewport, tol)) return true;
+    if (backdropQuad) {
+      const [tl, tr, , bl] = backdropQuad;
+      if (!pointInParallelogramInset(tl, tr, bl, p, backdropInsetU, backdropInsetV)) return true;
+    }
+  }
+  return false;
+}
+
+/**
+ * 平移裁剪框,使顶点进入底图平行四边形:T = (uc-u)e1 + (vc-v)e2(与「平移底图盖住点」互逆)。
+ */
+function subjectPullIntoBackdropQuad(
+  subjectCorners: XY[],
+  backdropQuad: Quad,
+  insetU: number,
+  insetV: number,
+  cap: number,
+): { dx: number; dy: number } {
+  const { tl, tr, bl, e1x, e1y, e2x, e2y } = parallelogramWithInset(backdropQuad, 0);
+
+  let sx = 0;
+  let sy = 0;
+  let n = 0;
+  for (const p of subjectCorners) {
+    if (pointInParallelogramInset(tl, tr, bl, p, insetU, insetV)) continue;
+    const { u, v } = parallelogramUV(tl, tr, bl, p);
+    const u0 = insetU;
+    const u1 = 1 - insetU;
+    const v0 = insetV;
+    const v1 = 1 - insetV;
+    const uc = Math.min(u1, Math.max(u0, u));
+    const vc = Math.min(v1, Math.max(v0, v));
+    const du = uc - u;
+    const dv = vc - v;
+    if (du === 0 && dv === 0) continue;
+    sx += du * e1x + dv * e2x;
+    sy += du * e1y + dv * e2y;
+    n++;
+  }
+  if (n === 0) return { dx: 0, dy: 0 };
+  let dx = sx / n;
+  let dy = sy / n;
+  const mag = Math.hypot(dx, dy);
+  if (mag > cap) {
+    const s = cap / mag;
+    dx *= s;
+    dy *= s;
+  }
+  return { dx, dy };
+}
+
+/**
+ * 主体:须在视口内、在轴对齐容器(R)内,且顶点在底图真实四边形内(旋转后非 AABB)。
+ * 先按容器做 AABB 平移,再按底图平行四边形做法向拉入平移,仍不满足则缩小。
+ */
+export function computeSubjectRefinePlan(p: SubjectRefineParams): RefineOp[] {
+  const ops: RefineOp[] = [];
+  let quad: Quad = [
+    { ...p.subjectQuad[0] },
+    { ...p.subjectQuad[1] },
+    { ...p.subjectQuad[2] },
+    { ...p.subjectQuad[3] },
+  ];
+
+  const { insetU: biU, insetV: biV } =
+    p.backdropQuad != null
+      ? parallelogramWithInset(p.backdropQuad, p.backdropInset)
+      : { insetU: 0, insetV: 0 };
+
+  for (let step = 0; step < p.maxIters; step++) {
+    const corners = [quad[0], quad[1], quad[2], quad[3]];
+    if (!subjectViolations(corners, p.viewport, p.backdropQuad, p.viewportTol, biU, biV)) break;
+
+    const bbox = makeBoundingBoxFromPoints(corners);
+    const d = translationDeltaClampInnerAabbInside(bbox, p.container);
+    if (d.dl !== 0 || d.dt !== 0) {
+      quad = translateQuad(quad, d.dl, d.dt);
+      ops.push({ kind: 'translate', dx: d.dl, dy: d.dt });
+    }
+
+    if (p.backdropQuad) {
+      const cornersB = [quad[0], quad[1], quad[2], quad[3]];
+      const pull = subjectPullIntoBackdropQuad(cornersB, p.backdropQuad, biU, biV, p.pullIntoBackdropCap);
+      if (pull.dx !== 0 || pull.dy !== 0) {
+        quad = translateQuad(quad, pull.dx, pull.dy);
+        ops.push({ kind: 'translate', dx: pull.dx, dy: pull.dy });
+      }
+    }
+
+    const corners2 = [quad[0], quad[1], quad[2], quad[3]];
+    if (!subjectViolations(corners2, p.viewport, p.backdropQuad, p.viewportTol, biU, biV)) break;
+
+    const c = quadCentroid(quad);
+    quad = scaleQuadUniform(quad, c.x, c.y, p.shrink);
+    ops.push({ kind: 'scale', k: p.shrink });
+  }
+
+  return ops;
+}
+
+/** 由画布与底图 AABB 得到主体精修用的轴对齐容器(与 moving 一致) */
+export function subjectClampContainer(viewport: TBBox, backdropOuter: TBBox): TBBox {
+  return effectiveClampRegion(viewport, backdropOuter);
+}
+
+/** 合并相邻平移,减少对 operand.left/top 的写入次数 */
+export function collapseRefinePlan(ops: RefineOp[]): RefineOp[] {
+  const out: RefineOp[] = [];
+  let accDx = 0;
+  let accDy = 0;
+  const flush = () => {
+    if (accDx !== 0 || accDy !== 0) {
+      out.push({ kind: 'translate', dx: accDx, dy: accDy });
+      accDx = 0;
+      accDy = 0;
+    }
+  };
+  for (const op of ops) {
+    if (op.kind === 'translate') {
+      accDx += op.dx;
+      accDy += op.dy;
+    } else {
+      flush();
+      out.push(op);
+    }
+  }
+  flush();
+  return out;
+}

+ 11 - 0
src/modules/monitor/Cropper/composables/useClamp/types.ts

@@ -0,0 +1,11 @@
+import type { MaybeRef } from 'vue';
+import type { FabricImage, FabricObject, Path } from 'fabric';
+import type { FabricContext, UnwrapperContext } from '@/lib/fabric';
+
+export interface ClampContext extends FabricContext {
+  image: MaybeRef<FabricImage | null>;
+  box: MaybeRef<FabricObject | null>;
+  overlay: MaybeRef<Path | null>;
+}
+
+export type ClampUn = UnwrapperContext<ClampContext>;

+ 51 - 0
src/modules/monitor/Cropper/composables/useClamp/useClamp.ts

@@ -0,0 +1,51 @@
+/**
+ * 裁剪器 clamp:仅负责注册 Fabric 事件;具体策略见 clampHandlers / clampRegion。
+ */
+import type { ClampContext } from './types';
+
+import { bindContext, useFabricEventListener, useSnapshot } from '@/lib/fabric';
+import { isShapeBrush } from '@/lib/fabric/brush';
+import { scaleObjectToContainer } from '../../actions';
+import { clampBackdropModified, clampBackdropMoving, clampBackdropScaling, clampSubjectModified, clampSubjectMoving, clampSubjectScaling } from './clampHandlers';
+
+export function useClamp(context: ClampContext) {
+  const { start, end, snapshot } = useSnapshot(context, [context.image, context.box]);
+  const bind = bindContext(context);
+
+  useFabricEventListener(context.image, 'added', () =>
+    bind()(({ canvas, image }) => {
+      if (!canvas || !image) return;
+      scaleObjectToContainer(image, { width: canvas.width, height: canvas.height, center: canvas.getCenterPoint() }, 'contain');
+      image.setControlsVisibility({ mt: false, mb: false, mr: false, ml: false });
+      canvas.requestRenderAll();
+    })
+  );
+
+  useFabricEventListener(context.canvas, 'object:moving', (event) =>
+    bind(event)((ctx, event) => {
+      if (event.target === ctx.image) clampBackdropMoving(ctx, event);
+      if (event.target === ctx.box) clampSubjectMoving(ctx, event);
+    })
+  );
+
+  useFabricEventListener(context.canvas, 'object:scaling', (event) =>
+    bind(event)((ctx, event) => {
+      if (event.target === ctx.image) clampBackdropScaling(ctx, event, snapshot);
+      if (event.target === ctx.box) clampSubjectScaling(ctx, event, snapshot);
+    })
+  );
+
+  useFabricEventListener(context.canvas, 'object:modified', (event) =>
+    bind(event)((ctx, event) => {
+      if (event.target === ctx.image) clampBackdropModified(ctx, event);
+      if (event.target === ctx.box) clampSubjectModified(ctx, event);
+    })
+  );
+
+  useFabricEventListener(context.canvas, 'brush:start', (event) =>
+    bind(event)(({ canvas, image }, event) => {
+      if (!canvas || !isShapeBrush(canvas.freeDrawingBrush)) return;
+      canvas.freeDrawingBrush.limited = image;
+    })
+  );
+}

+ 43 - 0
src/modules/monitor/Cropper/composables/useOverlay.ts

@@ -0,0 +1,43 @@
+import type { MaybeRef } from 'vue';
+import type { FabricObject } from 'fabric';
+import { Path } from 'fabric';
+import { bindContext, type FabricContext, type UnwrapperContext, useFabricEventListener } from '@/lib/fabric';
+import { toClipPathD } from '../actions/path';
+
+interface OverlayContext extends FabricContext {
+  box: MaybeRef<FabricObject | null>;
+}
+
+export function useOverlay(context: OverlayContext) {
+  const box = computed(() => context.box);
+  const object = shallowRef(
+    new Path([], {
+      fill: 'rgba(0,0,0,0.5)',
+      fillRule: 'evenodd',
+      selectable: false,
+      evented: false,
+    })
+  );
+
+  const bind = bindContext(context)();
+  const update = ({ canvas, box }: UnwrapperContext<OverlayContext>): void => {
+    if (!canvas) return;
+    if (box) {
+      const outer = toClipPathD(canvas);
+      const inner = box.visible && box.canvas ? toClipPathD(box) : '';
+      if (outer && inner) object.value._setPath(`${outer} ${inner}`, true);
+      else object.value.path = [];
+    } else object.value.path = [];
+    object.value.dirty = true;
+    if (!object.value.canvas) canvas.add(object.value);
+  };
+
+  useFabricEventListener(context.box, ['moving', 'scaling', 'rotating'], () => bind(update));
+  useFabricEventListener(context.box, ['added', 'removed'], () => bind(update));
+
+  useFabricEventListener(context.canvas, 'object:created', () => {
+    nextTick().then(() => bind(update));
+  });
+
+  return object;
+}

+ 68 - 0
src/modules/monitor/Cropper/composables/useShape.ts

@@ -0,0 +1,68 @@
+import type { MaybeRef } from 'vue';
+import type { FabricObject } from 'fabric';
+import type { FabricContext } from '@/lib/fabric';
+import type { Shape } from '@/lib/fabric/brush';
+import { useFabricEventListener } from '@/lib/fabric';
+import { createShapeBrush, isShapeBrush } from '@/lib/fabric/brush';
+
+interface ShapeContext extends FabricContext {
+  shape: MaybeRef<Shape | null>;
+}
+
+export function useShape(context: ShapeContext) {
+  const canvas = computed(() => toValue(context.canvas));
+  const shape = computed(() => toValue(context.shape));
+  const object = shallowRef<FabricObject | null>(null);
+
+  watch(
+    [canvas, shape],
+    ([canvas, shape], oldValue, onCleanup) => {
+      if (!canvas) return;
+
+      canvas.freeDrawingBrush = shape ? createShapeBrush(canvas, shape) : void 0;
+      if (canvas.freeDrawingBrush) canvas.fire('brush:start', { shape: shape ?? void 0 });
+      else canvas.fire('brush:end', { shape: oldValue[1] ?? void 0 });
+
+      onCleanup(() => {
+        if (isShapeBrush(canvas.freeDrawingBrush)) canvas.freeDrawingBrush.destroy();
+      });
+    },
+    { immediate: true, flush: 'post' }
+  );
+  useFabricEventListener(canvas, 'brush:start', (event) => {
+    const canvas = toValue(context.canvas);
+    if (!canvas) return;
+    canvas.isDrawingMode = true;
+    canvas.freeDrawingBrush!.color = `#6464FF`;
+
+    if (object.value?.canvas) {
+      canvas.remove(object.value);
+      canvas.requestRenderAll();
+    }
+  });
+  useFabricEventListener(canvas, 'brush:end', () => {
+    const canvas = toValue(context.canvas);
+    if (!canvas) return;
+    canvas.freeDrawingBrush = void 0;
+    canvas.isDrawingMode = false;
+
+    if (object.value?.canvas) object.value.visible = true;
+  });
+  useFabricEventListener(canvas, 'before:object:created', (event) => {
+    const box = event.object;
+    box.lockRotation = true;
+    box.setControlVisible('mtr', false);
+    if (isRef(context.shape)) context.shape.value = null;
+
+    const canvas = toValue(context.canvas);
+    canvas?.setActiveObject(event.object);
+  });
+  useFabricEventListener(canvas, 'object:created', (event) => {
+    if (object.value?.canvas) object.value.canvas.remove(object.value);
+    object.value = event.object;
+    const canvas = toValue(context.canvas);
+    canvas?.bringObjectToFront(object.value);
+  });
+
+  return object;
+}

+ 138 - 0
src/modules/monitor/Cropper/composables/useTools.ts

@@ -0,0 +1,138 @@
+import type { MaybeRef } from 'vue';
+import type { FabricImage, FabricObject, XY } from 'fabric';
+import type { FabricContext, UnwrapperContext } from '@/lib/fabric';
+import type { Shape } from '@/lib/fabric/brush';
+import type { ToolbarTool } from '@/lib/fabric/components/toolbar';
+import type { FlipKey, ScaleToContainerMode } from '../actions';
+import { bindContext } from '@/lib/fabric';
+import { flipObject, scaleObject, scaleObjectToContainer } from '../actions';
+
+interface ToolContext extends FabricContext {
+  shape: MaybeRef<Shape | null>;
+  image: MaybeRef<FabricImage | null>;
+  box: MaybeRef<FabricObject | null>;
+}
+
+export type UnContext = UnwrapperContext<ToolContext>;
+
+export function useTools(context: ToolContext, crop: any) {
+  const shape = computed(() => toValue(context.shape) ?? void 0);
+  const tools = computed<ToolbarTool[]>(() => [
+    {
+      type: 'dropdown-button',
+      key: 'shape',
+      label: '裁剪框',
+      icon: 'mdi:crop',
+      selectedKey: shape,
+      options: [
+        { key: 'rect', label: '矩形', title: '矩形标注框', icon: 'mdi:rectangle-outline' },
+        { key: 'circle', label: '圆形', title: '圆形标注框', icon: 'mdi:circle-outline' },
+        { key: 'ellipse', label: '椭圆', title: '椭圆标注框', icon: 'mdi:ellipse-outline' },
+        { key: 'polygon', label: '多边形', title: '多边形标注', icon: 'mdi:shape-polygon-plus' },
+      ],
+      action(payload) {
+        if (isRef(context.shape)) context.shape.value = payload.value as Shape;
+      },
+      allowClear: false,
+    },
+    {
+      type: 'dropdown-button',
+      key: 'fit',
+      label: '缩放',
+      icon: 'mdi:image-filter-center-focus-weak',
+      options: [
+        { key: 'zoom-in', label: '放大', icon: 'mdi:zoom-in', params: { factor: 1.25 } },
+        { key: 'zoom-out', label: '缩小', icon: 'mdi:zoom-out', params: { factor: 0.8 } },
+        { key: 'fit-centre', label: '适应画布', icon: 'mdi:fit-to-page-outline', params: { factor: 'cover' } },
+        { key: 'fit-width', label: '水平适配', icon: 'mdi:arrow-expand-horizontal', params: { factor: 'width' } },
+        { key: 'fit-height', label: '垂直适配', icon: 'mdi:arrow-expand-vertical', params: { factor: 'height' } },
+      ],
+      action(payload) {
+        const params = payload.tool.options.find((option) => option.key === payload.value)?.params as any;
+        if (params?.factor) bind(params.factor)(setImageScale);
+      },
+    },
+    {
+      type: 'dropdown-button',
+      key: 'rotate',
+      label: '旋转',
+      icon: 'mdi:rotate-360',
+      options: [
+        { key: 'rotate-right', label: '向右旋转', title: '顺时针旋转 90°', icon: 'mdi:rotate-right', params: { degrees: 90 } },
+        { key: 'rotate-left', label: '向左旋转', title: '逆时针旋转 90°', icon: 'mdi:rotate-left', params: { degrees: -90 } },
+      ],
+      defaultIndex: 0,
+      action(payload) {
+        const params = payload.tool.options.find((option) => option.key === payload.value)?.params as any;
+        if (params?.degrees) bind(params.degrees, true)(setImageRotate);
+      },
+    },
+    {
+      type: 'menu-button',
+      key: 'flip',
+      label: '镜像',
+      icon: 'mdi:flip-horizontal',
+      options: [
+        { key: 'flip-h', label: '水平镜像', title: '水平翻转图片', icon: 'mdi:flip-horizontal', params: { type: 'flipX' } },
+        { key: 'flip-v', label: '垂直镜像', title: '垂直翻转图片', icon: 'mdi:flip-vertical', params: { type: 'flipY' } },
+      ],
+      action(payload) {
+        const params = payload.tool.options.find((option) => option.key === payload.value)?.params as any;
+        if (params?.type) bind(params.type)(toggleImageFlip);
+      },
+    },
+    { type: 'button', key: 'reset', label: '重置', icon: 'mdi:refresh', title: '重置图片与裁剪框', side: 'right', action: () => bind()(resetImage) },
+    {
+      type: 'button',
+      key: 'export',
+      label: '完成',
+      icon: 'mdi:check-circle-outline',
+      side: 'right',
+      style: {
+        '--toolbar-color-hover-bg': '#f0f9ff',
+        '--toolbar-color-control-bg': '#faf5ff',
+        '--toolbar-color-control-border': '#a78bfa',
+        '--toolbar-color-text': '#6d28d9',
+      },
+      action: crop,
+    },
+  ]);
+
+  const bind = bindContext(context);
+  const toggleImageFlip = ({ canvas, image }: UnContext, key: FlipKey) => {
+    if (!canvas || !image) return;
+    flipObject(image, key);
+    canvas.requestRenderAll();
+  };
+  const setImageRotate = ({ canvas, image }: UnContext, degrees: number, right?: boolean) => {
+    if (!canvas || !image) return;
+    const rotation = image.getTotalAngle() + degrees;
+    let angle = rotation % 360;
+    if (right) angle = Math.round(angle / 90) * 90;
+    image.set('angle', angle < 0 ? angle + 360 : angle);
+    image.setCoords();
+    image.dirty = true;
+    canvas.fire('object:modified', { action: 'tool', target: image });
+    canvas.requestRenderAll();
+  };
+  const setImageScale = ({ canvas, image }: UnContext, factor: number | ScaleToContainerMode) => {
+    if (!canvas || !image) return;
+    const center = canvas.getCenterPoint();
+    const width = canvas.getWidth();
+    const height = canvas.getHeight();
+    if (typeof factor === 'string') scaleObjectToContainer(image, { width, height, center }, factor);
+    else scaleObject(image, factor, { center, uniform: true });
+    canvas.fire('object:modified', { action: 'tool', target: image });
+    canvas.requestRenderAll();
+  };
+  const resetImage = (unContext: UnContext) => {
+    if (unContext.box) {
+      unContext.canvas?.remove(unContext.box);
+      if (isRef(context.box)) context.box.value = null;
+    }
+    if (unContext.image) unContext.image.set({ angle: 0, flipX: false, flipY: false });
+    setImageScale(unContext, 'contain');
+  };
+
+  return tools;
+}

+ 2 - 0
src/modules/monitor/Cropper/index.ts

@@ -0,0 +1,2 @@
+export {default as Cropper} from './Cropper.vue'
+export type { CropperExportObject } from './actions';

+ 62 - 0
src/modules/monitor/annotator.page.vue

@@ -0,0 +1,62 @@
+<script setup lang="ts">
+import NavHomeSelect from '@/assets/images/nav-home.select.png?url';
+
+import type { MedicalData, MedicalString } from '@/request/model/medical-record.model';
+import { useRouteMeta } from '@/router/hooks/useRouteMeta';
+import { useLocalStorage } from '@vueuse/core';
+import { useVisitor } from '@/stores';
+import { useFloatPanel } from '@/composables/FloatPanel';
+import MedicalReportEdit from '@/modules/monitor/components/MedicalReportEdit.vue';
+import MedicalRecordPreview from '@/modules/monitor/components/MedicalRecordPreview.vue';
+
+const title = useRouteMeta('title', '图像处理', { priority: 'query' });
+
+const Visitor = useVisitor();
+const { patient, patientId } = storeToRefs(Visitor);
+const registerPatient = useLocalStorage(() => `${patientId.value}_data`, {});
+
+const record = computed<MedicalString>(() => {
+  return {
+    patient: { patientId: patientId.value, ...patient.value },
+    report: JSON.stringify({ patientId: patientId.value, patient: registerPatient.value }),
+  } as MedicalString;
+});
+
+const edit = useTemplateRef<InstanceType<typeof MedicalReportEdit>>('edit');
+const [FloatPanel, panel] = useFloatPanel<MedicalData, MedicalData>(MedicalRecordPreview);
+const onPreview = () => {
+  const data = edit.value?.preview();
+  if (data) panel.open(data as unknown as MedicalData);
+};
+
+const completeProps = shallowRef<MedicalData>();
+const router = useRouter();
+const onBack = () => {
+  router.push({ path: '/screen', replace: true });
+};
+</script>
+
+<template>
+  <div class="page-container">
+    <div class="page-header flex py-4 px-4 overflow-hidden">
+      <div class="grow shrink-0 h-full min-w-16"></div>
+      <div class="grow-[3] shrink mx-2 flex flex-col justify-center overflow-hidden">
+        <div class="font-bold text-3xl text-nowrap text-center tracking-wide overflow-ellipsis overflow-hidden">{{ title }}</div>
+      </div>
+      <div class="grow shrink-0 h-full min-w-16 flex items-center justify-end overflow-hidden">
+        <router-link :to="{ path: '/screen' }" replace>
+          <img class="size-8 object-scale-down" :src="NavHomeSelect" alt="返回首页" />
+        </router-link>
+      </div>
+    </div>
+    <MedicalRecordPreview v-if="completeProps" v-bind="completeProps" @cancel="onBack" />
+    <MedicalReportEdit v-else ref="edit" v-bind="record" @cancel="onBack" @complete="completeProps = $event">
+      <template #actions>
+        <van-button block type="primary" @click="onPreview()">预览</van-button>
+      </template>
+    </MedicalReportEdit>
+    <FloatPanel auto-height closable />
+  </div>
+</template>
+
+<style scoped lang="scss"></style>

+ 58 - 0
src/modules/monitor/components/MedicalPatientEdit.vue

@@ -0,0 +1,58 @@
+<script setup lang="ts">
+import type { MedicalData, MedicalString } from '@/request/model/medical-record.model';
+import type { RegisterModel } from '@/request/model';
+import type RegisterForm from '@/components/RegisterForm.vue';
+
+import { Notify } from '@/platform';
+import { useRequest } from 'alova/client';
+import { editMedicalRecordMethod } from '@/request/api/medical.api';
+import { getMedicalReportData } from '@/request/model/medical-record.model';
+import { updateMedicalPatient } from '@/request/model/medical-patient.model';
+
+const props = defineProps<Partial<MedicalString>>();
+const emits = defineEmits<{
+  complete: [MedicalString];
+  cancel: [];
+}>();
+
+const {
+  data: form,
+  loading: submitting,
+  send: submit,
+} = useRequest(editMedicalRecordMethod, { initialData: { ...props }, immediate: false }).onSuccess(({ data }) => emits('complete', data));
+
+const formRef = ref<InstanceType<typeof RegisterForm> | null>(null);
+const report = computed(() => {
+  try {
+    return getMedicalReportData(props.report ?? '');
+  } catch (e: any) {
+    Notify.error(e.message);
+    emits('cancel');
+  }
+});
+watch(
+  () => report.value?.patient,
+  (value) => {
+    if (!value) return;
+    nextTick(() => formRef.value?.setValues(value));
+  },
+  { immediate: true, flush: 'post' }
+);
+
+function onSubmitFromForm(payload: { model: RegisterModel; modelLabel: Partial<RegisterModel> }) {
+  submit(updateMedicalPatient(form.value, payload));
+}
+</script>
+
+<template>
+  <div class="p-6" style="height: calc(100vh - 30px)">
+    <RegisterForm ref="formRef" :search-forbidden-field="false" @submit="onSubmitFromForm" />
+    <van-loading class="flex justify-center" v-if="formRef?.loading" type="spinner" size="64" color="#38ff6e" />
+    <div v-else class="flex gap-4 mb-6 px-4 py-3">
+      <van-button block type="primary" plain :disabled="submitting" @click="emits('cancel')">取消</van-button>
+      <van-button block type="primary" :loading="submitting" @click="formRef?.submit()">保存</van-button>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss"></style>

+ 60 - 0
src/modules/monitor/components/MedicalRecordPreview.vue

@@ -0,0 +1,60 @@
+<script setup lang="ts">
+import type { MedicalString, MedicalData, MedicalModel } from '@/request/model/medical-record.model';
+import { getMedicalReportData } from '@/request/model/medical-record.model';
+import { Notify, Toast } from '@/platform';
+import { printFromUrl } from '@/platform/file';
+import { usePrint } from '@/modules/monitor/composables/usePrint';
+import MedicalReportPreview from '@/modules/monitor/components/MedicalReportPreview.vue';
+import { editMedicalRecordMethod, uploadFileMethod } from '@/request/api/medical.api';
+
+const props = defineProps<Partial<MedicalData>>();
+const emits = defineEmits<{
+  complete: [MedicalModel];
+  cancel: [];
+}>();
+
+const data = ref<MedicalModel>();
+const { printing, print } = usePrint(data);
+
+const handle = async () => {
+  let toast;
+  try {
+    const url = data.value?.report.reportURL ?? (await upload());
+    if (!url) return Notify.warning(`未获取到报告地址,请联系管理员或重试`);
+
+    toast = Toast.loading(100, { message: '开始打印' });
+    await printFromUrl(url, { rollback: true, title: `扫一扫 获取报告` });
+    if (data.value?.report.reportURL !== url) updateReport(url);
+  } finally {
+    toast?.close();
+  }
+};
+const upload = async () => {
+  const file = await print();
+  const toast = Toast.loading(100, { message: `文档准备中...` });
+  try {
+    return await uploadFileMethod(file).send(true);
+  } finally {
+    toast?.close();
+  }
+};
+const updateReport = (url: string) => {
+  data.value!.report.reportURL = url;
+  if (!url || !props.code || typeof props.report !== 'string') return;
+  const report = getMedicalReportData(props.report);
+  report.reportPdfUrl = url;
+  editMedicalRecordMethod({ ...props, report: JSON.stringify(report) } as MedicalString).send(true);
+};
+</script>
+
+<template>
+  <div class="page-content flex flex-col overflow-auto" style="height: calc(100vh - 30px)">
+    <MedicalReportPreview v-bind="props" @ready="data = $event" @cancel="emits('cancel')" />
+    <div class="flex gap-4 px-4 py-3">
+      <van-button block type="primary" plain :disabled="printing" @click="emits('cancel')">关闭</van-button>
+      <van-button block type="primary" :loading="printing" loading-text="生成中…" @click="handle">打印</van-button>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss"></style>

+ 177 - 0
src/modules/monitor/components/MedicalReportEdit.vue

@@ -0,0 +1,177 @@
+<script setup lang="ts">
+import type { FloatPanelProps } from '@/composables/FloatPanel';
+import type { MedicalString, MedicalData, MedicalModel } from '@/request/model/medical-record.model';
+import { showFailToast } from 'vant';
+import { useRequest, useSerialWatcher } from 'alova/client';
+import { getAnnotatorDictionaryMethod, getMedicalRecordMethod, editMedicalRecordMethod } from '@/request/api/medical.api';
+import { toMedicalRecord, fromMedicalReport, getMedicalReportData } from '@/request/model/medical-record.model';
+import { useFloatPanel } from '@/composables/FloatPanel';
+import { useAnnotatorPicker } from '@/modules/monitor/composables/useAnnotatorPicker';
+import { useAnnotatorFlow } from '@/modules/monitor/composables/useAnnotatorFlow';
+import { Annotator } from '@/modules/monitor/Annotator';
+import { Cropper } from '@/modules/monitor/Cropper';
+import PatientInfo from '@/modules/monitor/components/PatientInfo.vue';
+import PictureUpload from '@/modules/monitor/components/PictureUpload.vue';
+
+const props = defineProps<MedicalData | MedicalString>();
+const emits = defineEmits<{
+  complete: [MedicalData];
+  cancel: [];
+}>();
+
+const { loading: submitting, send: submit } = useRequest(editMedicalRecordMethod, { immediate: false }).onSuccess(({ data }) => {
+  emits('complete', { ...data, report: getMedicalReportData(data.report) });
+});
+
+const { data: dictionaries } = useRequest(getAnnotatorDictionaryMethod);
+const { data, loading } = useSerialWatcher(
+  [() => getAnnotatorDictionaryMethod(), (dictionaries) => getMedicalRecordMethod(props, dictionaries)],
+  [() => props.code, () => props.report],
+  {
+    immediate: true,
+    initialData: { report: {} } as MedicalModel,
+    middleware(_, next) {
+      if (props.code || props.report) next();
+    },
+  }
+).onError(() => emits('cancel'));
+
+const panelProps = reactive<FloatPanelProps & { type?: 'annotator' | 'cropper' }>({ title: '', closable: true, contentDraggable: false });
+const [FloatPanel, panel] = useFloatPanel();
+
+const [Picker, picker] = useAnnotatorPicker(dictionaries, { dynamicTabs: true });
+const { pictures, upload, onCrop, onPictureAnnotator, onTableAnnotator, onDeleteAnnotator } = useAnnotatorFlow(data, {
+  opener({ props, ...options }) {
+    for (const [key, value] of Object.entries(options)) (panelProps as any)[key] = value;
+    return panel.open(props as any) as any;
+  },
+  picker({ props: { scope, seed }, ...options }) {
+    seed = Array.isArray(seed) ? seed : seed ? [seed] : [];
+    return picker.open(scope, seed, options) as any;
+  },
+});
+
+const save = () => {
+  if (pictures.value.some((picture) => picture.url)) {
+    submit(data.value);
+  } else showFailToast(`请先上传图像并处理`);
+};
+defineExpose({
+  preview() {
+    return fromMedicalReport(toMedicalRecord(data.value));
+  },
+});
+</script>
+
+<template>
+  <div class="page-content flex flex-col overflow-auto">
+    <div class="mx-6" v-if="data.code">
+      <span>病案编号:</span>
+      <van-tag plain class="text-lg" size="large">{{ data.code }}</van-tag>
+    </div>
+    <div class="card m-6 text-lg" v-if="data.patient">
+      <div class="card__title mb-3 text-primary text-2xl font-bold">基本信息</div>
+      <div class="card__content">
+        <PatientInfo v-bind="data.patient" />
+      </div>
+    </div>
+    <div class="card m-6 text-lg">
+      <div class="card__title mb-3 text-primary text-2xl font-bold">图像处理</div>
+      <div class="card__content grid grid-cols-3 gap-4 justify-items-center">
+        <PictureUpload
+          v-for="picture in pictures"
+          :key="picture.key"
+          :label="picture.label"
+          :disabled="submitting"
+          v-model:url="picture.url"
+          @complete="onCrop(picture.key)"
+          @click="onPictureAnnotator(picture.key)"
+          @delete="onDeleteAnnotator(picture.key)"
+        />
+      </div>
+    </div>
+    <div class="card annotator-wrapper m-6 text-lg" v-if="data.report">
+      <div class="card__title mb-3 text-primary text-2xl font-bold">结果编辑</div>
+      <div class="card__content">
+        <table class="mt-4 mb-2 w-full table-auto border border-collapse border-primary">
+          <thead>
+            <tr>
+              <th class="py-4 px-2 text-primary border border-primary" v-for="(value, i) in data.report.tongue?.table?.columns" :key="i" v-html="value"></th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr
+              v-for="item in data.report.tongue?.table?.data"
+              :key="item.columns[0]"
+              :data-exception="item.exception"
+              :data-invalid="item.invalid"
+              @click="onTableAnnotator('tongue', item)"
+            >
+              <td class="py-4 px-2 border border-primary text-center text-grey" v-for="(value, i) in item.columns" :key="i" v-html="value"></td>
+            </tr>
+          </tbody>
+        </table>
+        <table class="mt-4 mb-2 w-full table-auto border border-collapse border-primary">
+          <thead>
+            <tr>
+              <th class="py-4 px-2 text-primary border border-primary" v-for="(value, i) in data.report.face?.table?.columns" :key="i" v-html="value"></th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr
+              v-for="item in data.report.face?.table?.data"
+              :key="item.columns[0]"
+              :data-exception="item.exception"
+              :data-invalid="item.invalid"
+              @click="onTableAnnotator('face', item)"
+            >
+              <td class="py-4 px-2 border border-primary text-center text-grey" v-for="(value, i) in item.columns" :key="i" v-html="value"></td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+      <Picker />
+    </div>
+    <van-loading class="mx-auto" v-if="loading" type="spinner" size="64" color="#38ff6e" />
+    <div v-else class="flex gap-4 mb-6 px-4 py-3">
+      <van-button block type="primary" plain :disabled="submitting" @click="emits('cancel')">取消</van-button>
+      <slot name="actions" :data="data" :disabled="submitting"></slot>
+      <van-button block type="primary" :loading="submitting" @click="save()">保存</van-button>
+    </div>
+
+    <FloatPanel auto-height v-bind="panelProps">
+      <template #default="props">
+        <Annotator v-if="panelProps.type === 'annotator'" :upload v-bind="props" />
+        <Cropper v-if="panelProps.type === 'cropper'" :upload v-bind="props" />
+      </template>
+    </FloatPanel>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.page-content :deep(.card) {
+  padding: 24px;
+  border-radius: 24px;
+  box-shadow: inset 0 0 80px 0 #34a76b60;
+
+  .van-tag {
+    --van-tag-font-size: 14px;
+  }
+}
+.annotator-wrapper {
+  tr {
+    th:first-of-type {
+      width: 160px;
+    }
+    th:last-of-type {
+      width: 180px;
+    }
+  }
+  tr[data-exception='true'] td:nth-of-type(2) {
+    color: #f87171;
+  }
+  tr[data-invalid='true'] td:nth-of-type(2) {
+    color: #9ca3af;
+  }
+}
+</style>

+ 67 - 0
src/modules/monitor/components/MedicalReportPreview.vue

@@ -0,0 +1,67 @@
+<script setup lang="ts">
+import { useWatcher } from 'alova/client';
+import { getMedicalRecordMethod } from '@/request/api/medical.api';
+
+import PatientInfo from '@/modules/monitor/components/PatientInfo.vue';
+import type { MedicalModel, MedicalData, MedicalString } from '@/request/model/medical-record.model';
+
+const props = defineProps<Partial<MedicalString | MedicalData>>();
+const emits = defineEmits<{
+  ready: [MedicalModel];
+  cancel: [];
+}>();
+const { data, loading } = useWatcher(() => getMedicalRecordMethod(props, []), [() => props.code, () => props.report], {
+  immediate: true,
+  initialData: { report: {} } as MedicalModel,
+  middleware(_, next) {
+    if (props.code || props.report) next();
+  },
+})
+  .onSuccess(({ data }) => emits('ready', data))
+  .onError(() => emits('cancel'));
+
+defineExpose({ data, loading });
+</script>
+
+<template>
+  <div class="report-wrapper">
+    <van-skeleton title :row="3" :loading>
+      <div v-if="data" class="flex-auto overflow-y-auto">
+        <div class="my-6 text-primary text-2xl text-center" v-if="data.report.date">报告日期:{{ data.report.date }}</div>
+        <div class="mx-6" v-if="props?.code">
+          <span>病案编号:</span>
+          <van-tag plain class="text-lg" size="large">{{ props?.code }}</van-tag>
+        </div>
+        <div class="card m-6 text-lg">
+          <div class="card__title mb-3 text-primary text-2xl font-bold">基本信息</div>
+          <div class="card__content">
+            <PatientInfo v-bind="data.patient" />
+          </div>
+        </div>
+        <div class="analysis-wrapper">
+          <AnalysisComponent title="舌象分析" v-bind="data.report.tongue" :cover="[]" filter-empty-exception></AnalysisComponent>
+          <AnalysisComponent title="面象分析" v-bind="data.report.face" :cover="[]" filter-empty-exception></AnalysisComponent>
+        </div>
+      </div>
+    </van-skeleton>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.report-wrapper :deep(.card) {
+  padding: 24px;
+  border-radius: 24px;
+  box-shadow: inset 0 0 80px 0 #34a76b60;
+
+  .van-tag {
+    --van-tag-font-size: 14px;
+  }
+}
+.analysis-wrapper {
+  :deep(.grid) {
+    img[alt='分析异常图像'] {
+      width: 100% !important;
+    }
+  }
+}
+</style>

+ 50 - 0
src/modules/monitor/components/PatientInfo.vue

@@ -0,0 +1,50 @@
+<script setup lang="ts">
+import type { FieldKey, RegisterModel } from '@/request/model';
+import type { DisplayPatientRowFieldConfig } from '@/request/model/medical-patient.model';
+import { displayMedicalPatient } from '@/request/model/medical-patient.model';
+
+interface Props {
+  cardno: RegisterModel['cardno'] | string;
+  phone: RegisterModel['phone'] | string;
+  code: RegisterModel['code'] | string;
+  name: RegisterModel['name'] | string;
+  sex: RegisterModel['sex'] | string;
+  age: RegisterModel['age'] | string;
+  height: RegisterModel['height'] | string;
+  weight: RegisterModel['weight'] | string;
+  isEasyAllergy: RegisterModel['isEasyAllergy'] | string;
+  womenSpecialPeriod: RegisterModel['womenSpecialPeriod'] | string;
+  foodAllergy: RegisterModel['foodAllergy'] | string;
+  hobbyFlavor: RegisterModel['hobbyFlavor'] | string;
+  drinkState: RegisterModel['drinkState'] | string;
+  smokeState: RegisterModel['smokeState'] | string;
+  address: RegisterModel['address'] | string;
+  detailAddress: RegisterModel['detailAddress'] | string;
+  job: RegisterModel['job'] | string;
+}
+
+const patient = withDefaults(defineProps<Partial<Props>>(), {
+  isEasyAllergy: void 0,
+  drinkState: void 0,
+  smokeState: void 0,
+});
+const rows = shallowRef<DisplayPatientRowFieldConfig[][]>([
+  [{ key: 'name', hideLabel: true }, { key: 'sex', hideLabel: true }, { key: 'age', hideLabel: true }, { key: 'womenSpecialPeriod' }, { key: 'cardno' }, { key: 'phone' }],
+  [{ key: 'height' }, { key: 'weight' }, { key: 'drinkState' }, { key: 'smokeState' }, { key: 'isEasyAllergy' }],
+  [{ key: 'foodAllergy' }, { key: 'hobbyFlavor' }, { key: 'job' }],
+  [{ key: 'address' }, { key: 'detailAddress', hideLabel: (model) => !!model.address }],
+]);
+
+const renderRows = computed(() => displayMedicalPatient(patient, rows.value));
+</script>
+
+<template>
+  <div class="flex flex-col gap-3">
+    <div v-for="(row, rowIndex) in renderRows" :key="`row-${rowIndex}`" class="flex flex-wrap items-center gap-4">
+      <div v-for="field in row" :key="field.key" class="inline-flex items-center gap-2">
+        <label v-if="!field.hideLabel" class="text-primary/80">{{ field.label }}</label>
+        <span>{{ field.value }}{{ field.suffix }}</span>
+      </div>
+    </div>
+  </div>
+</template>

+ 100 - 0
src/modules/monitor/components/PictureUpload.vue

@@ -0,0 +1,100 @@
+<script setup lang="ts">
+import { ref } from 'vue';
+import { tryOnMounted, tryOnUnmounted, type UseFileDialogOptions } from '@vueuse/core';
+import { OnLongPress } from '@vueuse/components';
+import { useFile } from '@/modules/monitor/composables/useFile';
+
+interface Props {
+  label: string;
+  disabled?: boolean;
+  update?: (file: File) => Promise<string>;
+}
+
+const props = defineProps<Props>();
+const emits = defineEmits<{
+  complete: [string];
+  delete: [string];
+  click: [string, PointerEvent];
+}>();
+
+const loading = ref(false);
+const url = defineModel('url', { default: '' });
+
+const support = ref(false);
+
+const dialog = useFile({ multiple: false, accept: 'image/*', reset: true });
+
+const onTrigger = () => {
+  if (props.disabled) return;
+  if (url.value) emits('delete', url.value);
+  else if (support.value) onTake({ capture: 'user' });
+};
+
+const onClick = (event: PointerEvent) => {
+  if (props.disabled) return;
+  if (url.value) emits('click', url.value, event);
+  else onTake();
+};
+
+const onTake = async (options?: UseFileDialogOptions) => {
+  let file = await dialog.open(options);
+  try {
+    loading.value = true;
+    url.value = (await props.update?.(file)) ?? URL.createObjectURL(file);
+    emits('complete', url.value);
+  } finally {
+    loading.value = false;
+  }
+};
+const ancient = new Set<string>();
+watch(url, (value, oldValue) => {
+  if (value) ancient.add(value);
+  if (oldValue) ancient.add(oldValue);
+});
+tryOnMounted(() => {
+  const isTouch = window.matchMedia('(pointer: coarse)').matches;
+  const supportsCamera = !!navigator.mediaDevices?.getUserMedia;
+  support.value = isTouch && supportsCamera;
+});
+tryOnUnmounted(() => {
+  for (const url of ancient) URL.revokeObjectURL(url);
+});
+</script>
+
+<template>
+  <div class="picture-container">
+    <div class="flex items-center mb-2">
+      <label>{{ props.label }}</label>
+      <template v-if="url">
+        <van-button class="!ml-2 !px-0.5 !text-sm" icon="edit" type="primary" round size="mini" :disabled="props.disabled" @click="onClick"></van-button>
+        <van-button class="!ml-2 !px-0.5 !text-sm" icon="delete-o" type="danger" round size="mini" :disabled="props.disabled" @click="onTrigger()"></van-button>
+      </template>
+    </div>
+    <OnLongPress
+      as="div"
+      class="upload-wrapper flex flex-col justify-center items-center rounded-lg border-dashed aspect-square"
+      :class="[url ? 'border-none' : 'border-2']"
+      @click="onClick"
+      @trigger="onTrigger()"
+    >
+      <img class="size-full object-cover" v-if="url" :src="url" alt="" />
+      <template v-else>
+        <van-icon class="text-primary" name="photo-o" :size="50" />
+        <div class="text-sm mt-2 text-gray-300">
+          <span>点击上传</span>
+          <span v-if="support"> 或 长按拍摄</span>
+        </div>
+      </template>
+    </OnLongPress>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.picture-container {
+  max-width: 180px;
+  width: 100%;
+}
+.upload-wrapper {
+  border-color: hsl(var(--primary-hover) / 0.5);
+}
+</style>

+ 213 - 0
src/modules/monitor/composables/useAnnotatorFlow.ts

@@ -0,0 +1,213 @@
+import type { Ref } from 'vue';
+import type { CropperExportObject } from '@/modules/monitor/Cropper';
+import type { AnnotatorExportObject, AnnotatorObject } from '@/modules/monitor/Annotator';
+import type { AnalysisModel } from '@/request/model';
+import type { MedicalModel } from '@/request/model/medical-record.model';
+import type { MedicalReportPictureKey, MedicalReportTableKey } from '@/request/model/medical-report.model';
+import { tryOnUnmounted } from '@vueuse/core';
+import { groupBy } from 'es-toolkit/array';
+import { showFailToast } from 'vant';
+import { Dialog } from '@/platform';
+import { toPictureKey, toTableKey } from '@/request/model/medical-report.model';
+import { hashStringToColor } from '@/modules/monitor/tools/color';
+import { uploadFileMethod } from '@/request/api/medical.api';
+
+type PictureKey = MedicalReportPictureKey;
+type TableKey = MedicalReportTableKey;
+type Row = AnalysisModel['table']['data'][number];
+
+interface PanelProps {
+  type: 'annotator' | 'cropper';
+  title: string;
+  closable?: boolean;
+  full?: boolean;
+}
+interface PickerProps {
+  title?: string;
+  withDefault?: boolean;
+}
+
+export interface UseAnnotatorFlowOptions {
+  opener<R, P = Record<string, any>>(payload: PanelProps & { props: P }): Promise<R>;
+  picker<R>(payload: PickerProps & { props: { scope: PictureKey | string; seed?: string | string[] } }): Promise<R>;
+}
+
+export function useAnnotatorFlow(data: Ref<MedicalModel>, options: UseAnnotatorFlowOptions) {
+  const report = computed(() => data.value.report);
+  const pictures = ref<{ label: string; url: string; key: MedicalReportPictureKey }[]>([
+    { label: '舌面图像', url: '', key: 'upImg' },
+    { label: '舌下图像', url: '', key: 'downImg' },
+    { label: '面部图像', url: '', key: 'faceImg' },
+  ]);
+  const init = () => {
+    if (!report.value) return;
+    for (const picture of pictures.value) picture.url = report.value[picture.key]?.object?.src ?? '';
+    updateAnnotator('tongue');
+    updateAnnotator('face');
+  };
+  watch(data, init, { immediate: true, deep: false });
+
+  const upload = async (file: File | Blob, name?: string): Promise<string> => {
+    const url = await uploadFileMethod(file as File, name);
+    rubbish.add(url);
+    return url;
+  };
+
+  const onCrop = async (key: PictureKey) => {
+    const target = report.value?.[key];
+    const picture = pictures.value.find((picture) => picture.key === key);
+    if (!target || !picture || !picture.url) return;
+    rubbish.add(picture.url);
+
+    const update = (value: string) => {
+      picture.url = value;
+      triggerRef(data);
+      rubbish.add(value);
+    };
+    target.object = await options.opener<CropperExportObject>({
+      type: 'cropper',
+      title: `裁剪${picture.label}`,
+      full: false,
+      closable: false,
+      props: { url: picture.url, 'onUpdate:url': update },
+    });
+    if (target.object) await onPictureAnnotator(key);
+  };
+  const onPictureAnnotator = async (key: PictureKey) => {
+    const target = report.value?.[key];
+    const picture = pictures.value.find((picture) => picture.key === key);
+    if (!target || !picture || !picture.url) return;
+
+    const picker = async (object?: AnnotatorObject, seed?: string) => {
+      if (!object) return;
+      seed ??= object.annotatorId;
+      try {
+        const values = await options.picker<string[]>({ title: `标注${picture.label}`, withDefault: false, props: { scope: key, seed } });
+        seed = values[0];
+        object.stroke = hashStringToColor(seed);
+      } catch {}
+      return seed;
+    };
+    const result = await options.opener<AnnotatorExportObject>({
+      type: 'annotator',
+      title: `标注${picture.label}`,
+      full: true,
+      closable: true,
+      props: { url: picture.url, annotator: target.annotator, relation: target.relation, picker },
+    });
+    if (!result) return;
+    target.annotator = result.annotator;
+    target.relation = result.relation;
+    updateAnnotator(toTableKey(key)[0]);
+  };
+  const onTableAnnotator = async (key: TableKey, row: Row) => {
+    const target = report.value?.[key];
+    if (!target) return;
+
+    const rowKey = row.key;
+    const subKey = toPictureKey(rowKey).shift();
+    if (subKey) {
+      const picture = pictures.value.find((picture) => picture.key === subKey);
+      if (!picture?.url) return showFailToast(`请先上传${picture?.label || '图像'}`);
+    }
+
+    const pictureKey = toPictureKey(key);
+    const tableAnnotator = new Set<string>(target.annotator[rowKey]);
+    const pictureAnnotator = new Set<string>();
+    for (const key of pictureKey) {
+      const annotator = report.value?.[key].annotator ?? [];
+      for (const { annotatorId } of annotator) {
+        if (annotatorId?.startsWith(rowKey)) {
+          tableAnnotator.add(annotatorId);
+          pictureAnnotator.add(annotatorId);
+        }
+      }
+    }
+    const values = await options.picker<string[]>({ withDefault: true, props: { scope: rowKey, seed: Array.from(tableAnnotator) } });
+    target.annotator[rowKey] = values;
+    // 是否同步图像标注
+    for (const value of values) pictureAnnotator.delete(value);
+    if (
+      pictureAnnotator.size &&
+      (await Dialog.asyncConfirm({
+        title: `图像上仍有标注`,
+        message: `被移除的选项在图像上仍有标注,是否需要保留图像标注(可能与勾选结果不一致)?`,
+        confirmButtonText: '移除图像标注',
+        cancelButtonText: '保留图像标注',
+      }))
+    ) {
+      for (const key of pictureKey) {
+        const annotator = report.value?.[key].annotator ?? [];
+        if (annotator) report.value[key].annotator = report.value[key].annotator.filter((object) => !pictureAnnotator.has(object.annotatorId ?? ''));
+      }
+    }
+    updateAnnotator(key, rowKey);
+  };
+  const onDeleteAnnotator = async (key: PictureKey) => {
+    const picture = pictures.value.find((picture) => picture.key === key);
+    if (picture) {
+      if (!picture.url) return;
+      const pictureAnnotator = report.value?.[key]?.annotator?.map((object) => object.annotatorId) ?? [];
+      if (
+        await Dialog.asyncConfirm({
+          title: `确定删除${picture.label || '图像'}`,
+          message: pictureAnnotator.length ? `图像中的相关标注数据也将会同步参数` : void 0,
+          confirmButtonText: '删除',
+        })
+      ) {
+        picture.url = '';
+        report.value[key].annotator = [];
+
+        const [tableKey, skip] = toTableKey(key);
+        const target = report.value?.[tableKey];
+
+        if (skip.length) {
+          Object.keys(target.annotator).forEach((key) => {
+            if (!skip.includes(key)) target.annotator[key] = [];
+          });
+        } else {
+          const annotator = new Set(pictureAnnotator.map((annotatorId) => annotatorId?.split(':').shift() ?? ''));
+          for (const rowKey of annotator) {
+            target.annotator[rowKey] = target.annotator[rowKey]?.filter((annotatorId) => !pictureAnnotator.includes(annotatorId)) ?? [];
+          }
+        }
+        showSuccessToast(`删除${picture.label || '图像'}成功`);
+        updateAnnotator(tableKey);
+      }
+    }
+  };
+
+  function updateAnnotator(key: TableKey, sub?: string) {
+    const target = report.value?.[key];
+    if (!target) return;
+
+    const pictureAnnotator = toPictureKey(key).flatMap((key) => report.value?.[key]?.annotator ?? []);
+    const group = groupBy(pictureAnnotator, (object) => object.annotatorId?.split(':').shift() ?? '');
+
+    const keys = new Set<string>();
+    if (sub && target.table.data.some((row) => row.key === sub)) keys.add(sub);
+    else target.table.data.forEach((row) => keys.add(row.key));
+
+    for (const rowKey of keys) {
+      const row = target.table.data.find((row) => row.key === rowKey);
+      if (!row) continue;
+
+      const annotator = new Set<string>(target?.annotator?.[rowKey]);
+      group[rowKey]?.forEach((object) => annotator.add(object.annotatorId!));
+      const value = Array.from(annotator, (annotatorId) => annotatorId?.split(':').pop())
+        .filter(Boolean)
+        .sort()
+        .join('<br>');
+
+      row.columns[1] = value || (sub ? row.columns[2] : '');
+      row.exception = annotator.size > 1 || !row.columns[1].includes(row.columns[2]);
+    }
+  }
+
+  const rubbish = new Set<string>();
+  tryOnUnmounted(() => {
+    for (const url of rubbish) URL.revokeObjectURL(url);
+  });
+
+  return { pictures, upload, onCrop, onPictureAnnotator, onTableAnnotator, onDeleteAnnotator };
+}

+ 448 - 0
src/modules/monitor/composables/useAnnotatorPicker.ts

@@ -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;
+}

+ 22 - 0
src/modules/monitor/composables/useFile.ts

@@ -0,0 +1,22 @@
+import { useFileDialog, type UseFileDialogOptions } from '@vueuse/core';
+
+export function useFile(options?: UseFileDialogOptions) {
+  let withResolvers: Omit<PromiseWithResolvers<File>, 'promise'>;
+  const { open, onChange, onCancel } = useFileDialog(options);
+  const { off: stop1 } = onChange((files) => {
+    files?.length === 1 ? withResolvers?.resolve(files[0]) : withResolvers?.reject();
+  });
+  const { off: stop2 } = onCancel(() => withResolvers?.reject());
+  onScopeDispose(() => {
+    stop1();
+    stop2();
+  });
+  return {
+    async open(localOptions?: Partial<UseFileDialogOptions>) {
+      open(localOptions);
+      const { promise, ..._withResolvers } = Promise.withResolvers<File>();
+      withResolvers = _withResolvers;
+      return promise;
+    },
+  };
+}

+ 259 - 0
src/modules/monitor/composables/usePrint.ts

@@ -0,0 +1,259 @@
+import type { Ref } from 'vue';
+import type { ContentStack, ContentTable, TDocumentDefinitions } from 'pdfmake/build/pdfmake';
+import type { AnalysisException, AnalysisModel } from '@/request/model';
+import type { MedicalModel } from '@/request/model/medical-record.model';
+import { loadPdfMake } from '@/lib/pdfmake';
+import { Toast, Dialog } from '@/platform';
+import { getClientURL } from '@/tools';
+import { getToken } from '@/stores/account.store';
+import { getMedicalReportData } from '@/request/model/medical-record.model';
+import { normalizeCompareString } from '@/request/model/medical-report.model';
+import { displayMedicalPatient } from '@/request/model/medical-patient.model';
+import convert from '@/tools/file';
+
+const pageSize = {
+  width: 595.28,
+  height: 841.89,
+} as const;
+const pageMargins: [number, number, number, number] = [28, 28, 28, 28];
+const halfPageContentWidth = (pageSize.width - pageMargins[0] - pageMargins[2]) / 2;
+
+const defaultDefinitions = (info?: TDocumentDefinitions['info']) =>
+  ({
+    pageSize,
+    pageOrientation: 'portrait',
+    pageMargins,
+    info: { author: ``, subject: `病案报告`, keywords: '舌象 面象 标注', creator: '', producer: 'six', ...info },
+
+    defaultStyle: { font: 'NotoSansSC', fontSize: 14, lineHeight: 1.45, color: '#00000' },
+    styles: {
+      th: { bold: true, color: palette.primary, fontSize: 14, lineHeight: 1, fillColor: palette.background, alignment: 'center' },
+      td: { bold: false, color: palette.text, fontSize: 12, lineHeight: 1, alignment: 'center' },
+    },
+  }) satisfies Omit<TDocumentDefinitions, 'content'>;
+
+const palette = {
+  dark: '#1b4d35',
+  green: '#7de0a4',
+  red: '#e05c3a',
+  gray: '#666666',
+
+  text: '#000000',
+  primary: '#34a76b',
+  background: '#f0faf5',
+  border: '#34a76b',
+};
+
+export function usePrint(data: Ref<MedicalModel | undefined>) {
+  const printing = ref(false);
+  const definition = shallowRef<[string, TDocumentDefinitions]>();
+
+  const create = (source?: MedicalModel): [string, TDocumentDefinitions] => {
+    if (!source && definition.value) return definition.value!;
+    source ??= data.value;
+    if (!source) throw new Error(`数据为空`);
+    try {
+      const title = `病案报告 ${source.code ?? Date.now()}`;
+      const footerCode = source.code ?? '';
+      const report = getMedicalReportData(source?.report as any) as unknown as MedicalModel['report'];
+      const content: TDocumentDefinitions['content'] = [
+        { canvas: [{ type: 'rect', x: 0, y: 0, w: halfPageContentWidth * 2, h: 40, r: 4, color: palette.dark }] },
+        {
+          text: '病 案 报 告',
+          fontSize: 18,
+          lineHeight: 1,
+          color: palette.green,
+          bold: true,
+          alignment: 'center',
+          margin: [0, -34, 0, 6],
+        },
+        {
+          columns: [
+            { text: `病案编号:${source.code ?? '-'}`, fontSize: 10, color: palette.gray },
+            { text: `报告日期:${source.lastTime || report.date}`, fontSize: 10, color: palette.gray, alignment: 'right' },
+          ],
+          margin: [0, 12, 0, 0],
+        },
+
+        titleDefinition('基本信息'),
+        patientDefinition(source.patient),
+      ];
+      const images: TDocumentDefinitions['images'] = {};
+
+      if (report.tongue.result) {
+        content.push(titleDefinition('舌象分析'), tableDefinition(report.tongue.table));
+        const exception = report.tongue.exception.filter((item) => item.cover);
+        if (exception.length) content.push(exceptionDefinition(exception, extractImage(images)));
+      }
+      if (report.face.result) {
+        content.push(titleDefinition('面象分析'), tableDefinition(report.face.table));
+        const exception = report.face.exception.filter((item) => item.cover);
+        if (exception.length) content.push(exceptionDefinition(exception, extractImage(images)));
+      }
+
+      definition.value = [
+        title,
+        {
+          ...defaultDefinitions({ title }),
+          content,
+          images,
+          footer(currentPage, pageCount) {
+            return {
+              columns: [
+                { text: footerCode, fontSize: 9, color: palette.gray, alignment: 'left' },
+                { text: `第 ${currentPage} 页 / 共 ${pageCount} 页`, fontSize: 9, color: palette.gray, alignment: 'right' },
+              ],
+              margin: [pageMargins[0], 8, pageMargins[2], 0],
+            };
+          },
+        } satisfies TDocumentDefinitions,
+      ];
+      return definition.value!;
+    } catch (error: any) {
+      definition.value = void 0;
+      Dialog.asyncConfirm({
+        title: `无法创建文档`,
+        message: `请重试 (${error.message ?? '未知错误'})`,
+        showCancelButton: false,
+      }).then();
+      throw error;
+    }
+  };
+
+  const print = async (source?: MedicalModel): Promise<File> => {
+    printing.value = true;
+    const toast = Toast.loading(0, { message: '正在生成报告中…' });
+    try {
+      const [title, value] = create(source);
+      const filename = `${title.replace(/\s/, '_')}.pdf`;
+      const pdfMake = await loadPdfMake();
+      const pdf = pdfMake.createPdf(value);
+      const blob = await pdf.getBlob();
+      const file = await convert(blob, 'file', { filename });
+      Toast.success(`${title} 已生成`);
+      return file;
+    } catch (error: any) {
+      await Dialog.asyncConfirm({
+        title: `无法生成文档`,
+        message: `请重试 (${error.message ?? '未知错误'})`,
+        showCancelButton: false,
+      });
+      throw error;
+    } finally {
+      toast.close();
+      printing.value = false;
+    }
+  };
+
+  loadPdfMake().then(() => create());
+  return { printing, print };
+}
+
+function titleDefinition(text: string, color = palette.dark): ContentStack {
+  return {
+    stack: [{ canvas: [{ type: 'rect', x: 0, y: 0, w: 3, h: 18, r: 1, color }] }, { text, bold: true, color, margin: [8, -20, 0, 0], outline: true }],
+    margin: [0, 16, 0, 8],
+  };
+}
+
+function patientDefinition(patient: MedicalModel['patient']): ContentStack {
+  const rows = displayMedicalPatient(patient, [
+    [{ key: 'name', hideLabel: true }, { key: 'sex', hideLabel: true }, { key: 'age', hideLabel: true }, { key: 'womenSpecialPeriod' }],
+    [{ key: 'cardno' }, { key: 'phone' }],
+    [{ key: 'height' }, { key: 'weight' }],
+    [{ key: 'drinkState' }, { key: 'smokeState' }, { key: 'isEasyAllergy' }],
+    [{ key: 'foodAllergy' }],
+    [{ key: 'hobbyFlavor' }],
+    [{ key: 'job' }],
+    [{ key: 'address' }, { key: 'detailAddress', hideLabel: (model) => !!model.address }],
+  ]);
+  const stack = rows.map((row) => ({
+    columnGap: 16,
+    columns: row.map((field) => {
+      const label = field.hideLabel ? '' : `${field.label}:`;
+      return { width: 'auto', text: `${label}${field.value}${field.suffix}` };
+    }),
+  }));
+  return { margin: [0, 0, 0, 8], stack };
+}
+
+function tableDefinition(table: AnalysisModel['table']): ContentTable {
+  const header = table.columns.map((col) => ({ text: col, style: 'th', alignment: 'center' }));
+  const rows = table.data.flatMap((row) => {
+    const values = normalizeCompareString(row.columns[1]).split(/\s+/);
+    const span = values.length;
+    return values.map((value) => {
+      const trimmed = value.trim();
+      const middle =
+        trimmed === ''
+          ? { text: ''.padStart(6, ' '), style: 'td', decoration: 'lineThrough', color: palette.gray }
+          : { text: value, style: 'td', color: value === row.columns[2] ? palette.text : row.exception ? palette.red : row.invalid ? palette.gray : '#999999' };
+      return [
+        { text: row.columns[0], rowSpan: span, style: 'td', verticalAlignment: 'middle' },
+        middle,
+        { text: row.columns[2], rowSpan: span, style: 'td', verticalAlignment: 'middle' },
+      ];
+    });
+  });
+  return {
+    table: { headerRows: 1, widths: [100, '*', 140], body: [header, ...rows] as any },
+    layout: {
+      hLineWidth: () => 0.5,
+      vLineWidth: () => 0.5,
+      hLineColor: () => palette.border,
+      vLineColor: () => palette.border,
+      paddingLeft: () => 6,
+      paddingRight: () => 6,
+      paddingTop: () => 8,
+      paddingBottom: () => 8,
+    },
+  };
+}
+
+function exceptionDefinition(exception: AnalysisException[], extract: ReturnType<typeof extractImage>): ContentTable {
+  const gap = 12;
+  const toCell = (item?: AnalysisException) =>
+    item == null
+      ? { text: '' }
+      : {
+          unbreakable: true,
+          stack: [
+            {
+              text: item.title,
+              alignment: 'left',
+              fontSize: 16,
+              bold: true,
+              color: palette.red,
+              margin: [0, 6, 0, -6],
+            },
+            { image: extract(item.cover!), fit: [halfPageContentWidth - gap, halfPageContentWidth - gap], alignment: 'center' },
+          ],
+        };
+
+  const body = Array.from({ length: Math.ceil(exception.length / 2) }, (_, rowIndex) => [toCell(exception[rowIndex * 2]), toCell(exception[rowIndex * 2 + 1])] as any);
+
+  return {
+    margin: [0, 8, 0, 16],
+    table: {
+      widths: ['50%', '50%'],
+      body,
+    },
+    layout: 'noBorders',
+  };
+}
+
+function extractImage(images: NonNullable<TDocumentDefinitions['images']>) {
+  const cache = new Map<string, string>();
+  return (value: string): string => {
+    if (cache.has(value)) return cache.get(value)!;
+
+    const client = getClientURL(value);
+    if (client.startsWith(location.origin)) {
+      const src = `p` + cache.size;
+      cache.set(value, src);
+      cache.set(client, src);
+      images[src] = { url: client, headers: { Authorization: getToken() } };
+      return src;
+    } else return value;
+  };
+}

+ 254 - 0
src/modules/monitor/medical-record.page.vue

@@ -0,0 +1,254 @@
+<script setup lang="ts">
+import NavHomeSelect from '@/assets/images/nav-home.select.png?url';
+
+import type { CheckboxInstance } from 'vant';
+import type { MedicalString } from '@/request/model/medical-record.model';
+import dayjs from 'dayjs';
+import { usePagination } from 'alova/client';
+import { listMedicalRecordsMethod, deleteMedicalRecordMethod, exportMedicalRecordMethod } from '@/request/api/medical.api';
+import { downloadFromUrl } from '@/platform/file';
+import { useRouteMeta } from '@/router/hooks/useRouteMeta';
+import { useFloatPanel } from '@/composables/FloatPanel';
+import MedicalRecordPreview from '@/modules/monitor/components/MedicalRecordPreview.vue';
+import MedicalPatientEdit from '@/modules/monitor/components/MedicalPatientEdit.vue';
+
+const title = useRouteMeta('title', '病案管理', { priority: 'query' });
+const now = dayjs();
+
+const form = ref({ name: '', medicalCode: '', startDate: '', endDate: '' });
+const reset = () => {
+  form.value = { name: '', medicalCode: '', startDate: '', endDate: '' };
+  reload();
+};
+const { loading, data, isLastPage, page, total, replace, remove, reload, refresh } = usePagination((...args) => listMedicalRecordsMethod(...args, form.value), {
+  initialData: { total: 0, data: [] },
+  initialPage: 1,
+  initialPageSize: 20,
+  append: true,
+  immediate: true,
+})
+  .onSuccess(({ method }) => {
+    if ((method.config.params as any)?.page?.toString() === '1') selected.value = [];
+  })
+  .onComplete(() => (sending.value = false));
+const sending = ref(true);
+const onLoad = () => {
+  if (sending.value) return;
+  sending.value = true;
+  page.value += 1;
+};
+
+const floatPanelType = ref<'edit-patient' | 'preview'>();
+const [FloatPanel, panel] = useFloatPanel<MedicalString, MedicalString>();
+const onPreview = (item: MedicalString, index: number) => {
+  floatPanelType.value = 'preview';
+  panel.open(item);
+};
+const onEdit = async (item: MedicalString, index: number) => {
+  floatPanelType.value = 'edit-patient';
+  const result = await panel.open(item);
+  if (!result) return;
+  replace(result, item);
+  showSuccessToast(`修改病案: ${result.code} 成功`);
+};
+const onDelete = (item: MedicalString, index: number) => {
+  showConfirmDialog({
+    title: '确定删除吗?',
+    message: `删除病案: ${item.code}`,
+    confirmButtonText: '删除',
+    async beforeClose(action) {
+      if (action !== 'confirm') return true;
+      try {
+        await deleteMedicalRecordMethod(item.code!).send(true);
+        await remove(item);
+        showSuccessToast(`删除病案: ${item.code} 成功`);
+        return true;
+      } catch {
+        return false;
+      }
+    },
+  });
+};
+const onDeleteSelected = () => {
+  const items: MedicalString[] = [];
+  for (const code of selected.value) {
+    const item = data.value.find((item) => item.code === code);
+    if (item) items.push(item);
+  }
+  if (items.length === 0) return;
+  showConfirmDialog({
+    title: '确定删除吗?',
+    message: `删除 ${items.length} 条病案`,
+    confirmButtonText: '删除',
+    async beforeClose(action) {
+      if (action !== 'confirm') return true;
+      try {
+        await deleteMedicalRecordMethod(items.map((item) => item.code!)).send(true);
+        for (const item of items) await remove(item);
+        showSuccessToast(`删除 ${length} 条病案成功`);
+        return true;
+      } catch {
+        return false;
+      }
+    },
+  });
+};
+const onExport = () => {
+  const items: MedicalString[] = [];
+  for (const code of selected.value) {
+    const item = data.value.find((item) => item.code === code);
+    if (item) items.push(item);
+  }
+  const length = items.length || total.value;
+  const query = { ...form.value, medicalCode: items.map((item) => item.code).join(',') };
+  showConfirmDialog({
+    title: '确定导出?',
+    message: `导出 ${length} 条病案`,
+    confirmButtonText: '导出',
+    async beforeClose(action) {
+      if (action !== 'confirm') return true;
+      try {
+        const { url, ...options } = await exportMedicalRecordMethod(query).send(true);
+        await downloadFromUrl(url, options);
+        showSuccessToast(`导出 ${length} 条病案成功`);
+        return true;
+      } catch {
+        return false;
+      }
+    },
+  });
+};
+
+const showDatePicker = ref(false);
+const displayDate = computed(() => {
+  const start = form.value.startDate?.split(' ')[0] ?? '';
+  const end = form.value.startDate?.split(' ')[0] ?? '';
+  if (!start && !end) return '';
+  return `${start} ~ ${end}`;
+});
+const editStartDate = ref<string[]>([]);
+const editEditDate = ref<string[]>([]);
+const openDatePicker = () => {
+  editStartDate.value = (form.value.startDate ? dayjs(form.value.startDate) : now.startOf('month')).format('YYYY-MM-DD').split('-');
+  editEditDate.value = (form.value.startDate ? dayjs(form.value.endDate) : now).format('YYYY-MM-DD').split('-');
+  showDatePicker.value = true;
+};
+const closeDatePicker = (event?: { selectedValues: string[] }[]) => {
+  showDatePicker.value = false;
+  if (!Array.isArray(event) || event.length < 2) return;
+  let start = dayjs(event[0].selectedValues.join('-'));
+  let end = dayjs(event[1].selectedValues.join('-'));
+  if (start.isAfter(end)) [start, end] = [end, start];
+  form.value.startDate = start.startOf('day').format('YYYY-MM-DD HH:mm:ss');
+  form.value.endDate = end.endOf('day').format('YYYY-MM-DD HH:mm:ss');
+  reload();
+};
+
+const selected = ref<string[]>([]);
+const checkboxRefs = useTemplateRef<CheckboxInstance[]>('checkbox');
+const onToggle = (item: MedicalString, index: number) => {
+  if (loading.value) return;
+  const instance = checkboxRefs.value?.find((ref) => ref.name === item.code);
+  instance?.toggle();
+};
+</script>
+
+<template>
+  <div class="page-container">
+    <div class="page-header flex py-4 px-4 overflow-hidden">
+      <div class="grow shrink-0 h-full min-w-16"></div>
+      <div class="grow-[3] shrink mx-2 flex flex-col justify-center overflow-hidden">
+        <div class="font-bold text-3xl text-nowrap text-center tracking-wide overflow-ellipsis overflow-hidden">{{ title }}</div>
+      </div>
+      <div class="grow shrink-0 h-full min-w-16 flex items-center justify-end overflow-hidden">
+        <router-link :to="{ path: '/screen' }" replace>
+          <img class="size-8 object-scale-down" :src="NavHomeSelect" alt="返回首页" />
+        </router-link>
+      </div>
+    </div>
+    <div class="page-content flex flex-col overflow-hidden">
+      <div class="flex-none">
+        <van-form label-width="4em" @submit="reload()" @reset="reset()">
+          <van-cell-group inset class="grid grid-cols-2 gap-2">
+            <van-field :border="false" name="medical-code" v-model="form.medicalCode" label="病案编号" placeholder="请输入病案编号" />
+            <van-field :border="false" name="patient-name" v-model="form.name" label="姓名" placeholder="请输入姓名" />
+            <van-field :border="false" name="medical-date" v-model="displayDate" label="病案日期" placeholder="请选择开始 - 结束日期" readonly @click="openDatePicker()" />
+            <div class="flex items-center justify-end gap-6">
+              <div class="flex gap-4">
+                <van-button plain size="small" native-type="reset">重置</van-button>
+                <van-button plain size="small" type="success" native-type="submit">搜索</van-button>
+              </div>
+              <div class="flex justify-end gap-4 flex-auto">
+                <van-button size="small" type="danger" :disabled="selected.length === 0" @click="onDeleteSelected()">删除</van-button>
+                <van-button size="small" type="primary" :disabled="total === 0" @click="onExport()">导出</van-button>
+              </div>
+            </div>
+          </van-cell-group>
+        </van-form>
+        <van-popup v-model:show="showDatePicker" position="top">
+          <van-picker-group title="病案日期" :tabs="['开始日期', '结束日期']" next-step-text="下一步" @confirm="closeDatePicker" @cancel="closeDatePicker()">
+            <van-date-picker v-model="editStartDate" :max-date="now.toDate()" />
+            <van-date-picker v-model="editEditDate" :max-date="now.toDate()" />
+          </van-picker-group>
+        </van-popup>
+        <van-divider style="border-color: hsl(var(--primary-hover) / 0.5)" />
+      </div>
+      <div class="flex-auto overflow-y-scroll list-content-container">
+        <van-pull-refresh :modelValue="loading && page === 1" class="min-h-full" @refresh="reload()">
+          <van-list :loading="loading || sending" :finished="isLastPage" finished-text="没有更多了" @load="onLoad()">
+            <van-checkbox-group v-model="selected" direction="horizontal" shape="square">
+              <van-cell v-for="(item, index) in data" :key="item.code" size="large" center clickable @click="onToggle(item, index)">
+                <template #icon>
+                  <van-checkbox :name="item.code" ref="checkbox" :disabled="loading" @click.stop />
+                </template>
+                <template #title>
+                  <van-row>
+                    <van-col span="12">
+                      <label class="mr-2" style="color: hsl(var(--primary) / 0.8)">编号:</label>
+                      <span>{{ item.code }}</span>
+                    </van-col>
+                    <van-col span="12">
+                      <label class="mr-2" style="color: hsl(var(--primary) / 0.8)">姓名:</label>
+                      <span>{{ item.name }}</span>
+                    </van-col>
+                  </van-row>
+                </template>
+                <template #label>
+                  <van-tag plain size="medium">{{ item.lastUser }}</van-tag>
+                  <span class="ml-2">{{ item.lastTime }}</span>
+                </template>
+                <template #value>
+                  <van-button square type="danger" text="删除" @click.stop="onDelete(item, index)" />
+                  <van-button square type="warning" text="修改" @click.stop="onEdit(item, index)" />
+                  <van-button square type="primary" text="查看" @click.stop="onPreview(item, index)" />
+                </template>
+              </van-cell>
+            </van-checkbox-group>
+          </van-list>
+        </van-pull-refresh>
+      </div>
+    </div>
+
+    <FloatPanel auto-height closable>
+      <template #default="props">
+        <MedicalPatientEdit v-if="floatPanelType === 'edit-patient'" v-bind="props" />
+        <MedicalRecordPreview v-else-if="floatPanelType === 'preview'" v-bind="props" />
+      </template>
+    </FloatPanel>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.list-content-container {
+  :deep(.van-cell__title) {
+    flex: auto;
+  }
+  :deep(.van-cell__value) {
+    flex: none;
+  }
+  :deep(.van-cell__label) {
+    display: flex;
+    align-items: center;
+  }
+}
+</style>

+ 58 - 0
src/modules/monitor/tools/color.ts

@@ -0,0 +1,58 @@
+// 常见底图色相禁区(度数)
+export const EXCLUDED_HUE_RANGES: [number, number][] = [
+  [20, 50], // 棕色/肤色区域
+  [0, 15], // 纯红偏橙(容易与出血/红色标记混淆)
+];
+/**
+ * 基于字符串哈希生成视觉鲜明颜色
+ * 同一字符串始终得到同一颜色
+ * @description
+ * | 维度                | 策略                                         |
+ * | :------------------ | :------------------------------------------- |
+ * | Hue (色相)          | 由哈希值决定,均匀分布在 0°–360°             |
+ * | Saturation (饱和度) | 固定在高饱和区 70%–100%,保证鲜艳            |
+ * | Lightness (明度)    | 固定在中等偏亮 45%–65%,避免太暗或太亮看不清 |
+ */
+export function hashStringToColor(value: string, options?: { excluded: [number, number][]; format: 'hsl' | 'hex' }): string {
+  // 1. 计算哈希值(FNV-1a,分布均匀且快速)
+  let hash = 2166136261;
+  for (let i = 0; i < value.length; i++) {
+    hash ^= value.charCodeAt(i);
+    hash = Math.imul(hash, 16777619);
+  }
+  hash = hash >>> 0; // 转为无符号
+
+  // 2. 映射到色相,使用黄金角分割让相邻 hash 的色相也尽量远离
+  const goldenAngle = 137.508;
+  let hue = (hash * goldenAngle) % 360;
+
+  // 3. 跳过底图常见色相区域
+  const excluded = options?.excluded ?? EXCLUDED_HUE_RANGES;
+  for (const [lo, hi] of excluded) {
+    if (hue >= lo && hue <= hi) {
+      hue = (hi + 10) % 360; // 推到禁区之外
+    }
+  }
+
+  // 4. 饱和度和明度用哈希的不同 bit 段做小幅抖动,增加区分度
+  const saturation = 75 + (hash % 25); // 75%–99%
+  const lightness = 48 + ((hash >> 8) % 17); // 48%–64%
+
+  const format = options?.format ?? 'hsl';
+  if (format === 'hsl') return `hsl(${hue.toFixed(1)}, ${saturation}%, ${lightness}%)`;
+  return hslToHex(hue, saturation, lightness);
+}
+
+function hslToHex(h: number, s: number, l: number): string {
+  s /= 100;
+  l /= 100;
+  const a = s * Math.min(l, 1 - l);
+  const f = (n: number) => {
+    const k = (n + h / 30) % 12;
+    const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
+    return Math.round(255 * color)
+      .toString(16)
+      .padStart(2, '0');
+  };
+  return `#${f(0)}${f(8)}${f(4)}`;
+}

+ 7 - 1
src/pages/register.page.vue

@@ -7,11 +7,16 @@ import type { RegisterModel } from '@/request/model';
 import { useRouteQuery } from '@vueuse/router';
 import { useRequest } from 'alova/client';
 import { getApplicationMethod, registerAccountMethod } from '@/request/api';
+import { storageRegisterPatient } from '@/request/model/medical-patient.model';
 
 import { useFlowStore, useVisitor } from '@/stores';
+import { is510k } from '@/platform';
 
 
-useRequest(getApplicationMethod, { initialData: { image: {} } });
+const extra_hack = ref(false);
+useRequest(getApplicationMethod, { initialData: { image: {} } }).onSuccess(({ data }) => {
+  extra_hack.value = is510k(data.image);
+});
 
 const flow = useFlowStore();
 const visitor = useVisitor();
@@ -21,6 +26,7 @@ const formRef = ref<InstanceType<typeof RegisterForm> | null>(null);
 async function onSubmitFromForm(payload: { model: RegisterModel; modelLabel: Partial<RegisterModel> }) {
   const patientId = await submit(payload.model);
   visitor.updatePatient(payload.modelLabel, patientId);
+  if (extra_hack.value) storageRegisterPatient(payload.model, patientId);
   flow.router.push();
 }
 

+ 9 - 2
src/platform/dialog.ui.ts

@@ -1,5 +1,5 @@
-import type { DialogOptions as VantDialogOptions } from 'vant';
-import { showDialog }                              from 'vant';
+import { type DialogOptions as VantDialogOptions } from 'vant';
+import { showDialog, showConfirmDialog }           from 'vant';
 import 'vant/es/dialog/style';
 
 
@@ -7,4 +7,11 @@ export class Dialog {
   static show(options: VantDialogOptions) {
     return showDialog(options);
   }
+
+  static asyncConfirm(options: VantDialogOptions) {
+    return showConfirmDialog(options).then(
+      () => true,
+      () => false
+    );
+  }
 }

+ 89 - 0
src/platform/file.ts

@@ -0,0 +1,89 @@
+import { Notify, platformIsAIO, Toast } from '@/platform/index';
+import { useShowScanCode } from '@/composables/useShowScanCode';
+
+export interface DownloadFromUrlOptions {
+  /** 保存时的文件名;不传则从响应头或 URL 路径推断 */
+  filename?: string;
+  /** 传给 fetch 的额外配置(如 credentials、headers) */
+  fetchInit?: RequestInit;
+}
+
+function filenameFromContentDisposition(header: string | null): string | undefined {
+  if (!header) return;
+  const utf8 = /filename\*=UTF-8''([^;\s]+)/i.exec(header);
+  if (utf8?.[1]) {
+    try {
+      return decodeURIComponent(utf8[1].replace(/['"]/g, ''));
+    } catch {
+      return utf8[1];
+    }
+  }
+  const plain = /filename\s*=\s*("?)([^";\n]+)\1/i.exec(header);
+  return plain?.[2];
+}
+
+function filenameFromUrl(url: string): string {
+  try {
+    const path = new URL(url, typeof location !== 'undefined' ? location.href : undefined).pathname;
+    const seg = path.split('/').filter(Boolean).pop();
+    return seg || 'download';
+  } catch {
+    return 'download';
+  }
+}
+
+/**
+ * 根据文件 URL 下载到本地(通过 fetch 取 Blob 后触发浏览器保存)。
+ * 跨域资源需服务端允许 CORS,否则 fetch 会失败。
+ */
+export function downloadFromUrl(url: string, options?: DownloadFromUrlOptions): Promise<void> {
+  return (async () => {
+    const res = await fetch(url, options?.fetchInit);
+    if (!res.ok) throw new Error(`下载失败: ${res.status} ${res.statusText}`);
+
+    const blob = await res.blob();
+    const filename = options?.filename ?? filenameFromContentDisposition(res.headers.get('content-disposition')) ?? filenameFromUrl(url);
+
+    const objectUrl = URL.createObjectURL(blob);
+    try {
+      const a = document.createElement('a');
+      a.href = objectUrl;
+      a.download = filename;
+      a.rel = 'noopener';
+      document.body.appendChild(a);
+      a.click();
+      a.remove();
+    } finally {
+      URL.revokeObjectURL(objectUrl);
+    }
+  })();
+}
+
+export async function printFromUrl(url: string, options?: { rollback?: boolean; title?: string }) {
+  let closed = false;
+  if (platformIsAIO()) {
+    try {
+      try {
+        await Bridge.print({ url });
+      } catch {
+        window.AIO?.print?.(url);
+      }
+    } catch (e) {
+      Notify.warning(`打印失败 (${e.message})`, { duration: 1500 });
+      closed = true;
+    }
+  } else {
+    try {
+      const current = window.open(url, '_blank');
+      closed = current.closed;
+      current.location.href;
+    } catch (e) {
+      Notify.warning(`无法打开窗口 (${e.message})`, { duration: 1500 });
+      closed = true;
+    }
+  }
+  if (closed && options?.rollback) {
+    Toast.close();
+    await useShowScanCode().open({ url, title: options?.title });
+  }
+}

+ 8 - 0
src/platform/index.ts

@@ -37,3 +37,11 @@ export { getNetworkWall };
 export * from './dialog.ui';
 export * from './notify.ui';
 export * from './toast.ui';
+
+/**
+ * 二类医疗
+ * @param image
+ */
+export function is510k(image: any) {
+  return image?.preset === 510
+}

+ 98 - 0
src/request/api/medical.api.ts

@@ -0,0 +1,98 @@
+import type { AnnotatorTreeNode } from '@/request/model/annotator.model';
+import type { MedicalData, MedicalModel, MedicalRecordModel, MedicalReportData, MedicalReportModel, MedicalString } from '@/request/model/medical-record.model';
+import HTTP from '@/request/alova';
+import { getClientURL, getURLSearchParamsByUrl } from '@/tools';
+import { annotatorScopeGroups, buildAnnotatorTree } from '@/request/model/annotator.model';
+import { fromMedicalRecord, fromMedicalReport, toMedicalRecord } from '@/request/model/medical-record.model';
+import convert from '@/tools/file';
+
+export function listMedicalRecordsMethod(page = 1, size = 20, query = {}) {
+  const params = { pageNum: page, pageSize: size };
+  return HTTP.Post<{ data: MedicalRecordModel<string>[]; total: number }, any>(
+    `/machine/patientInfoManage/medicalList`,
+    { ...params, ...query },
+    {
+      params,
+      transform(data) {
+        return { total: data.total, data: data.list.map(fromMedicalRecord) };
+      },
+      hitSource: /^medical-record-/,
+    }
+  );
+}
+
+export function getMedicalRecordMethod(params: Partial<MedicalRecordModel<string | MedicalReportData>>, dictionaries?: AnnotatorTreeNode[]) {
+  return params.code
+    ? HTTP.Get<MedicalModel, any>(`/machine/patientInfoManage/getReport`, {
+        params: { medicalcode: params.code },
+        transform(data) {
+          return fromMedicalReport(data, dictionaries as never);
+        },
+        hitSource: /^medical-record-/,
+      })
+    : HTTP.Head<MedicalModel, unknown>(getClientURL('/index.html'), {
+        transform() {
+          return fromMedicalReport(params, dictionaries as never);
+        },
+        cacheFor: null,
+      });
+}
+
+export function editMedicalRecordMethod(model: MedicalRecordModel<string | MedicalReportModel>) {
+  const url = model.code ? `/machine/patientInfoManage/updatePatientInfo` : `/machine/patientInfoManage/savePatientInfo`;
+  return HTTP.Post<MedicalString, any>(url, toMedicalRecord(model), {
+    transform(data) {
+      return fromMedicalRecord(data);
+    },
+    name: `medical-record-edit`,
+  });
+}
+
+export function deleteMedicalRecordMethod(code: string | string[]) {
+  if (Array.isArray(code)) code = code.join(',');
+  return HTTP.Post(
+    `/machine/patientInfoManage/deleteById`,
+    { medicalcode: code },
+    {
+      params: { medicalcode: code },
+      name: `medical-record-delete`,
+    }
+  );
+}
+
+export function exportMedicalRecordMethod(query = {}) {
+  return HTTP.Post<{ url: string; filename?: string }, string>(`/machine/patientInfoManage/export`, query, {
+    transform(url) {
+      const params = getURLSearchParamsByUrl(url);
+      return { url, filename: params.get('fileName') ?? void 0 };
+    },
+  });
+}
+
+export function getAnnotatorDictionaryMethod() {
+  return HTTP.Get<AnnotatorTreeNode[], any[]>(getClientURL('/database/annotator.json'), {
+    params: { v: __APP_VERSION__ },
+    cacheFor: {
+      mode: 'restore',
+      expire: 1000 * 60 * 60 * 24,
+      tag: __APP_VERSION__,
+    },
+    shareRequest: true,
+    transform(data) {
+      return buildAnnotatorTree(data, annotatorScopeGroups);
+    },
+  });
+}
+
+export function uploadFileMethod(file: File, name?: string) {
+  name ??= file.name;
+  const suffix = `${file.type.split('/').pop()}` || name?.split('.').pop() || '';
+  if (name && !name.endsWith(suffix)) name = `${name}.${suffix}`;
+  if (!name) name = `upload.${suffix}`;
+
+  const formData = new FormData();
+  if (name) formData.append('file', file, name);
+  else formData.append('file', file);
+
+  return HTTP.Post<string, string>(`/machine/common/upload`, formData, {});
+}

+ 379 - 0
src/request/model/annotator.model.ts

@@ -0,0 +1,379 @@
+/**
+ * 标注(annotator)领域模型:静态字典 `annotator.json`、舌/面维度配置,以及由字典生成的树形选项结构。
+ *
+ * 树形层级(自上而下):
+ * 1. **分组根**:舌诊 / 面诊(`AnnotatorScopeGroup`)
+ * 2. **维度节点**:与业务字段对应的检测维度(如「舌色」),`meta.standardValue` 为该维度标准项文案
+ * 3. **分类节点**(可选):字典中带 `category` 时出现(如「裂纹程度」)
+ * 4. **叶子**:具体选项值,`meta` 为完整字典行 `AnnotatorEntry` + 维度 `key`
+ *
+ * @module request/model/annotator.model
+ */
+
+import { type AnnotatorScope, faceScopes, tongueScopes } from '@/request/model/analysis.model';
+import { depthRange, findPreorder, pathPreorder, siblingsFind } from '@/tools/tree.tool';
+
+// ---------------------------------------------------------------------------
+// 维度配置(与报告/表单字段 key 及中文 dimension 对齐)
+// ---------------------------------------------------------------------------
+
+/**
+ * 舌诊或面诊下的维度分组:根节点展示 `dimension`,子树按 `scopes` 顺序展开。
+ */
+export interface AnnotatorScopeGroup extends AnnotatorScope {
+  /** 该分组下各维度定义,顺序即树中维度节点顺序 */
+  scopes: readonly AnnotatorScope[];
+}
+
+/** 默认分组:舌诊、面诊(常作为 `buildAnnotatorTree` 第二参)。 */
+export const annotatorScopeGroups: readonly AnnotatorScopeGroup[] = [
+  { key: 'tongue', dimension: '舌象', scopes: tongueScopes },
+  { key: 'face', dimension: '面象', scopes: faceScopes },
+];
+
+// ---------------------------------------------------------------------------
+// 字典行(annotator.json)
+// ---------------------------------------------------------------------------
+
+/**
+ * `public/database/annotator.json` 中单行结构。
+ *
+ * - 无 `category`:维度下选项扁平一层。
+ * - 有 `category`:先按分类再挂选项(如舌形下「裂纹程度」)。
+ * - `standard: true`:该维度标准答案,用于维度节点 `meta.standardValue`。
+ */
+export interface AnnotatorEntry {
+  dimension: string;
+  value: string;
+  category?: string;
+  standard?: boolean;
+}
+
+// ---------------------------------------------------------------------------
+// 树节点
+// ---------------------------------------------------------------------------
+
+/**
+ * 通用树节点。`meta`:分支为 `AnnotatorScope` + 可选 `standardValue`;叶为 `AnnotatorEntry` + `key`;纯分类中间层可无 `meta`。
+ */
+export interface AnnotatorTreeNode {
+  id: string;
+  label: string;
+  /** 与 `AnnotatorScope.key` 一致;叶子的维度 key 在 `meta.key` */
+  key?: string;
+  disabled?: boolean;
+  leaf: boolean;
+  meta?: (AnnotatorScope & { standardValue?: string }) | (AnnotatorEntry & Pick<AnnotatorScope, 'key'>);
+  children?: AnnotatorTreeNode[];
+}
+
+// ---------------------------------------------------------------------------
+// 选择器「正常」占位与补全层 id(`useAnnotatorPicker`、`padAnnotatorTreeToMaxLevel`)
+// ---------------------------------------------------------------------------
+
+/** 占位行 id 前缀;`resolve` 时 `id.startsWith` 此前缀视为占位。 */
+export const ANNOTATOR_PLACEHOLDER_PREFIX = 'annotator-default:';
+
+/** 占位行展示文案。 */
+export const ANNOTATOR_DEFAULT_OPTION_LABEL = '正常';
+
+/** Pick 单列占位 id 后缀(完整 id = `ANNOTATOR_PLACEHOLDER_PREFIX` + 本值)。 */
+export const ANNOTATOR_PLACEHOLDER_ID_PICK = 'pick';
+
+/** Cascader 占位 id 后缀。 */
+export const ANNOTATOR_PLACEHOLDER_ID_CASCADE = 'cascade';
+
+/** PickerGroup 第 `tabIndex` 个 tab 的占位 id 后缀。 */
+export function annotatorPlaceholderIdGroupTab(tabIndex: number): string {
+  return `g${tabIndex}`;
+}
+
+/** 补全层节点 id:`annotator-pad:原节点id:深度:层索引`。 */
+export function annotatorPadLayerId(nodeId: string, atDepth: number, layerIndex: number): string {
+  return `annotator-pad:${nodeId}:${atDepth}:${layerIndex}`;
+}
+
+/** 补全层节点 `label`;空串表示仅结构占位。 */
+export const ANNOTATOR_TREE_PAD_LABEL = '';
+
+/** 构建选择器用「正常」占位叶(`withDefault` 插入行)。 */
+export function annotatorDefaultOptionLeaf(suffix: string): AnnotatorTreeNode {
+  return {
+    id: `${ANNOTATOR_PLACEHOLDER_PREFIX}${suffix}`,
+    label: ANNOTATOR_DEFAULT_OPTION_LABEL,
+    leaf: true,
+  };
+}
+
+// ---------------------------------------------------------------------------
+// 构建逻辑
+// ---------------------------------------------------------------------------
+
+/** 避免 id 拼接时 `/`、`:` 等破坏路径或选择器 */
+function escapeIdSegment(s: string): string {
+  return s.replace(/[/\\:]/g, '_');
+}
+
+/** 单维度子树;无数据 `null`。有 `category` 时为 维度→分类→叶,否则 维度→叶。 */
+function buildDimensionBranch(scope: AnnotatorScope, data: AnnotatorEntry[]): AnnotatorTreeNode | null {
+  const dimRows = data.filter((r) => r.dimension === scope.dimension);
+  if (!dimRows.length) return null;
+
+  // 任一行带非空 category 则整维按分类拆层(与 JSON 约定一致)
+  const hasCategory = dimRows.some((r) => Boolean(r.category?.trim()));
+  const baseId = scope.key;
+
+  // 该维度下标准项:用于维度节点 meta(与是否中间有分类层无关,均在 dimRows 上查找)
+  const standardValue = dimRows.find((r) => r.standard === true)?.value;
+  const dimensionMeta: AnnotatorScope & { standardValue?: string } = {
+    ...scope,
+    ...(standardValue !== undefined ? { standardValue } : {}),
+  };
+
+  const toLeafNode = (r: AnnotatorEntry, suffix: string): AnnotatorTreeNode => ({
+    id: suffix,
+    label: r.value,
+    leaf: true,
+    meta: {
+      key: scope.key,
+      dimension: scope.dimension,
+      value: r.value,
+      category: r.category,
+      standard: r.standard,
+    },
+  });
+
+  if (!hasCategory) {
+    return {
+      id: baseId,
+      label: scope.dimension,
+      key: scope.key,
+      leaf: false,
+      meta: dimensionMeta,
+      children: dimRows.map((r, i) => toLeafNode(r, `${baseId}:v:${i}:${escapeIdSegment(r.value)}`)),
+    };
+  }
+
+  const byCategory = new Map<string, AnnotatorEntry[]>();
+  for (const r of dimRows) {
+    const cat = r.category?.trim() ?? '';
+    const bucket = byCategory.get(cat);
+    if (bucket) bucket.push(r);
+    else byCategory.set(cat, [r]);
+  }
+
+  let catIndex = 0;
+  const categoryNodes: AnnotatorTreeNode[] = [];
+  for (const [category, catRows] of byCategory) {
+    const catLabel = category || '其他';
+    const catId = `${baseId}:c:${catIndex++}:${escapeIdSegment(catLabel)}`;
+    categoryNodes.push({
+      id: catId,
+      label: catLabel,
+      leaf: false,
+      // 分类层仅做分组,不写 meta(无法对应单一 AnnotatorScope / 单行字典)
+      children: catRows.map((r, j) => toLeafNode(r, `${catId}:v:${j}:${escapeIdSegment(r.value)}`)),
+    });
+  }
+
+  return {
+    id: baseId,
+    label: scope.dimension,
+    key: scope.key,
+    leaf: false,
+    meta: dimensionMeta,
+    children: categoryNodes,
+  };
+}
+
+/**
+ * 字典数组 → 多根树(每组 `AnnotatorScopeGroup` 一根)。
+ * @param data - 字典扁平数组
+ * @param groups - 常用 `annotatorScopeGroups`
+ */
+export function buildAnnotatorTree(data: AnnotatorEntry[], groups: readonly AnnotatorScopeGroup[]): AnnotatorTreeNode[] {
+  const roots: AnnotatorTreeNode[] = [];
+
+  for (const group of groups) {
+    const children = group.scopes.map((scope) => buildDimensionBranch(scope, data)).filter((node): node is AnnotatorTreeNode => node != null);
+
+    if (!children.length) continue;
+
+    roots.push({
+      id: group.key,
+      key: group.key,
+      label: group.dimension,
+      leaf: false,
+      meta: { key: group.key, dimension: group.dimension },
+      children,
+    });
+  }
+
+  return roots;
+}
+
+// ---------------------------------------------------------------------------
+// 节点 id 解析与查找(与 `buildDimensionBranch` 的 `:` 分段约定一致)
+// ---------------------------------------------------------------------------
+
+/** id 中 `:v:` / `:c:` 后紧跟的仅为构建时的序号,可剥离后得到稳定路径 */
+const ANNOTATOR_ID_INDEX = /(^|:)([vc]):\d+:/g;
+
+/**
+ * 判断 `meta` 是否为叶子节点上的字典项(含 `value`)。
+ */
+export function isAnnotatorTreeLeafMeta(meta: AnnotatorTreeNode['meta']): meta is AnnotatorEntry & Pick<AnnotatorScope, 'key'> {
+  return meta != null && 'value' in meta;
+}
+
+/** 分组根 / 维度分支:`key`、`dimension` 有值且无字典行 `value`。 */
+export function isAnnotatorTreeBranchMeta(meta: AnnotatorTreeNode['meta']): meta is AnnotatorScope & { standardValue?: string } {
+  return meta != null && typeof meta.key === 'string' && typeof meta.dimension === 'string' && !('value' in meta);
+}
+
+/**
+ * 去掉 id 里 `:v:序号:`、`:c:序号:` 中的序号段,得到与数据顺序无关的稳定 id。
+ *
+ * @example
+ * `tongueColor:v:0:淡白舌` → `tongueColor:v:淡白舌`
+ * `tongueShape:c:0:裂纹程度:v:1:中度裂纹` → `tongueShape:c:裂纹程度:v:中度裂纹`
+ */
+export function simplifyAnnotatorNodeId(id: string): string {
+  return id.replace(ANNOTATOR_ID_INDEX, '$1$2:');
+}
+
+/**
+ * 解析节点 id(不访问树)。`simplifiedId` 同 `simplifyAnnotatorNodeId`;`kind`:`single` | `category` | `leaf`。
+ */
+export function parseAnnotatorNodeId(id: string): {
+  simplifiedId: string;
+  baseKey: string;
+  categoryLabel?: string;
+  value?: string;
+  kind: 'single' | 'category' | 'leaf';
+} {
+  const simplifiedId = simplifyAnnotatorNodeId(id);
+  const parts = simplifiedId.split(':');
+  const baseKey = parts[0] ?? '';
+
+  if (parts.length <= 1) {
+    return { simplifiedId, baseKey, kind: 'single' };
+  }
+
+  const vi = parts.indexOf('v');
+  const ci = parts.indexOf('c');
+
+  if (ci === -1 && vi !== -1) {
+    const value = parts.slice(vi + 1).join(':') || undefined;
+    return { simplifiedId, baseKey, value, kind: 'leaf' };
+  }
+
+  if (ci !== -1 && vi === -1) {
+    const categoryLabel = parts.slice(ci + 1).join(':') || undefined;
+    return { simplifiedId, baseKey, categoryLabel, kind: 'category' };
+  }
+
+  if (ci !== -1 && vi !== -1 && vi > ci) {
+    const categoryLabel = parts.slice(ci + 1, vi).join(':') || undefined;
+    const value = parts.slice(vi + 1).join(':') || undefined;
+    return { simplifiedId, baseKey, categoryLabel, value, kind: 'leaf' };
+  }
+
+  return { simplifiedId, baseKey, kind: 'single' };
+}
+
+/**
+ * 树节点是否命中查询串 —— **全文件「按 id / key 定位节点」的约定以本函数为准**。
+ *
+ * - {@link findAnnotatorSubtreeNode}、{@link getAnnotatorNodePath}:使用完整 **① → ④**。
+ * - {@link restoreAnnotatorNodeMeta}:**仅 ①、②**(只按 id,不支持 key)。
+ *
+ * @param node - 待判断的节点
+ * @param q - 已 `trim` 的查询串:原始 `id`、经 {@link simplifyAnnotatorNodeId} 后的 id,或 {@link AnnotatorScope.key}(供 ③④ 使用)
+ * @param qs - 恒为 `simplifyAnnotatorNodeId(q)`,与 `simplifyAnnotatorNodeId(node.id)` 比较(步骤 ②)
+ * @returns 是否命中
+ *
+ * **判定顺序**(自上而下,**任一成立即为 `true`**):
+ *
+ * - **①** `node.id === q` — 原始 id 全等。
+ * - **②** `simplifyAnnotatorNodeId(node.id) === qs` — 去掉 id 中 `(v|c):序号:` 段,规则见 {@link simplifyAnnotatorNodeId}。
+ * - **③** `!node.leaf && node.key === q` — 分支节点 `key`(分组根、维度节点)。
+ * - **④** `!node.leaf && meta && isAnnotatorTreeBranchMeta(meta) && meta.key === q` — 分支 `meta` 的 key({@link isAnnotatorTreeBranchMeta})。
+ *
+ * 叶子仅能通过 **①②** 命中;无 `meta` 的分类中间层亦仅 **①②**。
+ */
+function annotatorNodeMatchesQuery(node: AnnotatorTreeNode, q: string, qs: string): boolean {
+  if (node.id === q || simplifyAnnotatorNodeId(node.id) === qs) return true;
+  if (!node.leaf && node.key === q) return true;
+  const { meta } = node;
+  if (!node.leaf && meta && isAnnotatorTreeBranchMeta(meta) && meta.key === q) return true;
+  return false;
+}
+
+/** 按 id / 简化 id 找节点并返回 `meta`(不支持按 `AnnotatorScope.key` 查,需用 `findAnnotatorSubtreeNode`)。 */
+export function restoreAnnotatorNodeMeta(roots: readonly AnnotatorTreeNode[], nodeId: string): AnnotatorTreeNode['meta'] {
+  const target = simplifyAnnotatorNodeId(nodeId);
+  const hit = findPreorder(roots, (node) => node.id === nodeId || simplifyAnnotatorNodeId(node.id) === target);
+  return hit?.meta;
+}
+
+/** 树内按 id/key 查找;先序第一个命中。 */
+export function findAnnotatorSubtreeNode(roots: readonly AnnotatorTreeNode[], keyOrId: string): AnnotatorTreeNode | undefined {
+  const q = keyOrId.trim();
+  if (!q) return undefined;
+  const qs = simplifyAnnotatorNodeId(q);
+  return findPreorder(roots, (node) => annotatorNodeMatchesQuery(node, q, qs));
+}
+
+/** 仅扫一层兄弟,规则同 `annotatorNodeMatchesQuery`(多段路径逐级解析时用)。 */
+export function findAnnotatorNodeAmongSiblings(siblings: readonly AnnotatorTreeNode[], keyOrId: string): AnnotatorTreeNode | undefined {
+  const q = keyOrId.trim();
+  if (!q) return undefined;
+  const qs = simplifyAnnotatorNodeId(q);
+  return siblingsFind(siblings, (node) => annotatorNodeMatchesQuery(node, q, qs));
+}
+
+/** 根→命中节点路径;未找到 `[]`。`idOrKey` 会先 `trim`。 */
+export function getAnnotatorNodePath(nodes: readonly AnnotatorTreeNode[], idOrKey: string): AnnotatorTreeNode[] {
+  const q = idOrKey.trim();
+  if (!q) return [];
+  const qs = simplifyAnnotatorNodeId(q);
+  return pathPreorder(nodes, (node) => annotatorNodeMatchesQuery(node, q, qs));
+}
+
+/**
+ * 在父子间插入补全层(仅 id/label/leaf/children),使叶深度不低于当前树最大深度(或更大的 `max`)。
+ * 默认目标深度与 `depthRange(roots, n => n.leaf).max`(`@/tools/tree.tool`)一致。
+ */
+export function padAnnotatorTreeToMaxLevel(roots: readonly AnnotatorTreeNode[], max?: number, options?: { padLabel?: string }): AnnotatorTreeNode[] {
+  if (!roots.length) return [];
+
+  const naturalMax = depthRange(roots, (n) => n.leaf).max;
+  const targetMax = Math.max(max ?? naturalMax, naturalMax);
+  const padLabel = options?.padLabel ?? ANNOTATOR_TREE_PAD_LABEL;
+
+  const padSubtree = (node: AnnotatorTreeNode, depth: number): AnnotatorTreeNode => {
+    if (node.leaf) {
+      if (depth >= targetMax) return { ...node };
+      let inner: AnnotatorTreeNode = { ...node };
+      for (let k = 0; k < targetMax - depth; k++) {
+        const atDepth = depth + k;
+        inner = {
+          id: annotatorPadLayerId(node.id, atDepth, k),
+          label: padLabel,
+          leaf: false,
+          children: [inner],
+        };
+      }
+      return inner;
+    }
+
+    const raw = node.children;
+    if (!raw?.length) return { ...node };
+
+    const children = raw.map((ch) => padSubtree(ch, depth + 1));
+    return { ...node, children };
+  };
+
+  return roots.map((r) => padSubtree(r, 1));
+}

+ 112 - 0
src/request/model/medical-patient.model.ts

@@ -0,0 +1,112 @@
+import type { FieldKey, RegisterModel } from '@/request/model/register.model';
+import { Field_Config } from '@/request/model/register.model';
+import type { MedicalRecordModel, MedicalReportData } from '@/request/model/medical-record.model';
+import { getMedicalReportData } from '@/request/model/medical-record.model';
+
+export type MedicalPatient = { [K in keyof RegisterModel]?: RegisterModel[K] | string } & { patientId?: string };
+
+/**
+ * 将 病案患者数据 转换成 `建档表单` 显示数据
+ * @param data 病案数据
+ */
+export function fromMedicalPatient(data: Record<string, any>): MedicalPatient {
+  const bool = (value?: string) => (value == null ? void 0 : +value === 1 ? '是' : +value === 0 ? '否' : void 0);
+  const sex = (value?: string) => (value == null ? void 0 : +value === 1 ? '男' : +value === 2 ? '女' : void 0);
+  return {
+    patientId: data.patientId,
+    cardno: data.cardno,
+    name: data.name,
+    sex: sex(data.sex),
+    age: data.age,
+    height: data.height,
+    weight: data.weight,
+    womenSpecialPeriod: data.womenSpecialPeriod,
+    foodAllergy: data.allergicfoods,
+    hobbyFlavor: data.preferencetaste,
+    job: data.occupation,
+    address: data.currentaddress,
+    detailAddress: data.addressdetail,
+    drinkState: bool(data.isdrink),
+    smokeState: bool(data.issmoke),
+    isEasyAllergy: bool(data.isEasyAllergy),
+  };
+}
+
+/**
+ * 将 `建档表单` 显示数据 转换成 病案患者数据
+ * @param modelLabel 建档显示字段
+ */
+export function toMedicalPatient(modelLabel?: MedicalPatient) {
+  if (!modelLabel) return {};
+  const bool = (value?: string | boolean) => (value === '是' || value === true ? 1 : value === '否' || value === false ? 0 : null);
+  const sex = (value?: string) => (value === '男' ? 1 : value === '女' ? 2 : null);
+  return {
+    patientId: modelLabel.patientId,
+    cardno: modelLabel.cardno,
+    name: modelLabel.name,
+    sex: sex(modelLabel.sex),
+    age: modelLabel.age,
+    height: modelLabel.height,
+    weight: modelLabel.weight,
+    womenSpecialPeriod: modelLabel.womenSpecialPeriod,
+    allergicfoods: modelLabel.foodAllergy,
+    preferencetaste: modelLabel.hobbyFlavor,
+    occupation: modelLabel.job,
+    currentaddress: modelLabel.address,
+    addressdetail: modelLabel.detailAddress,
+    isdrink: bool(modelLabel.drinkState),
+    issmoke: bool(modelLabel.smokeState),
+    isEasyAllergy: bool(modelLabel.isEasyAllergy),
+  };
+}
+
+export function updateMedicalPatient(
+  model: MedicalRecordModel<string | MedicalReportData>,
+  payload: { modelLabel?: MedicalPatient; model: RegisterModel }
+): MedicalRecordModel<string> {
+  const data = getMedicalReportData(model.report);
+  const patientId = model.patient.patientId ?? model.patientId ?? data.patientId;
+  const patient = { ...payload.modelLabel, patientId };
+  data.patient = payload.model;
+  data.reportPdfUrl = void 0;
+  return { ...model, patientId, patient, report: JSON.stringify(data) };
+}
+
+export type DisplayPatientRowFieldConfig = { key: FieldKey; label?: string; hideLabel?: boolean | ((model: Partial<MedicalPatient>) => any) };
+
+export function displayMedicalPatient(model: MedicalPatient, rows: DisplayPatientRowFieldConfig[][]) {
+  return rows
+    .map((row) =>
+      row
+        .map((field) => ({
+          key: field.key,
+          label: field.label ?? Field_Config[field.key]?.control?.label ?? field.key,
+          value: formatValue(field.key, model[field.key]),
+          suffix: Field_Config[field.key]?.suffix ?? '',
+          hideLabel: typeof field.hideLabel === 'function' ? field.hideLabel?.(model) : !!field.hideLabel,
+        }))
+        .filter((field) => !!field.value)
+    )
+    .filter((row) => row.length > 0);
+}
+
+const formatValue = (key: FieldKey, value: unknown): string => {
+  if (value == null || value === '') return '';
+  if (typeof value === 'boolean') return value ? '是' : '否';
+  if (key === 'address')
+    return Array.isArray(value)
+      ? value
+          .map((item) => item.label)
+          .filter(Boolean)
+          .join('')
+      : String(value).replace(/\//g, '');
+  return String(value).replace(/[,,]/g, '、').trim();
+};
+
+export function storageRegisterPatient(data: RegisterModel, patientId: string) {
+  try {
+    const key = `${patientId}_data`;
+    localStorage.setItem(key, JSON.stringify({ ...data, patientId }));
+    return key;
+  } catch {}
+}

+ 84 - 0
src/request/model/medical-record.model.ts

@@ -0,0 +1,84 @@
+import type { AnnotatorTreeNode } from '@/request/model/annotator.model';
+import type { AnalysisData } from '@/request/model/analysis.model';
+import type { RegisterModel } from '@/request/model/register.model';
+import { fromMedicalPatient, type MedicalPatient, toMedicalPatient } from '@/request/model/medical-patient.model';
+import {
+  fromMedicalReportModel,
+  type MedicalReportPictureKey,
+  type MedicalReportTableKey,
+  type PictureAnnotatorModel,
+  type TableAnnotatorModel,
+  toMedicalReportModel,
+} from '@/request/model/medical-report.model';
+
+export interface MedicalRecordModel<R extends string | MedicalReportModel | MedicalReportData> {
+  code?: string;
+  name?: string;
+  patientId?: string;
+  patient: MedicalPatient;
+  report: R;
+
+  lastUser?: string;
+  lastTime?: string;
+}
+
+export type MedicalReportData =
+  & Partial<{ tonguefaceAnalysisReportId: string; reportTime: string; reportPdfUrl: string }>
+  & Pick<MedicalReportModel, 'patientId' | 'patient'>
+  & { [P in MedicalReportPictureKey]: string | PictureAnnotatorModel; }
+  & { [K: string]: AnalysisData; };
+
+export type MedicalReportModel =
+  & { id?: string; date?: string; reportURL?: string; }
+  & { patientId?: string; patient: RegisterModel; }
+  & { [K in MedicalReportTableKey]: TableAnnotatorModel; }
+  & { [P in MedicalReportPictureKey]: PictureAnnotatorModel; };
+
+export type MedicalString = MedicalRecordModel<string>;
+export type MedicalData = MedicalRecordModel<MedicalReportData>;
+export type MedicalModel = MedicalRecordModel<MedicalReportModel>;
+
+export function fromMedicalRecord(data: Record<string, any>): MedicalString {
+  return {
+    code: data.medicalcode,
+    name: data.name,
+    patient: fromMedicalPatient(data),
+    report: data.reportinfo ?? '',
+    lastUser: data.updateuser ?? data.createuser,
+    lastTime: data.updatetime ?? data.createtime,
+  };
+}
+
+export function toMedicalRecord(model: MedicalRecordModel<string | MedicalReportModel>) {
+  const patientId = model.patient.patientId ?? model.patientId;
+  return {
+    ...toMedicalPatient(model.patient),
+    medicalcode: model.code,
+    patientId,
+    reportinfo: typeof model.report === 'string' ? model.report : JSON.stringify(toMedicalReportModel(model.report)),
+  };
+}
+
+export function fromMedicalReport(data: Record<string, any>): MedicalData;
+export function fromMedicalReport(data: Record<string, any>, dictionaries: AnnotatorTreeNode[]): MedicalModel;
+export function fromMedicalReport(data: Record<string, any>, dictionaries?: AnnotatorTreeNode[] | undefined): MedicalRecordModel<MedicalReportData | MedicalReportModel> {
+  const record = fromMedicalRecord(data);
+  const reportData = data.report || record.report;
+  const patientData = data.patient || record.patient;
+  const report = typeof reportData === 'string' ? fromMedicalReportModel(getMedicalReportData(reportData), dictionaries) : reportData;
+  report.id = record.code ?? report.id;
+  report.date = record.lastTime ?? report.date;
+  return { ...record, patient: patientData, report };
+}
+
+export function getMedicalReportData(input: string | MedicalReportData): MedicalReportData {
+  if (!input) throw new Error('病案数据为空');
+  if (typeof input === 'string') {
+    try {
+      return JSON.parse(input);
+    } catch {
+      throw new Error(`无法解析病案数据`);
+    }
+  }
+  return input;
+}

+ 210 - 0
src/request/model/medical-report.model.ts

@@ -0,0 +1,210 @@
+import type { MedicalReportData, MedicalReportModel } from '@/request/model/medical-record.model';
+import type { AnnotatorTreeNode } from '@/request/model/annotator.model';
+import type { AnnotatorExportObject, AnnotatorObject } from '@/lib/Annotator';
+import type { CropperExportObject } from '@/lib/Cropper';
+import type { AnalysisData, AnalysisModel } from '@/request/model/analysis.model';
+
+import dayjs from 'dayjs';
+import { groupBy, pick } from 'es-toolkit';
+import { findPreorder } from '@/tools';
+import { fromAnalysisModel, tongueScopes, faceScopes } from '@/request/model/analysis.model';
+import { ANNOTATOR_DEFAULT_OPTION_LABEL, findAnnotatorSubtreeNode, parseAnnotatorNodeId, simplifyAnnotatorNodeId } from '@/request/model/annotator.model';
+
+export type MedicalReportTableKey = 'tongue' | 'face';
+export type MedicalReportPictureKey = 'upImg' | 'downImg' | 'faceImg';
+
+export function toTableKey(key: MedicalReportPictureKey): [key: MedicalReportTableKey, skips: string[]] {
+  if (key === 'faceImg') return ['face', []];
+  if (key === 'downImg') return ['tongue', []];
+  if (key === 'upImg') return ['tongue', ['sublingualVein']];
+  throw new Error('Not implemented');
+}
+export function toPictureKey(key: string | MedicalReportTableKey): MedicalReportPictureKey[] {
+  if (key === 'tongue') return ['upImg', 'downImg'];
+  if (key === 'face') return ['faceImg'];
+  if (key === 'sublingualVein') return ['downImg'];
+  if (tongueScopes.some((scope) => scope.key === key)) return ['upImg'];
+  if (faceScopes.some((scope) => scope.key === key)) return ['faceImg'];
+  throw new Error('Not implemented');
+}
+
+export interface PictureAnnotatorModel extends AnnotatorExportObject {
+  object: CropperExportObject;
+}
+
+export interface TableAnnotatorModel extends AnalysisModel {
+  annotator: Record<string, string[]>;
+}
+
+export function fromMedicalReportModel(data: MedicalReportData, dictionaries?: AnnotatorTreeNode[] | Dictionaries): MedicalReportModel {
+  if (Array.isArray(dictionaries)) dictionaries = new Dictionaries(dictionaries);
+  const options = { dictionaries, resource: new Resource() };
+  return {
+    id: data.tonguefaceAnalysisReportId,
+    date: data.reportTime,
+    reportURL: data.reportPdfUrl,
+    patientId: data.patientId,
+    patient: data.patient,
+    tongue: resolveTableAnnotator('tongue', data, dictionaries),
+    face: resolveTableAnnotator('face', data, dictionaries),
+    upImg: resolvePictureAnnotator('upImg', data, options),
+    downImg: resolvePictureAnnotator('downImg', data, options),
+    faceImg: resolvePictureAnnotator('faceImg', data, options),
+  };
+}
+
+export function toMedicalReportModel(model: MedicalReportModel): MedicalReportData {
+  const skipKeys: string[] = [];
+  if (!model.downImg.object.src) skipKeys.push(...toTableKey('upImg')[1]);
+
+  const resolveAnnotator = (table: TableAnnotatorModel, pictures: PictureAnnotatorModel[], resultKey: 'tongueAnalysisResult' | 'faceAnalysisResult') => {
+    const ignore = pictures.every((picture) => !picture.object.src);
+    const results = new Set<string>();
+    const values: Record<string, AnalysisData> = {};
+
+    const annotator = pictures.flatMap((picture) => picture.annotator);
+    const group = groupBy(annotator, (object) => object.annotatorId?.split(':').shift() ?? '');
+    for (const { key, columns } of table.table.data) {
+      if (skipKeys.includes(key)) {
+        values[key] = { standardValue: columns[2], actualList: [{ actualValue: `图像不符合检测要求`, contrast: 'r', attrs: [] }] };
+        continue;
+      }
+
+      values[key] = { standardValue: columns[2], actualList: [] };
+      if (ignore) continue;
+
+      const annotator = new Set(table.annotator[key]);
+      const picture = group[key] ?? [];
+      for (const object of picture) {
+        if (!object.annotatorId || !object.src) continue;
+        annotator.delete(object.annotatorId);
+        const value = resolveAnnotatorId(values[key], object, columns[0]);
+        results.add(value);
+      }
+      for (const annotatorId of annotator) {
+        const value = resolveAnnotatorId(values[key], { annotatorId }, columns[0]);
+        results.add(value);
+      }
+      if (values[key].actualList.length === 0) {
+        values[key].actualList.push({ actualValue: columns[2], contrast: 's', attrs: [] });
+        results.add(columns[2]);
+      }
+    }
+    return {
+      ...values,
+      [resultKey]: ignore ? null : [...results].filter(Boolean).join(',') || ANNOTATOR_DEFAULT_OPTION_LABEL,
+    };
+  };
+
+  const resolveAnnotatorId = ({ standardValue, actualList }: AnalysisData, { annotatorId, src }: Pick<AnnotatorObject, 'annotatorId' | 'src'>, dimension: string): string => {
+    const description = parseAnnotatorNodeId(annotatorId!);
+    const actualValue = description.value?.split(':').pop() ?? annotatorId!.split(':').pop() ?? '';
+    if (actualList.some((item) => item.actualValue === actualValue && item.splitImage === src)) return actualValue;
+    actualList.push({
+      actualValue,
+      contrast: actualValue === standardValue ? 's' : 'r',
+      splitImage: src,
+      attrs: [],
+    });
+    return actualValue;
+  };
+
+  return {
+    tonguefaceAnalysisReportId: model.id,
+    reportTime: model.date ?? dayjs().format(`YYYY年MM月DD日`),
+    reportPdfUrl: model.reportURL,
+    patientId: model.patientId,
+    patient: model.patient,
+    upImg: model.upImg,
+    downImg: model.downImg,
+    faceImg: model.faceImg,
+    ...resolveAnnotator(model.tongue, [model.upImg, model.downImg], 'tongueAnalysisResult'),
+    ...resolveAnnotator(model.face, [model.faceImg], 'faceAnalysisResult'),
+  } as MedicalReportData;
+}
+
+function resolveTableAnnotator(scope: MedicalReportTableKey, data: MedicalReportData, dictionaries?: Dictionaries): TableAnnotatorModel {
+  const analysis = fromAnalysisModel(scope, data);
+  const table = analysis.table;
+  const annotator: Record<string, string[]> = {};
+  if (dictionaries) {
+    for (const { key, columns } of table.data) {
+      const tree = dictionaries.find(key);
+      if (!columns[2] && tree) columns[2] = (tree.meta as any)?.standardValue ?? ANNOTATOR_DEFAULT_OPTION_LABEL;
+      if (columns[1] && Array.isArray(tree?.children))
+        annotator[key] = normalizeCompareString(columns[1])
+          .split(/\s/)
+          .map((value) => (value ? dictionaries.find((node) => node.leaf && node.label === value)?.id : void 0) as string)
+          .filter(Boolean);
+    }
+  }
+  return { ...analysis, annotator };
+}
+
+function resolvePictureAnnotator(key: MedicalReportPictureKey, data: MedicalReportData, options: { dictionaries?: Dictionaries; resource?: Resource }): PictureAnnotatorModel {
+  const value = data[key];
+  if (!value || typeof value === 'string') return { object: { src: value || '' }, annotator: [], relation: {} } as unknown as PictureAnnotatorModel;
+
+  if (options.dictionaries != null) {
+    value.annotator = value.annotator.map((item) => {
+      const annotatorId = options.dictionaries!.whole(item.annotatorId);
+      const src = options.resource?.client(item.src) ?? item.src;
+      return { ...item, annotatorId, src } as any;
+    });
+  }
+  if (options.resource != null) {
+    const relation = Object.entries(value.relation).map(([key, value]) => [options.resource!.client(key), value]);
+    value.relation = Object.fromEntries(relation) as any;
+  }
+
+  return value;
+}
+
+/**
+ * 去 HTML 标签、去 `()`()`[]`【】 成对括号及其中的内容(多轮剥离以处理嵌套),再将连续空白压成单个空格并 trim。
+ */
+export function normalizeCompareString(input: string | undefined): string {
+  let s = input ?? '';
+  s = s.replace(/<[^>]+>/g, ' ');
+  let prev: string;
+  do {
+    prev = s;
+    s = s
+      .replace(/[((][^()()]*[))]/g, '')
+      .replace(/\[[^\[\]]*]/g, '')
+      .replace(/【[^【】]*】/g, '');
+  } while (s !== prev);
+  return s.replace(/\s+/g, ' ').trim();
+}
+
+class Dictionaries {
+  private cache = new Map<string, string>();
+
+  constructor(private tree: AnnotatorTreeNode[]) {}
+
+  find(key: string | ((n: AnnotatorTreeNode) => boolean)): AnnotatorTreeNode | undefined {
+    if (typeof key === 'string') return findAnnotatorSubtreeNode(this.tree, key);
+    return findPreorder(this.tree, key);
+  }
+
+  whole(id?: string) {
+    const simplify = id ? simplifyAnnotatorNodeId(id) : void 0;
+    if (!simplify) return void 0;
+    if (this.cache.has(simplify)) return this.cache.get(simplify);
+
+    const value = this.find(id!)?.id;
+    if (value) this.cache.set(simplify, value);
+    return value;
+  }
+}
+
+class Resource {
+  service(value: string) {
+    return value;
+  }
+
+  client(value?: string) {
+    if (!value) return void 0;
+    return value;
+  }
+}

+ 3 - 0
src/router/index.ts

@@ -18,12 +18,14 @@ const router = createRouter({
     { path: '/report/:id(\\w+)?', component: () => import('@/modules/report/report.page.vue'), meta: { title: '健康分析报告' } },
     { path: '/scheme/:id(\\w+)?', component: () => import('@/modules/report/scheme.page.vue'), meta: { title: '调理方案', toggle: false } },
     { path: '/crossing/:flow(\\w+)?', component: () => import('@/pages/crossing.page.vue') },
+    { path: '/annotator', component: () => import('@/modules/monitor/annotator.page.vue'), meta: { title: '图像处理' } },
     {
       path: '/manage',
       children: [
         { path: 'system/role', component: () => import('@/modules/system/role.page.vue'), meta: { title: '角色管理' } },
         { path: 'system/user', component: () => import('@/modules/system/user.page.vue'), meta: { title: '用户管理' } },
         { path: 'system/logger', component: () => import('@/modules/system/logger.page.vue'), meta: { title: '日志管理' } },
+        { path: 'medical/record', component: () => import('@/modules/monitor/medical-record.page.vue'), meta: { title: '病案管理' } },
       ],
     },
     { path: '/forbidden', name: 'forbidden', component: () => import('@/router/pages/forbidden.page.vue') },
@@ -42,6 +44,7 @@ export const flow_router = {
   'tongueface_upload': '/camera',
   'tongueface_analysis_result': '/camera/result',
   'tongueface_analysis': '/questionnaire',
+  'tongueface_annotator': '/annotator',
   'alcohol_upload_result': '/alcohol/result',
   'health_analysis': '/report',
   'health_analysis_scheme': '/scheme',

+ 4 - 2
src/stores/visitor.store.ts

@@ -1,7 +1,9 @@
+import type { PulseAnalysisModel } from '@/request/model';
+import type { MedicalPatient } from '@/request/model/medical-patient.model';
+
 import { defineStore } from 'pinia';
-import type { PulseAnalysisModel, RegisterModel } from '@/request/model';
 
-type Patient = { [K in keyof RegisterModel]?: RegisterModel[K] | string };
+type Patient = MedicalPatient;
 
 export const useVisitor = defineStore('visitor', () => {
   const patientId = ref<string>();

+ 1 - 0
src/tools/index.ts

@@ -1,3 +1,4 @@
+export * from './tree.tool';
 export * from './url.tool';
 export * from './string.tool';
 export * from './params';

+ 221 - 0
src/tools/tree.tool.ts

@@ -0,0 +1,221 @@
+/**
+ * 通用树(`children` 子列表,根深 **1**)。
+ *
+ * 命名:`preorder` / `levelOrder` / `postorder` 遍历;`findPreorder` / `pathPreorder` 查找;
+ * `depthRange`(叶的 min + 全局 max)与 `depthExtent`(全节点 min/max);
+ * `keyIndex` + `lcaKeys`;`cloneChildren` 浅拷一层子数组。
+ *
+ * @module tools/tree.tool
+ */
+
+// -----------------------------------------------------------------------------
+// 类型
+// -----------------------------------------------------------------------------
+
+/** 仅约定子列表;业务字段由具体类型 `T` 扩展。 */
+export interface TreeLike {
+  children?: readonly TreeLike[] | undefined;
+}
+
+/**
+ * {@link depthRange}:`min` 为**叶**最浅深度,`max` 为全局最深(与 {@link depthExtent} 的 `max` 相同)。
+ */
+export interface DepthRange {
+  min: number;
+  max: number;
+}
+
+/** {@link depthExtent}:全体节点(含分支)的深度最小值与最大值。 */
+export interface DepthExtent {
+  min: number;
+  max: number;
+}
+
+/**
+ * {@link keyIndex}:`keyOf` 须在森林内唯一;重复则后遍历覆盖,父链可能错。
+ */
+export interface KeyIndex<T> {
+  byKey: Map<string, T>;
+  parent: Map<string, T | undefined>;
+  parentKey: Map<string, string | null>;
+}
+
+// -----------------------------------------------------------------------------
+// 遍历:pre / level / post
+// -----------------------------------------------------------------------------
+
+/** 先序 DFS;子从左到右,与 {@link findPreorder} / {@link keyIndex} 顺序一致。 */
+export function preorder<T extends TreeLike>(roots: readonly T[], visit: (n: T) => void): void {
+  const st = [...roots];
+  while (st.length) {
+    const n = st.pop()!;
+    visit(n);
+    const kids = n.children;
+    if (kids?.length) for (let i = kids.length - 1; i >= 0; i--) st.push(kids[i] as T);
+  }
+}
+
+/** 层序 BFS;同层从左到右。 */
+export function levelOrder<T extends TreeLike>(roots: readonly T[], visit: (n: T) => void): void {
+  const q: T[] = [...roots];
+  for (let i = 0; i < q.length; i++) {
+    const n = q[i]!;
+    visit(n);
+    const kids = n.children;
+    if (kids?.length) q.push(...(kids as T[]));
+  }
+}
+
+/** 后序;子树从左到右再父。递归实现,极深树慎用。 */
+export function postorder<T extends TreeLike>(roots: readonly T[], visit: (n: T) => void): void {
+  const go = (nodes: readonly T[]) => {
+    for (const n of nodes) {
+      const kids = n.children;
+      if (kids?.length) go(kids as T[]);
+      visit(n);
+    }
+  };
+  go(roots);
+}
+
+// -----------------------------------------------------------------------------
+// 查找
+// -----------------------------------------------------------------------------
+
+/** 先序第一个满足 `test` 的节点。 */
+export function findPreorder<T extends TreeLike>(roots: readonly T[], test: (n: T) => boolean): T | undefined {
+  const st = [...roots];
+  while (st.length) {
+    const n = st.pop()!;
+    if (test(n)) return n;
+    const kids = n.children;
+    if (kids?.length) for (let i = kids.length - 1; i >= 0; i--) st.push(kids[i] as T);
+  }
+  return undefined;
+}
+
+/** 根→命中节点的路径;先序下第一个命中;未找到 `[]`。 */
+export function pathPreorder<T extends TreeLike>(roots: readonly T[], test: (n: T) => boolean): T[] {
+  const dfs = (level: readonly T[], prefix: T[]): T[] | null => {
+    for (const n of level) {
+      const path = [...prefix, n];
+      if (test(n)) return path;
+      const kids = n.children;
+      if (kids?.length) {
+        const hit = dfs(kids as T[], path);
+        if (hit) return hit;
+      }
+    }
+    return null;
+  };
+  return dfs(roots, []) ?? [];
+}
+
+/** 仅扫一层兄弟,不递归子节点。 */
+export function siblingsFind<T>(siblings: readonly T[], test: (n: T) => boolean): T | undefined {
+  for (const n of siblings) if (test(n)) return n;
+  return undefined;
+}
+
+// -----------------------------------------------------------------------------
+// 深度
+// -----------------------------------------------------------------------------
+
+/**
+ * 一次遍历:`max` 为最深节点;`min` 为 `isLeaf` 为真者的最浅深度(无叶则 `0`)。
+ * 空森林 `{ min:0, max:0 }`。
+ */
+export function depthRange<T extends TreeLike>(roots: readonly T[], isLeaf: (n: T) => boolean): DepthRange {
+  if (!roots.length) return { min: 0, max: 0 };
+  let leafMin = Infinity;
+  let dmax = 0;
+  const walk = (nodes: readonly T[], d: number) => {
+    for (const n of nodes) {
+      dmax = Math.max(dmax, d);
+      if (isLeaf(n)) leafMin = Math.min(leafMin, d);
+      const kids = n.children;
+      if (kids?.length) walk(kids as T[], d + 1);
+    }
+  };
+  walk(roots, 1);
+  return { min: leafMin === Infinity ? 0 : leafMin, max: dmax };
+}
+
+/** 全体节点的深度 min / max;非空单根时 `min` 一般为 `1`。空森林 `{0,0}`。 */
+export function depthExtent<T extends TreeLike>(roots: readonly T[]): DepthExtent {
+  if (!roots.length) return { min: 0, max: 0 };
+  let dmin = Infinity;
+  let dmax = 0;
+  const walk = (nodes: readonly T[], d: number) => {
+    for (const n of nodes) {
+      dmin = Math.min(dmin, d);
+      dmax = Math.max(dmax, d);
+      const kids = n.children;
+      if (kids?.length) walk(kids as T[], d + 1);
+    }
+  };
+  walk(roots, 1);
+  return { min: dmin, max: dmax };
+}
+
+// -----------------------------------------------------------------------------
+// 索引与 LCA
+// -----------------------------------------------------------------------------
+
+/** 先序建 `byKey` / `parent` / `parentKey`(根父为 `undefined` / `null`)。 */
+export function keyIndex<T extends TreeLike>(roots: readonly T[], keyOf: (n: T) => string): KeyIndex<T> {
+  const byKey = new Map<string, T>();
+  const parent = new Map<string, T | undefined>();
+  const parentKey = new Map<string, string | null>();
+
+  const walk = (nodes: readonly T[], par: T | undefined, pk: string | null) => {
+    for (const n of nodes) {
+      const k = keyOf(n);
+      byKey.set(k, n);
+      parent.set(k, par);
+      parentKey.set(k, pk);
+      const kids = n.children;
+      if (kids?.length) walk(kids as T[], n, k);
+    }
+  };
+  walk(roots, undefined, null);
+  return { byKey, parent, parentKey };
+}
+
+/**
+ * LCA(父链):先 {@link keyIndex},再集合并上爬。
+ * 不同根子树不相交则 `undefined`。
+ */
+export function lcaKeys<T extends TreeLike>(
+  roots: readonly T[],
+  keyOf: (n: T) => string,
+  ka: string,
+  kb: string,
+): T | undefined {
+  const { byKey, parentKey } = keyIndex(roots, keyOf);
+  if (!byKey.has(ka) || !byKey.has(kb)) return undefined;
+
+  const up = new Set<string>();
+  let x: string | null = ka;
+  while (x !== null) {
+    up.add(x);
+    const p = parentKey.get(x);
+    x = p === undefined || p === null ? null : p;
+  }
+  x = kb;
+  while (x !== null) {
+    if (up.has(x)) return byKey.get(x);
+    const p = parentKey.get(x);
+    x = p === undefined || p === null ? null : p;
+  }
+  return undefined;
+}
+
+// -----------------------------------------------------------------------------
+// 拷贝
+// -----------------------------------------------------------------------------
+
+/** `...n` + 新开 `children` 数组一层,子节点对象不克隆。 */
+export function cloneChildren<T extends TreeLike>(n: T): T {
+  return { ...n, children: n.children ? [...n.children] : undefined } as T;
+}

+ 6 - 2
src/tools/url.tool.ts

@@ -8,13 +8,17 @@ export function getURLSearchParamsByUrl(value: string): URLSearchParams {
   return getURLSearchParams(snippet.map((param) => param.split('?')[1] || '').join('&'))
 }
 
-
-export function getClientURL(value: string, origin?: string) {
+export function getClientURL(value: string, origin = location.origin) {
+  if ( value?.startsWith(__APP_URL__) ) return value.replace(__APP_URL__, origin);
   if ( !value || /^https?:\/\//.test(value) ) return value;
   if ( value.startsWith('~') ) { value = value.slice(1); }
   return fullURL(value, origin);
 }
 
+export function getServerURL(value: string, origin = location.origin) {
+  return value?.startsWith(origin) ? value.replace(origin, __APP_URL__) : value;
+}
+
 function fullURL(value: string, origin = location.origin) {
   return origin + `${ import.meta.env.BASE_URL }/${ value }`.replace(/\/{2,}/g, '/');
 }

+ 1 - 0
vite.config.ts

@@ -19,6 +19,7 @@ export default defineConfig((configEnv) => {
     envPrefix: 'SIX_',
     define: {
       __APP_VERSION__: JSON.stringify(process.env.npm_package_version),
+      __APP_URL__: JSON.stringify(env.REQUEST_API_PROXY_URL),
       __FORBID_AUTO_PROCESS_PULSE_AGENCY__: argv.includes('--legacy-pulse-agency')
     },
     css: {