zhangwencui
7 天以前 dab35bff798cfce2ddf7e8a67e4e1cdb144f5b4c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
<script setup lang="ts">
  import { computed, ref, watch } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import {
    createProcessInstance,
    getBpmSimpleDeptList,
    getBpmSimpleUserList,
    getProcessDefinition,
    getProcessInstance,
    getSimpleDictDataList,
    getStartApprovalNodes,
    type BpmApprovalDetail,
    type BpmApprovalNode,
    type BpmDept,
    type BpmProcessDefinition,
    type BpmUser,
    type DictData,
  } from "../../../../api/bpm";
  import { useAuth } from "../../../../stores/auth";
  import {
    buildTree,
    confirmModal,
    CANDIDATE_STRATEGY_START_USER_SELECT,
    FIELD_PERMISSION,
    flattenTree,
    MODEL_FORM_TYPE,
    toast,
  } from "../../shared";
  import { buildFormValue, parseFormFields, type FormField } from "../../form-parser";
  import BpmFormRenderer from "../../components/BpmFormRenderer.vue";
  import BpmApprovalFlow from "../../components/BpmApprovalFlow.vue";
  import DeptTreePicker from "../../components/DeptTreePicker.vue";
  import PickerSheet from "../../../mes/process-design/components/PickerSheet.vue";
  import type { SheetOption } from "../../../mes/process-design/shared";
 
  const { state } = useAuth();
  /**
   * App 端 onLoad 可能保留 navigateTo 参数中的百分号编码,H5 则通常已自动解码。
   * 流程定义 id 含冒号,未解码会导致缓存匹配失败并把错误 id 传给后端。
   */
  function decodeRouteParam(value: unknown) {
    const raw = value === undefined || value === null ? "" : String(value);
    try {
      return decodeURIComponent(raw);
    } catch {
      return raw;
    }
  }
 
  const SELECTED_DEFINITION_KEY = "bpm_create_selected_definition";
  const selectedDefinition = ref<BpmProcessDefinition>();
 
  const definitionId = ref("");
  /** 调用方显式传入的流程定义 id(从列表页选择 / 重新发起时解析出的最新版本) */
  const explicitDefinitionId = ref("");
  const processInstanceId = ref("");
 
  const loading = ref(true);
  const submitting = ref(false);
  const loadError = ref("");
  /** 首次加载完成(用于控制表单值变化后的审批节点重预测) */
  const ready = ref(false);
 
  /** 表单字段与值 */
  const fields = ref<FormField[]>([]);
  const formValue = ref<Record<string, any>>({});
  const permissions = ref<Record<string, string>>({});
 
  /** 审批节点(发起阶段为预测结果) */
  const activityNodes = ref<BpmApprovalNode[]>([]);
  /** 审批节点加载失败原因(非空时在卡片内提示) */
  const approvalFlowError = ref("");
 
  /** 发起人自选审批人:activityId -> userIds */
  const startUserSelectAssignees = ref<Record<string, number[]>>({});
 
  /* ---------- 下拉/选择面板数据 ---------- */
 
  const users = ref<BpmUser[]>([]);
  const depts = ref<BpmDept[]>([]);
  /**
   * 部门树。
   * `/system/dept/simple-list` 返回的是**扁平列表**(靠 parentId 关联),
   * web 端 dept-select.vue 也是先 `handleTree(data)` 再交给 TreeSelect。
   * 选择面板与回显共用这棵树,加载完成后建一次即可。
   */
  const deptTree = ref<BpmDept[]>([]);
  const dicts = ref<DictData[]>([]);
 
  /** 当前面板的用途:审批人 or 表单字段 */
  const sheetVisible = ref(false);
  const sheetTitle = ref("");
  const sheetOptions = ref<SheetOption[]>([]);
  const sheetMultiple = ref(false);
  const sheetSelected = ref<Array<number | string>>([]);
 
  /** 部门选择面板(树形,独立于通用 PickerSheet) */
  const deptSheetVisible = ref(false);
  const deptSheetTitle = ref("选择部门");
  const deptSheetMultiple = ref(false);
  const deptSheetSelected = ref<Array<number | string>>([]);
 
  /**
   * 字段名 -> 已加载的候选选项,传给 BpmFormRenderer 做回显。
   * UserSelect / DeptSelect / DictSelect 存的是后端的原始 id 或枚举值,
   * 渲染器自己不请求数据,没有这张映射表就只能显示「122」这种原始值。
   */
  const fieldOptions = ref<Record<string, SheetOption[]>>({});
 
  /**
   * 发起/重新发起阶段表单应保持可填写。
   * 预测审批节点接口可能返回后续审批节点的只读权限;移动端发起页若直接套用,
   * 会把姓名、年龄等字段渲染成只读,用户看起来像“输入框点不动”。
   */
  const startPermissions = computed<Record<string, string>>(() => {
    const writable: Record<string, string> = {};
    fields.value.forEach(field => {
      writable[field.field] =
        permissions.value[field.field] === FIELD_PERMISSION.NONE
          ? FIELD_PERMISSION.NONE
          : FIELD_PERMISSION.WRITE;
    });
    return writable;
  });
 
  /** 表单字段选择 */
  const activeField = ref<FormField>();
  /** 审批人选择 */
  const activeNode = ref<BpmApprovalNode>();
 
  async function ensurePickerData(kind: string) {
    if (!state.accessToken) return;
    if (kind === "user" && !users.value.length) {
      users.value = (await getBpmSimpleUserList(state.accessToken)) || [];
    }
    if (kind === "dept" && !depts.value.length) {
      depts.value = (await getBpmSimpleDeptList(state.accessToken)) || [];
      deptTree.value = buildTree<BpmDept>(depts.value);
    }
    if (kind === "dict" && !dicts.value.length) {
      dicts.value = (await getSimpleDictDataList(state.accessToken)) || [];
    }
  }
 
  /**
   * 部门树拍平为「值 -> 名称」选项(带层级缩进)。
   * 单选时父级也可作为值,所以父级 id 同样要能被查到。
   */
  function deptFlatOptions(): SheetOption[] {
    return flattenTree<BpmDept>(deptTree.value).map(({ node, depth }) => ({
      key: Number(node.id),
      label: `${" ".repeat(depth)}${node.name || node.id}`,
    }));
  }
 
  /**
   * 目标字段的候选选项(依赖 ensurePickerData 已加载对应数据)。
   * 与选择面板展示的选项完全一致,因此可以直接用来把值反查成名称。
   */
  function buildFieldOptions(field: FormField): SheetOption[] {
    if (field.kind === "user") {
      return users.value.map(u => ({ key: Number(u.id), label: u.nickname || String(u.id) }));
    }
    if (field.kind === "dept") return deptFlatOptions();
    if (field.kind === "dict") {
      return dicts.value
        .filter(item => item.dictType === field.dictType)
        .map(item => ({ key: item.value, label: item.label }));
    }
    return [];
  }
 
  /**
   * 缓存字段选项供渲染器回显。
   * 部门树选项带层级缩进(全角空格),回显时要去掉,否则「部门」后面会跟一串空白。
   */
  function cacheFieldOptions(field: FormField, options: SheetOption[]) {
    fieldOptions.value[field.field] = options.map(item => ({
      ...item,
      label: String(item.label).replace(/^\s+/, ""),
    }));
  }
 
  /**
   * 预加载「已有值」的选择类字段的选项,保证一进页面就能显示名称。
   * 典型场景:重新发起时表单值来自历史实例,此时人员/部门/字典列表还没请求过。
   */
  async function primeFieldOptions() {
    for (const field of fields.value) {
      if (field.kind !== "user" && field.kind !== "dept" && field.kind !== "dict") continue;
      const value = formValue.value[field.field];
      const empty =
        value === undefined || value === null || value === "" ||
        (Array.isArray(value) && value.length === 0);
      if (empty) continue;
      try {
        await ensurePickerData(field.kind);
        const options = buildFieldOptions(field);
        if (options.length) cacheFieldOptions(field, options);
      } catch {
        // 预加载失败不阻塞表单:回显退化为原始值,用户点开选择面板仍可用
      }
    }
  }
 
  /** 点击表单里的 UserSelect / DeptSelect / DictSelect 字段 */
  async function onFieldPick(field: FormField) {
    activeField.value = field;
    activeNode.value = undefined;
    sheetTitle.value = field.title;
    sheetMultiple.value = field.multiple;
    const current = formValue.value[field.field];
    const currentKeys = Array.isArray(current)
      ? [...current]
      : current === undefined || current === null || current === ""
        ? []
        : [current];
    sheetSelected.value = currentKeys;
    deptSheetSelected.value = [...currentKeys];
 
    uni.showLoading({ title: "加载选项…", mask: true });
    try {
      await ensurePickerData(field.kind);
      // 部门走树形面板(对齐 web 端 antd TreeSelect:树状 + 可搜 + 父级可选)
      if (field.kind === "dept") {
        if (!deptTree.value.length) {
          toast("暂无可选项");
          return;
        }
        deptSheetTitle.value = field.title;
        deptSheetMultiple.value = field.multiple;
        // 缓存选项供渲染器回显名称(选中后立刻就能显示中文名)
        cacheFieldOptions(field, deptFlatOptions());
        deptSheetVisible.value = true;
        return;
      }
      sheetOptions.value = buildFieldOptions(field);
      if (!sheetOptions.value.length) {
        // 省市区等复杂组件移动端暂不支持,或确实没有可选项
        toast(
          field.kind === "user" || field.kind === "dict"
            ? "暂无可选项"
            : "该字段类型请在 Web 端填写",
        );
        return;
      }
      // 缓存下来供渲染器回显名称(选中后立刻就能显示中文名)
      cacheFieldOptions(field, sheetOptions.value);
      sheetVisible.value = true;
    } catch (error) {
      toast(error instanceof Error ? error.message : "选项加载失败");
    } finally {
      uni.hideLoading();
    }
  }
 
  /** 部门树面板确认(单选直接回填,多选已按 web 端 SHOW_CHILD 只保留叶子) */
  function onDeptConfirm(keys: Array<number | string>) {
    const field = activeField.value;
    if (!field) return;
    if (field.multiple) formValue.value[field.field] = [...keys];
    else formValue.value[field.field] = keys[0];
  }
 
  /** 点击审批流里的「自选审批人」节点 */
  async function onPickUser(node: BpmApprovalNode) {
    if (!state.accessToken || !node.id) return;
    activeNode.value = node;
    activeField.value = undefined;
    sheetTitle.value = node.name || "选择审批人";
    sheetMultiple.value = true;
    sheetSelected.value = [...(startUserSelectAssignees.value[node.id] || [])];
 
    uni.showLoading({ title: "加载人员…", mask: true });
    try {
      await ensurePickerData("user");
      sheetOptions.value = users.value.map(u => ({
        key: Number(u.id),
        label: u.nickname || String(u.id),
      }));
      if (!sheetOptions.value.length) {
        toast("暂无可选人员");
        return;
      }
      sheetVisible.value = true;
    } catch (error) {
      toast(error instanceof Error ? error.message : "人员加载失败");
    } finally {
      uni.hideLoading();
    }
  }
 
  function onSheetConfirm(keys: Array<number | string>) {
    // 审批人
    if (activeNode.value?.id) {
      startUserSelectAssignees.value[activeNode.value.id] = keys.map(Number);
      return;
    }
    // 表单字段
    const field = activeField.value;
    if (!field) return;
    if (field.multiple) formValue.value[field.field] = [...keys];
    else formValue.value[field.field] = keys[0];
  }
 
  /** 已选审批人文案:activityId -> 昵称串 */
  function pickedUserText(): Record<string, string> {
    const map: Record<string, string> = {};
    Object.keys(startUserSelectAssignees.value).forEach(activityId => {
      const ids = startUserSelectAssignees.value[activityId] || [];
      if (!ids.length) return;
      map[activityId] = ids
        .map(id => users.value.find(u => Number(u.id) === id)?.nickname ?? id)
        .join("、");
    });
    return map;
  }
 
  /* ---------- 加载 ---------- */
 
  /** 拉取预测审批节点(与 web 端 getApprovalDetail 一致) */
  async function loadApprovalNodes(presetVariables?: Record<string, any>) {
    if (!state.accessToken || !definitionId.value) return;
    // 重预测前暂存用户已选的审批人,避免节点变化导致已选丢失
    const previous = { ...startUserSelectAssignees.value };
    try {
      const detail: BpmApprovalDetail | undefined = await getStartApprovalNodes(
        state.accessToken,
        {
          processDefinitionId: definitionId.value,
          activityId: "StartUserNode",
          processVariablesStr: JSON.stringify(presetVariables ?? {}),
        },
      );
      const nodes = detail?.activityNodes || [];
      activityNodes.value = nodes;
      approvalFlowError.value = "";
      permissions.value = detail?.formFieldsPermission ?? {};
      // 重建自选审批人槽位:沿用同 activityId 的已选值
      const assignees: Record<string, number[]> = {};
      nodes.forEach(node => {
        if (Number(node.candidateStrategy) !== CANDIDATE_STRATEGY_START_USER_SELECT) return;
        if (!node.id) return;
        assignees[node.id] = previous[node.id] || [];
      });
      startUserSelectAssignees.value = assignees;
    } catch (error) {
      activityNodes.value = [];
      setApprovalFlowError(error);
    }
  }
 
  function setApprovalFlowError(error: unknown) {
    approvalFlowError.value = error instanceof Error ? error.message : "审批节点加载失败";
  }
 
  async function initForm() {
    if (!state.accessToken || (!explicitDefinitionId.value && !processInstanceId.value)) {
      loadError.value = "缺少流程定义";
      loading.value = false;
      return;
    }
    loading.value = true;
    loadError.value = "";
    try {
      let preset: Record<string, any> | undefined;
      /** 流程实例所属流程定义的 key(重新发起时用于取「最新版本」定义) */
      let instanceKey = "";
      /** 流程实例绑定的定义 id(兜底,可能是旧版本) */
      let instanceBoundId = "";
 
      // 重新发起:读取历史表单值并裁掉非表单字段
      if (processInstanceId.value) {
        const instance = await getProcessInstance(state.accessToken, processInstanceId.value);
        if (instance) {
          instanceKey = instance.processDefinition?.key ?? instance.processDefinitionKey ?? "";
          instanceBoundId = String(
            instance.processDefinition?.id ?? instance.processDefinitionId ?? "",
          );
          preset = { ...(instance.formVariables || {}) };
        }
      }
 
      // 定义优先级:调用方显式指定的 id(已是最新版本)
      //   > 实例 key 对应的最新版本 > 实例绑定的定义 id
      let def: BpmProcessDefinition | undefined = selectedDefinition.value;
      if (!def?.id && explicitDefinitionId.value) {
        def = await getProcessDefinition(state.accessToken, { id: explicitDefinitionId.value });
      }
      if (!def?.id && instanceKey) {
        def = await getProcessDefinition(state.accessToken, { key: instanceKey });
      }
      if (!def?.id && instanceBoundId) {
        def = await getProcessDefinition(state.accessToken, { id: instanceBoundId });
      }
      if (!def?.id) {
        loadError.value = "未找到对应的流程定义";
        return;
      }
      // 同步最终使用的定义,供审批节点预测与提交使用
      definitionId.value = String(def.id);
 
      // 业务表单(CUSTOM)在 web 端会跳到业务自己的创建页(formCustomCreatePath);
      // App 端没有这些业务页面,只能提示回 Web 端办理。
      if (Number(def.formType) === MODEL_FORM_TYPE.CUSTOM) {
        loadError.value = "该流程为业务表单,请在 Web 端发起";
        toast(loadError.value);
        setTimeout(() => uni.navigateBack(), 800);
        return;
      }
      uni.setNavigationBarTitle({ title: def.name ? `发起 · ${def.name}` : "发起流程" });
      fields.value = parseFormFields(def.formFields);
      const allowed = new Set(fields.value.map(f => f.field));
      if (preset) {
        Object.keys(preset).forEach(k => {
          if (!allowed.has(k)) delete preset![k];
        });
      }
      formValue.value = buildFormValue(fields.value, preset);
      await loadApprovalNodes(preset);
      // 预加载「已有值」的动态字段选项,保证一进页面就显示名称而不是原始 id
      await primeFieldOptions();
    } catch (error) {
      loadError.value = error instanceof Error ? error.message : "表单加载失败";
    } finally {
      loading.value = false;
      ready.value = !loadError.value;
    }
  }
 
  /* ---------- 提交 ---------- */
 
  /** 裁掉非表单字段(与 web 端 submitForm 一致,防后端报错) */
  function trimmedVariables() {
    const allowed = new Set(fields.value.map(f => f.field));
    const variables: Record<string, any> = {};
    Object.keys(formValue.value).forEach(key => {
      if (!allowed.has(key)) return;
      variables[key] = formValue.value[key];
    });
    return variables;
  }
 
  /** 必填校验 */
  function firstInvalidMessage() {
    for (const field of fields.value) {
      if (!field.required) continue;
      const value = formValue.value[field.field];
      const empty =
        value === undefined || value === null || value === "" ||
        (Array.isArray(value) && value.length === 0);
      if (empty) return `请填写${field.title}`;
    }
    return "";
  }
 
  async function submitForm() {
    if (!state.accessToken || submitting.value || !definitionId.value) return;
 
    const invalid = firstInvalidMessage();
    if (invalid) {
      toast(invalid);
      return;
    }
 
    // 自选审批人未选齐时拦截
    for (const node of activityNodes.value) {
      if (Number(node.candidateStrategy) !== CANDIDATE_STRATEGY_START_USER_SELECT) continue;
      if (!node.id) continue;
      const ids = startUserSelectAssignees.value[node.id] || [];
      if (!ids.length) {
        toast(`请选择「${node.name || "审批人"}」的审批人`);
        return;
      }
    }
 
    submitting.value = true;
    try {
      await createProcessInstance(state.accessToken, {
        processDefinitionId: definitionId.value,
        variables: trimmedVariables(),
        startUserSelectAssignees: startUserSelectAssignees.value,
      });
      toast("发起成功");
      setTimeout(() => {
        uni.redirectTo({ url: "/pages/bpm/process-instance/my/index" });
      }, 600);
    } catch (error) {
      toast(error instanceof Error ? error.message : "发起失败");
    } finally {
      submitting.value = false;
    }
  }
 
  /** 取消:有填写内容时二次确认(使用系统返回) */
  async function handleCancel() {
    const hasInput = Object.values(formValue.value).some(v =>
      Array.isArray(v) ? v.length : v !== undefined && v !== null && v !== "",
    );
    if (hasInput) {
      const ok = await confirmModal("确认放弃当前填写并返回?");
      if (!ok) return;
    }
    uni.navigateBack();
  }
 
  onLoad(query => {
    explicitDefinitionId.value = decodeRouteParam(query?.id);
    definitionId.value = explicitDefinitionId.value;
    processInstanceId.value = decodeRouteParam(query?.processInstanceId);
    try {
      const cachedValue = uni.getStorageSync(SELECTED_DEFINITION_KEY) as
        | BpmProcessDefinition
        | string
        | undefined;
      const cached = typeof cachedValue === "string"
        ? (JSON.parse(cachedValue) as BpmProcessDefinition)
        : cachedValue;
      if (cached?.id && String(cached.id) === explicitDefinitionId.value) {
        selectedDefinition.value = cached;
      }
      uni.removeStorageSync(SELECTED_DEFINITION_KEY);
    } catch {
      selectedDefinition.value = undefined;
    }
    initForm();
  });
 
  /**
   * 表单值变化后重新预测审批节点(对齐 web 端 watch(detailForm.value.value))。
   * 条件分支流程的审批链路依赖表单变量,改了表单就要重算,否则审批流展示会过期。
   * 仅在首次加载完成后生效(ready),避免初始化赋值本身触发重复请求。
   */
  let repredictTimer: ReturnType<typeof setTimeout> | undefined;
  watch(
    formValue,
    () => {
      if (!ready.value || !definitionId.value) return;
      if (repredictTimer) clearTimeout(repredictTimer);
      repredictTimer = setTimeout(() => {
        loadApprovalNodes(formValue.value);
      }, 400);
    },
    { deep: true },
  );
