@cat-factory/app 0.162.2 → 0.164.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/app/components/board/RecurringPipelineModal.vue +9 -22
- package/app/components/focus/BlockFocusView.vue +25 -19
- package/app/components/layout/CommandBar.vue +8 -0
- package/app/components/layout/IntegrationsHub.vue +7 -0
- package/app/components/panels/InspectorPanel.vue +13 -0
- package/app/components/pipeline/PipelinePicker.vue +38 -16
- package/app/components/pipeline/PipelinePreview.vue +65 -23
- package/app/components/tasks/BugHuntModal.vue +361 -0
- package/app/components/tasks/ContextIssuePicker.logic.spec.ts +89 -0
- package/app/components/tasks/ContextIssuePicker.logic.ts +74 -0
- package/app/components/tasks/ContextIssuePicker.vue +71 -21
- package/app/components/tasks/TaskImportModal.vue +2 -4
- package/app/composables/api/bugHunt.ts +40 -0
- package/app/composables/useApi.ts +2 -0
- package/app/pages/index.vue +2 -0
- package/app/stores/bugHunt.spec.ts +103 -0
- package/app/stores/bugHunt.ts +148 -0
- package/app/stores/ui/modals.ts +14 -0
- package/app/types/bugHunt.ts +21 -0
- package/app/types/domain.ts +1 -0
- package/app/utils/pipeline.spec.ts +41 -1
- package/app/utils/pipeline.ts +9 -0
- package/i18n/locales/de.json +61 -5
- package/i18n/locales/en.json +64 -5
- package/i18n/locales/es.json +61 -5
- package/i18n/locales/fr.json +61 -5
- package/i18n/locales/he.json +61 -5
- package/i18n/locales/it.json +61 -5
- package/i18n/locales/ja.json +61 -5
- package/i18n/locales/pl.json +61 -5
- package/i18n/locales/tr.json +61 -5
- package/i18n/locales/uk.json +61 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -77,7 +77,10 @@ example ships in [`deploy/frontend`](../../deploy/frontend) (the `acme:security`
|
|
|
77
77
|
structure, dependencies, model + fragment picker, live execution, and linked
|
|
78
78
|
docs/issues/scenarios. Decisions resolve via `DecisionModal`.
|
|
79
79
|
- **Pipeline builder** (`components/pipeline`) — assemble/edit agent chains and
|
|
80
|
-
watch `PipelineProgress`.
|
|
80
|
+
watch `PipelineProgress`. `PipelinePicker` (+ its `PipelinePreview` pane) is the
|
|
81
|
+
single way a pipeline is chosen anywhere — add-task, run settings, the recurring
|
|
82
|
+
schedule, the focus view's Run menu — so every surface explains a pipeline by the
|
|
83
|
+
ordered steps it will run rather than by its name alone.
|
|
81
84
|
- **Integrations** — modals/panels for `github` (the source-control panel, shared
|
|
82
85
|
by every VCS provider), `vcs` (the GitLab personal-access-token connect),
|
|
83
86
|
`bootstrap`, `documents`, `tasks`, `requirements` (review), `scenarios`
|
|
@@ -70,17 +70,7 @@ function defaultRecurrence(): Recurrence {
|
|
|
70
70
|
const selectablePipelines = computed(() =>
|
|
71
71
|
pipelines.pipelines.filter((p) => pipelineAllowedForSchedule(p, frame.value, board.blocks)),
|
|
72
72
|
)
|
|
73
|
-
const pipelineMenu = computed(() => [
|
|
74
|
-
selectablePipelines.value.map((p) => ({
|
|
75
|
-
label: p.name,
|
|
76
|
-
icon: 'i-lucide-workflow',
|
|
77
|
-
onSelect: () => (pipelineId.value = p.id),
|
|
78
|
-
})),
|
|
79
|
-
])
|
|
80
73
|
const selectedPipeline = computed(() => pipelines.getPipeline(pipelineId.value))
|
|
81
|
-
const selectedPipelineLabel = computed(
|
|
82
|
-
() => selectedPipeline.value?.name ?? t('board.recurring.pickPipeline'),
|
|
83
|
-
)
|
|
84
74
|
|
|
85
75
|
// Infer the template from the picked pipeline so the backend seeds the right block
|
|
86
76
|
// description (and so we know to show the tracker config).
|
|
@@ -269,19 +259,16 @@ async function add() {
|
|
|
269
259
|
/>
|
|
270
260
|
</UFormField>
|
|
271
261
|
|
|
262
|
+
<!-- The rich picker, not a name-only menu: which steps a schedule will run unattended,
|
|
263
|
+
every time it fires, is exactly what the person setting it up needs to see. A schedule
|
|
264
|
+
must name a pipeline, so there is no "none" row. -->
|
|
272
265
|
<UFormField :label="t('board.recurring.pipeline')" required>
|
|
273
|
-
<
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
trailing-icon="i-lucide-chevron-down"
|
|
280
|
-
class="w-full justify-between"
|
|
281
|
-
>
|
|
282
|
-
{{ selectedPipelineLabel }}
|
|
283
|
-
</UButton>
|
|
284
|
-
</UDropdownMenu>
|
|
266
|
+
<PipelinePicker
|
|
267
|
+
v-model="pipelineId"
|
|
268
|
+
:options="selectablePipelines"
|
|
269
|
+
:placeholder="t('board.recurring.pickPipeline')"
|
|
270
|
+
trigger-class="w-full justify-between"
|
|
271
|
+
/>
|
|
285
272
|
</UFormField>
|
|
286
273
|
|
|
287
274
|
<UFormField :label="t('board.recurring.prompt')">
|
|
@@ -31,17 +31,19 @@ const deps = computed(() =>
|
|
|
31
31
|
// Hide UI-testing pipelines when this block's frame has no UI to exercise, `'recurring'`-only
|
|
32
32
|
// pipelines (a manual run of one is refused server-side), and — for a `document` task — every
|
|
33
33
|
// non-document pipeline (per the `purpose` classifier) — see the backend gate.
|
|
34
|
-
const
|
|
34
|
+
const runOptions = computed(() => {
|
|
35
35
|
const frame = block.value ? board.serviceOf(block.value) : undefined
|
|
36
|
-
return pipelines.pipelines
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
label: p.name,
|
|
40
|
-
icon: 'i-lucide-play',
|
|
41
|
-
onSelect: () => block.value && execution.start(block.value.id, p),
|
|
42
|
-
}))
|
|
36
|
+
return pipelines.pipelines.filter((p) =>
|
|
37
|
+
pipelineAllowedForManualStart(p, frame, board.blocks, block.value?.taskType),
|
|
38
|
+
)
|
|
43
39
|
})
|
|
44
40
|
|
|
41
|
+
/** Start the picked pipeline immediately — the Run menu chooses an ACTION, it stores no default. */
|
|
42
|
+
function runPipeline(id: string) {
|
|
43
|
+
const pipeline = pipelines.getPipeline(id)
|
|
44
|
+
if (pipeline && block.value) void execution.start(block.value.id, pipeline)
|
|
45
|
+
}
|
|
46
|
+
|
|
45
47
|
function close() {
|
|
46
48
|
ui.focus(null)
|
|
47
49
|
}
|
|
@@ -97,17 +99,21 @@ function openApprovalFor(approvalId: string) {
|
|
|
97
99
|
{{ statusMeta.label }}
|
|
98
100
|
</UBadge>
|
|
99
101
|
<div class="ms-auto flex items-center gap-2">
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
102
|
+
<!-- The rich picker rather than a list of names: the run starts the moment a row is
|
|
103
|
+
clicked, so the preview is the only chance to see which agents it will run. -->
|
|
104
|
+
<PipelinePicker model-value="" :options="runOptions" @update:model-value="runPipeline">
|
|
105
|
+
<template #trigger>
|
|
106
|
+
<UButton
|
|
107
|
+
color="primary"
|
|
108
|
+
variant="soft"
|
|
109
|
+
size="sm"
|
|
110
|
+
icon="i-lucide-play"
|
|
111
|
+
trailing-icon="i-lucide-chevron-down"
|
|
112
|
+
>
|
|
113
|
+
{{ instance ? t('focus.rerunPipeline') : t('focus.runPipeline') }}
|
|
114
|
+
</UButton>
|
|
115
|
+
</template>
|
|
116
|
+
</PipelinePicker>
|
|
111
117
|
<IconButton
|
|
112
118
|
icon="i-lucide-x"
|
|
113
119
|
color="neutral"
|
|
@@ -118,6 +118,14 @@ const dynamicIntegrationCommands = computed<Command[]>(() => {
|
|
|
118
118
|
keywords: t('layout.commandBar.keywords.taskImport'),
|
|
119
119
|
run: () => ui.openTaskImport(null),
|
|
120
120
|
})
|
|
121
|
+
list.push({
|
|
122
|
+
id: 'bug-hunt',
|
|
123
|
+
label: t('layout.commandBar.cmd.bugHunt'),
|
|
124
|
+
group: groupIntegrations,
|
|
125
|
+
icon: 'i-lucide-radar',
|
|
126
|
+
keywords: t('layout.commandBar.keywords.bugHunt'),
|
|
127
|
+
run: () => ui.openBugHunt(null),
|
|
128
|
+
})
|
|
121
129
|
}
|
|
122
130
|
}
|
|
123
131
|
return list
|
|
@@ -241,6 +241,13 @@ const groups = computed<IntegrationGroup[]>(() => {
|
|
|
241
241
|
description: t('layout.integrationsHub.items.taskImport.description'),
|
|
242
242
|
onClick: () => go(() => ui.openTaskImport(null)),
|
|
243
243
|
})
|
|
244
|
+
trackers.push({
|
|
245
|
+
key: 'task:bug-hunt',
|
|
246
|
+
icon: 'i-lucide-radar',
|
|
247
|
+
label: t('layout.integrationsHub.items.bugHunt.label'),
|
|
248
|
+
description: t('layout.integrationsHub.items.bugHunt.description'),
|
|
249
|
+
onClick: () => go(() => ui.openBugHunt(null)),
|
|
250
|
+
})
|
|
244
251
|
}
|
|
245
252
|
// Choosing the filing tracker / writeback is workspace CONFIG, not an integration, so it
|
|
246
253
|
// sits as a quiet footer link under the sources rather than a competing row.
|
|
@@ -451,6 +451,19 @@ const showOriginalDescription = ref(false)
|
|
|
451
451
|
: t('panels.inspector.connectTracker')
|
|
452
452
|
}}
|
|
453
453
|
</UButton>
|
|
454
|
+
<!-- Hunt this service's tracker board for a bug worth picking up. Scoped to the
|
|
455
|
+
selected container, so an adopted candidate lands here rather than wherever the
|
|
456
|
+
board's first frame happens to be. -->
|
|
457
|
+
<UButton
|
|
458
|
+
v-if="isContainer && tasks.anyOffered"
|
|
459
|
+
color="neutral"
|
|
460
|
+
variant="soft"
|
|
461
|
+
size="xs"
|
|
462
|
+
icon="i-lucide-radar"
|
|
463
|
+
@click="ui.openBugHunt(null, block!.id)"
|
|
464
|
+
>
|
|
465
|
+
{{ t('panels.inspector.huntBugs') }}
|
|
466
|
+
</UButton>
|
|
454
467
|
<UButton
|
|
455
468
|
v-if="isContainer && documents.available && documents.anyConnected"
|
|
456
469
|
color="neutral"
|
|
@@ -1,25 +1,36 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// The rich pipeline picker used wherever a pipeline is chosen (add-task
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
2
|
+
// The rich pipeline picker used wherever a pipeline is chosen — as a stored default (add-task
|
|
3
|
+
// modal, inspector run settings, the recurring-schedule modal) or as an immediate action (the
|
|
4
|
+
// focus view's Run menu). A master–detail popover: the left column lists the selectable pipelines
|
|
5
|
+
// (plus an optional "none / choose at run time" row), and hovering a row reveals that pipeline's
|
|
6
|
+
// full preview — its description and every step it will run, in order — in the right column, so a
|
|
7
|
+
// user sees exactly what a pipeline does before picking it. The trigger is customizable via the
|
|
8
|
+
// `#trigger` slot (the inspector uses a bare icon button; the modal a full-width labelled one).
|
|
8
9
|
import { computed, ref } from 'vue'
|
|
9
10
|
import type { Pipeline } from '~/types/domain'
|
|
10
11
|
|
|
11
12
|
const props = withDefaults(
|
|
12
13
|
defineProps<{
|
|
13
|
-
/** Selected pipeline id, or '' for
|
|
14
|
+
/** Selected pipeline id, or '' for none. An action-mode caller passes a constant ''. */
|
|
14
15
|
modelValue: string
|
|
15
16
|
/** The pipelines offered (already filtered for the surface, e.g. manual-start allowed). */
|
|
16
17
|
options: Pipeline[]
|
|
17
|
-
/**
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Label for the "none" row (e.g. "Choose at run time" / "No default"). Omit where picking
|
|
20
|
+
* nothing is not a valid outcome — a schedule needs a pipeline, and a Run menu acts on the
|
|
21
|
+
* chosen row — and the row is left out entirely.
|
|
22
|
+
*/
|
|
23
|
+
noneLabel?: string
|
|
24
|
+
/**
|
|
25
|
+
* Default-trigger text while nothing is selected. Defaults to {@link noneLabel}, which reads
|
|
26
|
+
* correctly when the "none" row IS the empty state; a surface without that row supplies its
|
|
27
|
+
* own prompt ("Pick a pipeline").
|
|
28
|
+
*/
|
|
29
|
+
placeholder?: string
|
|
19
30
|
/** Extra classes for the default trigger button (e.g. full-width in the modal). */
|
|
20
31
|
triggerClass?: string
|
|
21
32
|
}>(),
|
|
22
|
-
{ triggerClass: '' },
|
|
33
|
+
{ noneLabel: undefined, placeholder: undefined, triggerClass: '' },
|
|
23
34
|
)
|
|
24
35
|
|
|
25
36
|
const emit = defineEmits<{ 'update:modelValue': [string] }>()
|
|
@@ -31,7 +42,9 @@ const open = ref(false)
|
|
|
31
42
|
const hoverId = ref<string | undefined>(undefined)
|
|
32
43
|
|
|
33
44
|
const selected = computed(() => props.options.find((p) => p.id === props.modelValue))
|
|
34
|
-
const triggerLabel = computed(
|
|
45
|
+
const triggerLabel = computed(
|
|
46
|
+
() => selected.value?.name ?? props.placeholder ?? props.noneLabel ?? '',
|
|
47
|
+
)
|
|
35
48
|
|
|
36
49
|
/** The pipeline the right pane previews: the hovered row, else the current selection. */
|
|
37
50
|
const previewPipeline = computed<Pipeline | null>(() => {
|
|
@@ -39,6 +52,17 @@ const previewPipeline = computed<Pipeline | null>(() => {
|
|
|
39
52
|
return id ? (props.options.find((p) => p.id === id) ?? null) : null
|
|
40
53
|
})
|
|
41
54
|
|
|
55
|
+
/**
|
|
56
|
+
* With no pipeline to preview the pane explains WHY it's empty, and the two reasons differ: the
|
|
57
|
+
* "none" row is a real choice worth describing, while a picker without one is simply waiting for
|
|
58
|
+
* a hover. Sharing one hint would tell a Run menu it has no default pipeline, which is nonsense.
|
|
59
|
+
*/
|
|
60
|
+
const emptyHint = computed(() =>
|
|
61
|
+
props.noneLabel !== undefined && (hoverId.value ?? props.modelValue) === ''
|
|
62
|
+
? t('pipeline.picker.noneHint')
|
|
63
|
+
: t('pipeline.picker.hoverHint'),
|
|
64
|
+
)
|
|
65
|
+
|
|
42
66
|
function choose(id: string) {
|
|
43
67
|
emit('update:modelValue', id)
|
|
44
68
|
open.value = false
|
|
@@ -63,13 +87,13 @@ function choose(id: string) {
|
|
|
63
87
|
|
|
64
88
|
<template #content>
|
|
65
89
|
<div
|
|
66
|
-
class="flex max-h-[
|
|
90
|
+
class="flex max-h-[28rem] w-[min(44rem,94vw)]"
|
|
67
91
|
data-testid="pipeline-picker-panel"
|
|
68
92
|
@mouseleave="hoverId = undefined"
|
|
69
93
|
>
|
|
70
94
|
<!-- left: selectable options -->
|
|
71
95
|
<ul class="w-1/2 shrink-0 overflow-y-auto border-e border-slate-800 p-1">
|
|
72
|
-
<li>
|
|
96
|
+
<li v-if="noneLabel !== undefined">
|
|
73
97
|
<button
|
|
74
98
|
type="button"
|
|
75
99
|
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm hover:bg-slate-800/60"
|
|
@@ -110,9 +134,7 @@ function choose(id: string) {
|
|
|
110
134
|
<!-- right: preview of the hovered (or selected) pipeline -->
|
|
111
135
|
<div class="w-1/2 overflow-y-auto p-3">
|
|
112
136
|
<PipelinePreview v-if="previewPipeline" :pipeline="previewPipeline" />
|
|
113
|
-
<div v-else class="text-[12px] leading-snug text-slate-500">
|
|
114
|
-
{{ t('pipeline.picker.noneHint') }}
|
|
115
|
-
</div>
|
|
137
|
+
<div v-else class="text-[12px] leading-snug text-slate-500">{{ emptyHint }}</div>
|
|
116
138
|
</div>
|
|
117
139
|
</div>
|
|
118
140
|
</template>
|
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
// A compact, read-only summary of a pipeline: its name, prose description (when authored), and the
|
|
3
|
-
// ordered list of
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// ordered list of steps it will actually run — each a numbered row carrying the agent's icon, label
|
|
4
|
+
// and catalog description, with the human approval gates flagged. The FULL list, not a step count:
|
|
5
|
+
// "what does this pipeline actually do" is the only question a picker's preview pane exists to
|
|
6
|
+
// answer, and a row of interchangeable chips answers just "how many". Shared by the pipeline
|
|
7
|
+
// pickers everywhere a pipeline is chosen, so the explanation is identical on every surface.
|
|
8
|
+
// Resolves each step's display metadata through the single `agentKindMeta` path (via
|
|
9
|
+
// <AgentKindIcon> and {@link stepDescription}), so a system/custom kind can never blow up the
|
|
10
|
+
// renderer.
|
|
7
11
|
import { computed } from 'vue'
|
|
8
12
|
import type { Pipeline } from '~/types/domain'
|
|
9
|
-
import {
|
|
13
|
+
import { agentKindMeta } from '~/utils/catalog'
|
|
14
|
+
import { pipelineDisplaySteps, pipelineGateCount } from '~/utils/pipeline'
|
|
10
15
|
|
|
11
16
|
const props = defineProps<{ pipeline: Pipeline }>()
|
|
12
17
|
const { t } = useI18n()
|
|
13
18
|
|
|
14
19
|
const steps = computed(() => pipelineDisplaySteps(props.pipeline))
|
|
20
|
+
const gateCount = computed(() => pipelineGateCount(props.pipeline))
|
|
21
|
+
|
|
22
|
+
/** What the agent at this step does — the same catalog prose the palette and step tooltips use. */
|
|
23
|
+
function stepDescription(kind: string): string {
|
|
24
|
+
return agentKindMeta(kind).description
|
|
25
|
+
}
|
|
15
26
|
</script>
|
|
16
27
|
|
|
17
28
|
<template>
|
|
@@ -24,26 +35,57 @@ const steps = computed(() => pipelineDisplaySteps(props.pipeline))
|
|
|
24
35
|
>
|
|
25
36
|
{{ pipeline.description }}
|
|
26
37
|
</p>
|
|
27
|
-
|
|
28
|
-
|
|
38
|
+
|
|
39
|
+
<div
|
|
40
|
+
class="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] uppercase tracking-wide text-slate-500"
|
|
41
|
+
>
|
|
42
|
+
<span class="inline-flex items-center gap-1">
|
|
29
43
|
<UIcon name="i-lucide-workflow" class="h-3 w-3" />
|
|
30
44
|
{{ t('pipeline.preview.stepCount', { count: steps.length }, steps.length) }}
|
|
31
|
-
</
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
<AgentKindIcon :kind="s.kind" show-label icon-class="h-3.5 w-3.5" />
|
|
39
|
-
<UIcon
|
|
40
|
-
v-if="s.gated"
|
|
41
|
-
name="i-lucide-shield-check"
|
|
42
|
-
class="h-3 w-3 text-amber-400"
|
|
43
|
-
:title="t('pipeline.preview.gated')"
|
|
44
|
-
/>
|
|
45
|
-
</li>
|
|
46
|
-
</ol>
|
|
45
|
+
</span>
|
|
46
|
+
<!-- Gates are the reason a run stops for a human, so they earn a headline of their own
|
|
47
|
+
rather than only the per-step marker below. -->
|
|
48
|
+
<span v-if="gateCount" class="inline-flex items-center gap-1 text-amber-500">
|
|
49
|
+
<UIcon name="i-lucide-shield-check" class="h-3 w-3" />
|
|
50
|
+
{{ t('pipeline.preview.gateCount', { count: gateCount }, gateCount) }}
|
|
51
|
+
</span>
|
|
47
52
|
</div>
|
|
53
|
+
|
|
54
|
+
<!-- The ordered steps. The number column doubles as the flow connector (a rule drawn between
|
|
55
|
+
consecutive numbers), so the list reads as a sequence rather than an unordered set. -->
|
|
56
|
+
<ol>
|
|
57
|
+
<li
|
|
58
|
+
v-for="(s, i) in steps"
|
|
59
|
+
:key="i"
|
|
60
|
+
class="flex gap-2"
|
|
61
|
+
data-testid="pipeline-preview-step"
|
|
62
|
+
:data-step-kind="s.kind"
|
|
63
|
+
>
|
|
64
|
+
<div class="flex flex-col items-center">
|
|
65
|
+
<span
|
|
66
|
+
class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-slate-800 font-mono text-[9px] tabular-nums text-slate-400"
|
|
67
|
+
>
|
|
68
|
+
{{ i + 1 }}
|
|
69
|
+
</span>
|
|
70
|
+
<span v-if="i < steps.length - 1" class="w-px flex-1 bg-slate-800" />
|
|
71
|
+
</div>
|
|
72
|
+
<div class="min-w-0 flex-1 pb-2">
|
|
73
|
+
<div class="flex items-center gap-1">
|
|
74
|
+
<AgentKindIcon :kind="s.kind" show-label icon-class="h-3.5 w-3.5" />
|
|
75
|
+
<UIcon
|
|
76
|
+
v-if="s.gated"
|
|
77
|
+
name="i-lucide-shield-check"
|
|
78
|
+
class="h-3 w-3 shrink-0 text-amber-400"
|
|
79
|
+
:title="t('pipeline.preview.gated')"
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
<!-- Clamped: the catalog prose runs long for some kinds, and <AgentKindIcon> already
|
|
83
|
+
carries the full text in its hover tooltip. -->
|
|
84
|
+
<p class="line-clamp-2 text-[11px] leading-snug text-slate-500">
|
|
85
|
+
{{ stepDescription(s.kind) }}
|
|
86
|
+
</p>
|
|
87
|
+
</div>
|
|
88
|
+
</li>
|
|
89
|
+
</ol>
|
|
48
90
|
</div>
|
|
49
91
|
</template>
|