</script>
 
<template>
  <view class="page">
    <view v-if="loadError" class="state error" @click="initForm">{{ loadError }},点击重试</view>
    <view v-else-if="loading" class="state">加载中...</view>
 
    <scroll-view v-else class="content" scroll-y>
      <!-- 审批流程(与 web 端右侧审批流一致) -->
      <view class="card">
        <text class="card-title">审批流程</text>
        <text class="card-sub">提交后将按以下流程流转</text>
        <BpmApprovalFlow
          :nodes="activityNodes"
          :selectable="true"
          :picked-text="pickedUserText()"
          :empty-text="approvalFlowError || '该流程未配置审批节点'"
          @pick-user="onPickUser"
        />
      </view>
 
      <!-- 表单信息 -->
      <view class="card">
        <text class="card-title">表单信息</text>
        <BpmFormRenderer
          v-model="formValue"
          :fields="fields"
          :permissions="startPermissions"
          :field-options="fieldOptions"
          @pick="onFieldPick"
        />
      </view>
    </scroll-view>
 
    <view v-if="!loading && !loadError" class="footer">
      <button class="btn ghost" @click="handleCancel">取消</button>
      <button class="btn primary" :loading="submitting" :disabled="submitting" @click="submitForm">
        发起
      </button>
    </view>
 
    <PickerSheet
      v-model:show="sheetVisible"
      :title="sheetTitle"
      :multiple="sheetMultiple"
      :items="sheetOptions"
      :selected-keys="sheetSelected"
      @confirm="onSheetConfirm"
    />
 
    <DeptTreePicker
      v-model:show="deptSheetVisible"
      :title="deptSheetTitle"
      :multiple="deptSheetMultiple"
      :nodes="deptTree"
      :selected-keys="deptSheetSelected"
      @confirm="onDeptConfirm"
    />
  </view>
</template>
 
<style lang="scss" scoped>
  .page{height:100vh;display:flex;flex-direction:column;padding:0 30rpx;box-sizing:border-box;overflow:hidden;background:#f3f6fb}
  /* 底部固定栏占位:给内容留出足够的下方空间,避免最后一个字段被按钮挡住 */
  .content{height:0;flex:1;padding:20rpx 0 200rpx;box-sizing:border-box}
  .card{padding:24rpx 22rpx;margin-bottom:18rpx;background:#fff;border-radius:16rpx;box-shadow:0 10rpx 24rpx rgba(43,69,107,.08)}
  .card-title{display:block;color:#20334c;font-size:28rpx;font-weight:600}
  .card-sub{display:block;margin:6rpx 0 20rpx;color:#93a1b3;font-size:23rpx}
  .card-title + .bpm-form{padding-top:20rpx}
  /* 底部操作栏固定在屏幕底部(position:fixed 在 H5 与小程序都以可视区为基准,
     不受 H5 下 100vh 与导航栏高度不一致的影响) */
  .footer{position:fixed;z-index:20;right:0;bottom:0;left:0;display:flex;gap:20rpx;padding:16rpx 30rpx calc(20rpx + env(safe-area-inset-bottom));background:#fff;border-top:1rpx solid #e9eef5;box-shadow:0 -6rpx 20rpx rgba(43,69,107,.06)}
  .btn{flex:1;font-size:29rpx;border-radius:14rpx}
  .btn.ghost{color:#3d5069;background:#fff;border:1rpx solid #dbe4ef}
  .btn.primary{color:#fff;background:var(--color-primary, #1769d8)}
  .state{flex:1;padding:60rpx 20rpx;color:#8795a8;font-size:25rpx;text-align:center}
  .state.error{color:#c65050}
</style>