android-midscene-automation 0.1.43 → 0.2.1

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.
@@ -17,16 +17,28 @@ export function aiAnswerText(content: string | null | undefined) {
17
17
  return (content || '').replace(/<think>[\s\S]*?<\/think>/gi, '').trim().replace(/^```(?:json)?\s*([\s\S]*?)\s*```$/i, '$1').trim();
18
18
  }
19
19
 
20
+ export class AiInvalidResultError extends Error {
21
+ constructor(message: string) {
22
+ super(message);
23
+ this.name = 'AiInvalidResultError';
24
+ }
25
+ }
26
+
27
+ export function resolveAiInvalidResultFallback(error: unknown, branch?: 'yes' | 'no'): boolean | null {
28
+ if (!(error instanceof AiInvalidResultError) || (branch !== 'yes' && branch !== 'no')) return null;
29
+ return branch === 'yes';
30
+ }
31
+
20
32
  export function parseAiRecognitionResult(content: string | null | undefined): { result: boolean; reason: string } {
21
33
  // 只接受 JSON 布尔值;字符串 "false"、不确定或格式错误不能误走 false 分支。
22
34
  const text = aiAnswerText(content);
23
35
  let value: unknown;
24
- try { value = JSON.parse(text); } catch { throw new Error('AI 识别未返回有效 JSON 结果'); }
36
+ try { value = JSON.parse(text); } catch { throw new AiInvalidResultError('AI 识别未返回有效 JSON 结果'); }
25
37
  if (!value || typeof value !== 'object' || !('result' in value) || typeof value.result !== 'boolean') {
26
38
  if (value && typeof value === 'object' && 'result' in value && value.result === null) {
27
- throw new Error(`AI 无法判断,无法进入分支:${'reason' in value && typeof value.reason === 'string' ? value.reason.slice(0, 2000) : '未返回确定的判断结果'}`);
39
+ throw new AiInvalidResultError(`AI 无法判断:${'reason' in value && typeof value.reason === 'string' ? value.reason.slice(0, 2000) : '未返回确定的判断结果'}`);
28
40
  }
29
- throw new Error('AI 识别结果必须包含布尔值 result(true/false)');
41
+ throw new AiInvalidResultError('AI 识别结果必须包含布尔值 result(true/false)');
30
42
  }
31
43
  const reason = 'reason' in value && typeof value.reason === 'string' ? value.reason.trim() : '';
32
44
  return { result: value.result, reason: reason.slice(0, 2000) };
@@ -96,7 +108,7 @@ export async function recognizeDeviceScreen(input: {
96
108
  // 先记录正文再解析,格式错误或被截断的回答也可排查;不记录请求与鉴权信息。
97
109
  const content = (choice?.message.content || '').split(model.apiKey).join('[REDACTED]');
98
110
  input.onModelOutput?.(content || '(空响应)');
99
- if (choice?.finish_reason !== 'stop') throw new Error('AI 识别响应未完整返回');
111
+ if (choice?.finish_reason !== 'stop') throw new AiInvalidResultError('AI 识别响应未完整返回');
100
112
  if (input.aiBranchEnabled === false && observationConfig?.mode !== 'untilMatch') {
101
113
  const reason = aiAnswerText(content);
102
114
  if (!reason) throw new Error('AI 识别未返回回答内容');
@@ -10,7 +10,7 @@ import { adbScreenshotBase64 } from './screenshot';
10
10
  import { loadConfig } from '../config';
11
11
  import { timestampReplayLog } from './replay-log';
12
12
  import { checkImage } from './image-check';
13
- import { recognizeDeviceScreen } from './ai-recognition';
13
+ import { recognizeDeviceScreen, resolveAiInvalidResultFallback } from './ai-recognition';
14
14
  import { IMAGE_CHECK_MODES, type ImageCheckResult } from '../../src/appium-recorder/image-check';
15
15
  import { formatStageLog } from '../../src/appium-recorder/stage-log';
16
16
  import { openGalleryOnDevice } from './open-gallery';
@@ -1347,6 +1347,16 @@ async function replayFlowSteps(
1347
1347
  await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'stopped', '已终止');
1348
1348
  throw new ReplayStoppedError();
1349
1349
  }
1350
+ const invalidResultFallback = step.type === 'aiRecognition'
1351
+ ? resolveAiInvalidResultFallback(error, step.aiInvalidResultBranch)
1352
+ : null;
1353
+ if (invalidResultFallback !== null) {
1354
+ const status = `AI 未返回有效 true/false,按兜底配置进入 ${invalidResultFallback ? 'true' : 'false'} 分支`;
1355
+ lines.push(`[节点 ${index + 1}] ${status};${errorDetail(error)}`);
1356
+ await captureReplayFrame(sessionId, frameStep, index + 1, options.scriptName || '', 'after', status);
1357
+ index = traversal.branch(step, invalidResultFallback);
1358
+ continue;
1359
+ }
1350
1360
  if (error instanceof ConditionTimeoutError && (step.timeoutBranch === 'yes' || step.timeoutBranch === 'no')) {
1351
1361
  const branch = step.timeoutBranch;
1352
1362
  const status = `判断超时,按配置进入${branch === 'yes' ? '左' : '右'}侧分支(${flowBranchLabel(step, branch)})`;
@@ -1,4 +1,4 @@
1
- import { validateAiObservation, type AiObservationConfig } from '../../src/appium-recorder/ai-recognition';
1
+ import { validateAiInvalidResultBranch, validateAiObservation, type AiInvalidResultBranch, type AiObservationConfig } from '../../src/appium-recorder/ai-recognition';
2
2
  import {
3
3
  createId,
4
4
  querySql,
@@ -103,6 +103,7 @@ export type AppiumRecordedStepRecord = {
103
103
  timeoutMs?: number;
104
104
  aiTimeoutEnabled?: boolean;
105
105
  aiBranchEnabled?: boolean;
106
+ aiInvalidResultBranch?: AiInvalidResultBranch;
106
107
  aiObservation?: AiObservationConfig;
107
108
  timeoutBranch?: 'stop' | 'yes' | 'no';
108
109
  longPressMode?: 'element' | 'coordinates';
@@ -382,7 +383,10 @@ export function saveAppiumRecordedScript(input: {
382
383
  if (input.variables !== undefined) validateVariables(input.variables);
383
384
  for (const step of steps) {
384
385
  // 读取旧脚本必须容错,让用户能打开并修正无效配置;仅在写入时校验。
385
- if (step.type === 'aiRecognition') validateAiObservation(step.aiObservation);
386
+ if (step.type === 'aiRecognition') {
387
+ validateAiObservation(step.aiObservation);
388
+ validateAiInvalidResultBranch(step.aiInvalidResultBranch);
389
+ }
386
390
  if (step.type === 'imageCheck') validateImageCheck(step.imageCheck);
387
391
  if (step.type === 'extractVariable') validateExtraction(step.extractVariable);
388
392
  if (step.type === 'runScript') { validateVariables(step.parameters); validateReturns(step.returns); }
package/src/App.vue CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  Loading,
13
13
  Monitor,
14
14
  Operation,
15
+ QuestionFilled,
15
16
  RefreshLeft,
16
17
  Setting,
17
18
  VideoPause,
@@ -40,6 +41,7 @@ import AppiumPage from './appium-recorder/AppiumPage.vue';
40
41
  import { normalizeFlowBackground, normalizeFlowLineColor } from './appium-recorder/flow-appearance';
41
42
  import ConfigPage from './pages/ConfigPage.vue';
42
43
  import GeneratorPage from './pages/GeneratorPage.vue';
44
+ import HelpCenterDialog from './components/help/HelpCenterDialog.vue';
43
45
  import type {
44
46
  AndroidDevice,
45
47
  ConfigForm,
@@ -67,6 +69,7 @@ const isSavingAppPreset = ref(false);
67
69
  const isRunningScript = ref(false);
68
70
  const isStoppingScript = ref(false);
69
71
  const showGeneratedCode = ref(false);
72
+ const helpCenterVisible = shallowRef(false);
70
73
  const generatedCodeOverride = shallowRef<string | null>(null);
71
74
  const generatedCodeDraft = shallowRef('');
72
75
  const generatedCodeEditing = shallowRef(false);
@@ -1451,6 +1454,18 @@ onUnmounted(() => {
1451
1454
  <span>参数配置</span>
1452
1455
  </el-menu-item>
1453
1456
  </el-menu>
1457
+ <div class="layout-sidebar__footer">
1458
+ <el-tooltip content="帮助与版本" placement="right">
1459
+ <button
1460
+ type="button"
1461
+ class="layout-sidebar__help"
1462
+ aria-label="打开帮助与版本信息"
1463
+ @click="helpCenterVisible = true"
1464
+ >
1465
+ <el-icon><QuestionFilled /></el-icon>
1466
+ </button>
1467
+ </el-tooltip>
1468
+ </div>
1454
1469
  </aside>
1455
1470
 
1456
1471
  <div class="layout-main">
@@ -1607,7 +1622,10 @@ onUnmounted(() => {
1607
1622
  :refresh-device-preview="refreshDevicePreview"
1608
1623
  :swipe-device="swipeDevice"
1609
1624
  />
1625
+
1610
1626
  </main>
1611
1627
  </div>
1628
+
1629
+ <HelpCenterDialog v-model="helpCenterVisible" />
1612
1630
  </div>
1613
1631
  </template>
@@ -1,6 +1,7 @@
1
1
  import type { AiDeduplicationConfig } from './ai-deduplication';
2
2
 
3
3
  export type AiRecognitionModel = { baseUrl: string; apiKey: string; name: string };
4
+ export type AiInvalidResultBranch = 'yes' | 'no';
4
5
  export type AiObservationConfig = { durationMs: number; intervalMs: number; mode?: 'batch' | 'untilMatch' };
5
6
  export type AiObservationFrame = { elapsedMs: number; imageDataUrl: string };
6
7
  export type AiRecognitionResult = {
@@ -21,6 +22,12 @@ export function validateAiObservation(value: unknown): AiObservationConfig | und
21
22
  return { durationMs, intervalMs, ...(mode ? { mode } : {}) };
22
23
  }
23
24
 
25
+ export function validateAiInvalidResultBranch(value: unknown): AiInvalidResultBranch | undefined {
26
+ if (value === undefined || value === null || value === '') return undefined;
27
+ if (value !== 'yes' && value !== 'no') throw new Error('AI 无有效结果兜底分支无效');
28
+ return value;
29
+ }
30
+
24
31
  // 未保存此字段的是旧版判断节点,保留已有分支;新节点显式保存 false。
25
32
  export function isAiBranchEnabled(step: { aiBranchEnabled?: boolean }) {
26
33
  return step.aiBranchEnabled !== false;
@@ -3,6 +3,7 @@ import { computed, inject, onBeforeUnmount, ref, shallowRef, watch } from 'vue';
3
3
  import { Loading, QuestionFilled } from '@element-plus/icons-vue';
4
4
  import { isAiBranchEnabled, validateAiRecognitionPrompt, DEFAULT_AI_OBSERVATION, validateAiObservation, type AiObservationConfig } from '../ai-recognition';
5
5
  import { optimizeAppiumPrompt, validateAiBranchQuestion } from '../api';
6
+ import { flowBranchLabel } from '../flow-labels';
6
7
  import type { AppiumRecordedStep } from '../types';
7
8
  import { aiPromptPresetsKey, DEFAULT_AI_PROMPT_PRESETS } from '../ai-prompt-presets';
8
9
  import AiRecognitionPromptInput from './AiRecognitionPromptInput.vue';
@@ -17,7 +18,9 @@ const error = ref('');
17
18
  const branch = computed(() => isAiBranchEnabled(props.step));
18
19
  const untilMatch = computed(() => props.step.aiObservation?.mode === 'untilMatch');
19
20
  const branchChoice = ref(branch.value);
21
+ const fallbackEnabled = shallowRef(Boolean(props.step.aiInvalidResultBranch));
20
22
  watch(branch, value => { branchChoice.value = value; });
23
+ watch(() => props.step.aiInvalidResultBranch, value => { fallbackEnabled.value = Boolean(value); });
21
24
  let controller: AbortController | undefined;
22
25
  let optimizeController: AbortController | undefined;
23
26
  watch(() => props.step.value, value => { prompt.value = value || ''; });
@@ -37,6 +40,12 @@ async function toggleBranch(enabled: boolean) {
37
40
  branchChoice.value = branch.value;
38
41
  }
39
42
 
43
+ function toggleFallback(enabled: boolean) {
44
+ fallbackEnabled.value = enabled;
45
+ error.value = '';
46
+ if (!enabled) emit('update', { aiInvalidResultBranch: undefined });
47
+ }
48
+
40
49
  async function optimizePrompt() {
41
50
  if (busy.value || optimizingPrompt.value || props.disabled) return;
42
51
  error.value = '';
@@ -148,6 +157,24 @@ async function save(enabled: boolean, observation?: AiObservationConfig) {
148
157
  正在验证…
149
158
  </span>
150
159
  </el-form-item>
160
+ <el-form-item v-if="branch">
161
+ <el-checkbox :model-value="fallbackEnabled" :disabled="disabled || busy" @update:model-value="toggleFallback($event === true)">启用兜底</el-checkbox>
162
+ <el-tooltip placement="top" :show-after="200">
163
+ <template #content><div class="recognition-help-content">仅在模型返回空内容、无效格式、无法判断或没有返回 true/false 时生效。正常返回布尔结果时仍按识别结果进入分支;请求失败和超时不使用此兜底。</div></template>
164
+ <el-button class="recognition-help" text :icon="QuestionFilled" aria-label="AI 识别兜底说明" @click.prevent />
165
+ </el-tooltip>
166
+ </el-form-item>
167
+ <el-form-item v-if="branch && fallbackEnabled" label="兜底分支">
168
+ <el-select
169
+ :model-value="step.aiInvalidResultBranch || ''"
170
+ :disabled="disabled || busy"
171
+ placeholder="请选择左右分支"
172
+ @update:model-value="emit('update', { aiInvalidResultBranch: $event })"
173
+ >
174
+ <el-option :label="`进入左侧分支(${flowBranchLabel(step, 'yes')})`" value="yes" />
175
+ <el-option :label="`进入右侧分支(${flowBranchLabel(step, 'no')})`" value="no" />
176
+ </el-select>
177
+ </el-form-item>
151
178
  <el-alert v-if="error" :title="error" type="warning" :closable="false" show-icon />
152
179
  </template>
153
180
 
@@ -85,7 +85,7 @@ export function flowStepMeta(step: AppiumRecordedStep) {
85
85
  if (step.type === 'breakLoop') return step.breakLoopTargetId ? '退出指定循环,继续循环结束后的流程' : '退出当前循环,继续循环结束后的流程';
86
86
  if (step.type === 'continueLoop') return step.continueLoopTargetId ? '结束本轮,进入指定循环的下一轮' : '结束本轮,进入当前循环的下一轮';
87
87
  if (step.type === 'log') return `${step.logPrefix ?? DEFAULT_LOG_PREFIX}:${step.value || ''}`;
88
- if (step.type === 'aiRecognition') return `AI ${step.aiObservation?.mode === 'untilMatch' ? '命中即结束' : step.aiObservation ? '持续观察' : '识别'} · ${step.value || '未填写识别内容'}`;
88
+ if (step.type === 'aiRecognition') return `AI ${step.aiObservation?.mode === 'untilMatch' ? '命中即结束' : step.aiObservation ? '持续观察' : '识别'} · ${step.value || '未填写识别内容'}${step.aiInvalidResultBranch ? ` · 无有效结果→${step.aiInvalidResultBranch === 'yes' ? 'true' : 'false'}` : ''}`;
89
89
  if (step.type === 'longPress') {
90
90
  const target = longPressMode(step) === 'element'
91
91
  ? `元素 ${step.selector?.strategy || ''} ${step.selector?.value || ''}`
@@ -1,4 +1,4 @@
1
- import type { AiObservationConfig } from './ai-recognition';
1
+ import type { AiInvalidResultBranch, AiObservationConfig } from './ai-recognition';
2
2
  import type { TestVariable, VariableExtraction, ScriptReturn } from './variables';
3
3
  import type { ImageCheckConfig } from './image-check';
4
4
 
@@ -125,6 +125,7 @@ export type AppiumRecordedStep = {
125
125
  timeoutMs?: number;
126
126
  aiTimeoutEnabled?: boolean;
127
127
  aiBranchEnabled?: boolean;
128
+ aiInvalidResultBranch?: AiInvalidResultBranch;
128
129
  aiObservation?: AiObservationConfig;
129
130
  timeoutBranch?: 'stop' | 'yes' | 'no';
130
131
  longPressMode?: 'element' | 'coordinates';
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref } from 'vue';
3
- import { Delete, Plus } from '@element-plus/icons-vue';
2
+ import { computed, ref, shallowRef } from 'vue';
3
+ import { Check, Close, Delete, Edit, Plus } from '@element-plus/icons-vue';
4
4
  import { ElMessage } from 'element-plus';
5
5
  import { resolveAiPromptPresets, type AiPromptPreset } from '../../appium-recorder/ai-prompt-presets';
6
6
  import { validateAiRecognitionPrompt } from '../../appium-recorder/ai-recognition';
@@ -10,6 +10,9 @@ const emit = defineEmits<{ 'update:modelValue': [presets: AiPromptPreset[]] }>()
10
10
  const presets = computed(() => resolveAiPromptPresets(props.modelValue));
11
11
  const draft = ref('');
12
12
  const sceneName = ref('');
13
+ const editIndex = shallowRef<number | null>(null);
14
+ const editSceneName = ref('');
15
+ const editPrompt = ref('');
13
16
 
14
17
  function addPreset() {
15
18
  try {
@@ -20,6 +23,34 @@ function addPreset() {
20
23
  draft.value = '';
21
24
  } catch (error) { ElMessage.warning(error instanceof Error ? error.message : '添加提示词失败'); }
22
25
  }
26
+
27
+ function startEdit(index: number) {
28
+ const preset = presets.value[index];
29
+ if (!preset) return;
30
+ editIndex.value = index;
31
+ editSceneName.value = preset.name;
32
+ editPrompt.value = preset.prompt;
33
+ }
34
+
35
+ function cancelEdit() {
36
+ editIndex.value = null;
37
+ editSceneName.value = '';
38
+ editPrompt.value = '';
39
+ }
40
+
41
+ function saveEdit() {
42
+ if (editIndex.value === null) return;
43
+ try {
44
+ const next = presets.value.map((preset, index) => index === editIndex.value
45
+ ? { name: editSceneName.value, prompt: validateAiRecognitionPrompt(editPrompt.value) }
46
+ : preset);
47
+ emit('update:modelValue', resolveAiPromptPresets(next));
48
+ cancelEdit();
49
+ ElMessage.success('预设提示词已修改');
50
+ } catch (error) {
51
+ ElMessage.warning(error instanceof Error ? error.message : '修改提示词失败');
52
+ }
53
+ }
23
54
  </script>
24
55
 
25
56
  <template>
@@ -37,9 +68,24 @@ function addPreset() {
37
68
  <div class="ai-prompt-actions"><el-button native-type="submit" :icon="Plus">添加提示词</el-button></div>
38
69
  </el-form>
39
70
  <div class="ai-prompt-list" aria-label="已添加的提示词">
40
- <div v-for="(preset, index) in presets" :key="preset.name" class="ai-prompt-row">
41
- <div class="ai-prompt-content"><strong>{{ preset.name }}</strong><p>{{ preset.prompt }}</p></div>
42
- <el-button text :icon="Delete" :aria-label="`删除提示词 ${preset.name}`" @click="emit('update:modelValue', presets.filter((_, i) => i !== index))" />
71
+ <div v-for="(preset, index) in presets" :key="`${preset.name}-${index}`" class="ai-prompt-row" :class="{ 'ai-prompt-row--editing': editIndex === index }">
72
+ <template v-if="editIndex === index">
73
+ <div class="ai-prompt-editor">
74
+ <el-input v-model="editSceneName" maxlength="80" placeholder="测试场景名称" />
75
+ <el-input v-model="editPrompt" type="textarea" :rows="3" maxlength="4000" show-word-limit placeholder="提示词内容" />
76
+ <div class="ai-prompt-editor__actions">
77
+ <el-button size="small" :icon="Close" @click="cancelEdit">取消</el-button>
78
+ <el-button size="small" type="primary" :icon="Check" @click="saveEdit">保存</el-button>
79
+ </div>
80
+ </div>
81
+ </template>
82
+ <template v-else>
83
+ <div class="ai-prompt-content"><strong>{{ preset.name }}</strong><p>{{ preset.prompt }}</p></div>
84
+ <div class="ai-prompt-row__actions">
85
+ <el-button text :icon="Edit" :disabled="editIndex !== null" :aria-label="`编辑提示词 ${preset.name}`" @click="startEdit(index)" />
86
+ <el-button text :icon="Delete" :disabled="editIndex !== null" :aria-label="`删除提示词 ${preset.name}`" @click="emit('update:modelValue', presets.filter((_, i) => i !== index))" />
87
+ </div>
88
+ </template>
43
89
  </div>
44
90
  <el-empty v-if="!presets.length" description="暂无预设提示词" :image-size="60" />
45
91
  </div>
@@ -54,10 +100,14 @@ function addPreset() {
54
100
  .ai-prompt-actions { display: flex; justify-content: flex-end; }
55
101
  .ai-prompt-list { --ai-prompt-row-height: 76px; min-width: 0; height: calc(var(--ai-prompt-row-height) * 3 + 1px); overflow-y: auto; scrollbar-gutter: stable; border-top: 1px solid var(--ui-border); }
56
102
  .ai-prompt-row { display: flex; align-items: flex-start; gap: 12px; height: var(--ai-prompt-row-height); box-sizing: border-box; padding: 10px 0; }
103
+ .ai-prompt-row--editing { height: auto; min-height: var(--ai-prompt-row-height); }
57
104
  .ai-prompt-row + .ai-prompt-row { border-top: 1px solid var(--ui-border); }
58
105
  .ai-prompt-content { flex: 1; min-width: 0; font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.6; }
59
106
  .ai-prompt-content strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
60
107
  .ai-prompt-content p { display: -webkit-box; margin: 4px 0 0; overflow: hidden; color: var(--el-text-color-secondary); -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
108
+ .ai-prompt-row__actions { display: flex; flex-shrink: 0; }
109
+ .ai-prompt-editor { display: grid; flex: 1; gap: 10px; min-width: 0; padding-right: 4px; }
110
+ .ai-prompt-editor__actions { display: flex; justify-content: flex-end; gap: 8px; }
61
111
  .ai-prompt-row .el-button { flex-shrink: 0; }
62
112
  .ai-prompt-list :deep(.el-empty) { height: 100%; padding: 0; }
63
113
  </style>
@@ -0,0 +1,160 @@
1
+ <script setup lang="ts">
2
+ import { computed, shallowRef } from 'vue';
3
+ import { InfoFilled, QuestionFilled, Tools } from '@element-plus/icons-vue';
4
+ import changelog from '../../../CHANGELOG.md?raw';
5
+ import faq from '../../../FAQ.md?raw';
6
+ import aiRecognitionGuide from '../../../AI识别操作说明.md?raw';
7
+ import appiumGuide from '../../../流程节点操作说明.md?raw';
8
+ import operationGuide from '../../../USAGE.md?raw';
9
+ import packageText from '../../../package.json?raw';
10
+ import MarkdownDocument from './MarkdownDocument.vue';
11
+
12
+ type PackageInfo = { version?: string; dependencies?: Record<string, string> };
13
+ type OperationSection = { id: string; title: string; content: string };
14
+
15
+ const visible = defineModel<boolean>({ required: true });
16
+ const activeTab = shallowRef('about');
17
+ type OperationType = 'midscene' | 'appium' | 'aiRecognition' | 'faq';
18
+
19
+ const operationType = shallowRef<OperationType>('midscene');
20
+ const activeOperation = shallowRef('');
21
+ const packageInfo = JSON.parse(packageText) as PackageInfo;
22
+ const dependencyVersion = (name: string) => packageInfo.dependencies?.[name]?.match(/(\d+\.\d+\.\d+)/)?.[1] ?? '未知';
23
+ const appVersion = packageInfo.version ?? '未知';
24
+ const midsceneVersion = dependencyVersion('@midscene/core');
25
+
26
+ const technologies = computed(() => [
27
+ { name: 'Vue', version: dependencyVersion('vue'), description: '界面与交互框架' },
28
+ { name: 'Element Plus', version: dependencyVersion('element-plus'), description: '界面组件库' },
29
+ { name: 'Appium', version: '2.x', description: 'Android 原生自动化与回放' },
30
+ { name: 'Midscene', version: midsceneVersion, description: 'AI 驱动的界面理解与自动化' },
31
+ { name: 'Vue Flow', version: dependencyVersion('@vue-flow/core'), description: 'Appium 流程画布' },
32
+ { name: 'OpenCV.js', version: dependencyVersion('@techstark/opencv-js'), description: '本地图像检测' },
33
+ { name: 'Vite', version: dependencyVersion('vite'), description: '开发与构建工具' },
34
+ { name: 'TypeScript', version: dependencyVersion('typescript'), description: '类型与工程开发语言' },
35
+ ]);
36
+
37
+ const extractOperationSections = (startHeading: string, endHeading: string) => {
38
+ const start = operationGuide.indexOf(`## ${startHeading}`);
39
+ const end = operationGuide.indexOf(`## ${endHeading}`, start + 1);
40
+ if (start < 0) return [];
41
+ const source = operationGuide.slice(start, end < 0 ? undefined : end);
42
+ const matches = [...source.matchAll(/^###\s+(.+)$/gm)];
43
+ return matches.map<OperationSection>((match, index) => ({
44
+ id: `${startHeading}-${index}`,
45
+ title: match[1]!.trim(),
46
+ content: source.slice(match.index! + match[0].length, matches[index + 1]?.index).trim(),
47
+ }));
48
+ };
49
+
50
+ const extractDocumentSections = (source: string, prefix: string) => {
51
+ const matches = [...source.matchAll(/^##\s+(.+)$/gm)];
52
+ return matches.map<OperationSection>((match, index) => ({
53
+ id: `${prefix}-${index}`,
54
+ title: match[1]!.trim(),
55
+ content: source.slice(match.index! + match[0].length, matches[index + 1]?.index).trim(),
56
+ }));
57
+ };
58
+
59
+ const midsceneOperations = computed(() => extractOperationSections('Midscene 操作说明', 'Appium 使用前准备'));
60
+ const appiumOperations = computed(() => extractDocumentSections(appiumGuide, 'appium'));
61
+ const aiRecognitionOperations = computed(() => extractDocumentSections(aiRecognitionGuide, 'ai-recognition'));
62
+ const faqOperations = computed(() => extractDocumentSections(faq, 'faq'));
63
+ const visibleOperations = computed(() => {
64
+ if (operationType.value === 'midscene') return midsceneOperations.value;
65
+ if (operationType.value === 'appium') return appiumOperations.value;
66
+ if (operationType.value === 'aiRecognition') return aiRecognitionOperations.value;
67
+ return faqOperations.value;
68
+ });
69
+
70
+ const switchOperationType = (value: OperationType) => {
71
+ operationType.value = value;
72
+ activeOperation.value = '';
73
+ };
74
+ </script>
75
+
76
+ <template>
77
+ <el-dialog
78
+ v-model="visible"
79
+ title="帮助与版本"
80
+ width="min(960px, calc(100vw - 48px))"
81
+ class="help-center-dialog"
82
+ top="5vh"
83
+ draggable
84
+ destroy-on-close
85
+ >
86
+ <el-tabs v-model="activeTab" class="help-center">
87
+ <el-tab-pane name="about">
88
+ <template #label><el-icon><InfoFilled /></el-icon><span>关于</span></template>
89
+ <el-scrollbar height="min(620px, calc(100vh - 230px))">
90
+ <div class="help-center__content">
91
+ <div class="help-center__versions">
92
+ <div><span>当前版本</span><strong>v{{ appVersion }}</strong></div>
93
+ <div><span>Midscene 版本</span><strong>v{{ midsceneVersion }}</strong></div>
94
+ </div>
95
+ <h3 class="help-center__section-title">使用技术</h3>
96
+ <div class="help-center__technology-grid">
97
+ <div v-for="item in technologies" :key="item.name" class="help-center__technology">
98
+ <div><strong>{{ item.name }}</strong><el-tag size="small" effect="plain">{{ item.version }}</el-tag></div>
99
+ <p>{{ item.description }}</p>
100
+ </div>
101
+ </div>
102
+ </div>
103
+ </el-scrollbar>
104
+ </el-tab-pane>
105
+ <el-tab-pane name="guide">
106
+ <template #label><el-icon><QuestionFilled /></el-icon><span>操作说明</span></template>
107
+ <el-scrollbar height="min(620px, calc(100vh - 230px))">
108
+ <div class="help-center__operations">
109
+ <el-radio-group :model-value="operationType" class="help-center__operation-types" @change="switchOperationType">
110
+ <el-radio-button value="midscene">Midscene</el-radio-button>
111
+ <el-radio-button value="appium">Appium</el-radio-button>
112
+ <el-radio-button value="aiRecognition">AI 识别</el-radio-button>
113
+ <el-radio-button value="faq">常见问题</el-radio-button>
114
+ </el-radio-group>
115
+ <el-collapse v-model="activeOperation" accordion class="help-center__operation-menu">
116
+ <el-collapse-item v-for="item in visibleOperations" :key="item.id" :name="item.id">
117
+ <template #title><span class="help-center__operation-title">{{ item.title }}</span></template>
118
+ <MarkdownDocument :content="item.content" />
119
+ </el-collapse-item>
120
+ </el-collapse>
121
+ </div>
122
+ </el-scrollbar>
123
+ </el-tab-pane>
124
+ <el-tab-pane name="changelog">
125
+ <template #label><el-icon><Tools /></el-icon><span>更新记录</span></template>
126
+ <el-scrollbar height="min(620px, calc(100vh - 230px))">
127
+ <div class="help-center__document"><MarkdownDocument :content="changelog" /></div>
128
+ </el-scrollbar>
129
+ </el-tab-pane>
130
+ </el-tabs>
131
+ </el-dialog>
132
+ </template>
133
+
134
+ <style scoped>
135
+ .help-center :deep(.el-tabs__header) { margin-bottom: 0; }
136
+ .help-center :deep(.el-tabs__item) { display: inline-flex; align-items: center; gap: 6px; }
137
+ .help-center__content,
138
+ .help-center__document { padding: 22px 6px 18px 2px; }
139
+ .help-center__versions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
140
+ .help-center__versions div { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px; border: 1px solid #e2e8f0; border-radius: 10px; background: #fff; }
141
+ .help-center__versions span { color: #64748b; }
142
+ .help-center__versions strong { color: #1677ff; font-size: 18px; }
143
+ .help-center__section-title { margin: 24px 0 12px; color: #1f2937; font-size: 17px; }
144
+ .help-center__technology-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
145
+ .help-center__technology { padding: 14px 16px; border: 1px solid #e2e8f0; border-radius: 9px; background: #fff; }
146
+ .help-center__technology > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
147
+ .help-center__technology p { margin: 6px 0 0; color: #64748b; font-size: 13px; }
148
+ .help-center__operations { padding: 18px 6px 18px 2px; }
149
+ .help-center__operation-types { margin-bottom: 16px; }
150
+ .help-center__operation-menu { border-top: 1px solid #e5e7eb; }
151
+ .help-center__operation-title { color: #334155; font-size: 14px; font-weight: 600; }
152
+ .help-center__operation-menu :deep(.el-collapse-item__header) { min-height: 48px; padding: 0 14px; border-right: 1px solid #e5e7eb; border-left: 1px solid #e5e7eb; }
153
+ .help-center__operation-menu :deep(.el-collapse-item__wrap) { border-right: 1px solid #e5e7eb; border-left: 1px solid #e5e7eb; }
154
+ .help-center__operation-menu :deep(.el-collapse-item__content) { padding: 4px 18px 18px; }
155
+ :global(.help-center-dialog.el-dialog) { margin-top: 5vh; }
156
+ @media (max-width: 760px) {
157
+ .help-center__versions,
158
+ .help-center__technology-grid { grid-template-columns: 1fr; }
159
+ }
160
+ </style>
@@ -0,0 +1,139 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+
4
+ type DocumentBlock =
5
+ | { type: 'heading'; level: number; text: string }
6
+ | { type: 'paragraph'; text: string }
7
+ | { type: 'list'; ordered: boolean; items: string[] }
8
+ | { type: 'code'; text: string }
9
+ | { type: 'table'; headers: string[]; rows: string[][] };
10
+
11
+ const props = defineProps<{ content: string }>();
12
+
13
+ const cleanText = (value: string) => value
14
+ .replace(/\[([^\]]+)]\([^)]+\)/g, '$1')
15
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
16
+ .replace(/`([^`]+)`/g, '$1')
17
+ .trim();
18
+
19
+ const splitTableRow = (line: string) => line
20
+ .replace(/^\||\|$/g, '')
21
+ .split('|')
22
+ .map(cell => cleanText(cell));
23
+
24
+ const blocks = computed<DocumentBlock[]>(() => {
25
+ const lines = props.content.replace(/\r\n/g, '\n').split('\n');
26
+ const result: DocumentBlock[] = [];
27
+ let index = 0;
28
+
29
+ while (index < lines.length) {
30
+ const line = lines[index] ?? '';
31
+ const trimmed = line.trim();
32
+ if (!trimmed) {
33
+ index += 1;
34
+ continue;
35
+ }
36
+
37
+ if (/^([-*_])\1{2,}$/.test(trimmed)) {
38
+ index += 1;
39
+ continue;
40
+ }
41
+
42
+ if (trimmed.startsWith('```')) {
43
+ const code: string[] = [];
44
+ index += 1;
45
+ while (index < lines.length && !lines[index]!.trim().startsWith('```')) {
46
+ code.push(lines[index]!);
47
+ index += 1;
48
+ }
49
+ result.push({ type: 'code', text: code.join('\n') });
50
+ index += 1;
51
+ continue;
52
+ }
53
+
54
+ const heading = /^(#{1,6})\s+(.+)$/.exec(trimmed);
55
+ if (heading) {
56
+ result.push({ type: 'heading', level: heading[1]!.length, text: cleanText(heading[2]!) });
57
+ index += 1;
58
+ continue;
59
+ }
60
+
61
+ if (trimmed.includes('|') && /^\s*\|?\s*:?-{3,}/.test(lines[index + 1] ?? '')) {
62
+ const headers = splitTableRow(trimmed);
63
+ const rows: string[][] = [];
64
+ index += 2;
65
+ while (index < lines.length && lines[index]!.includes('|') && lines[index]!.trim()) {
66
+ rows.push(splitTableRow(lines[index]!));
67
+ index += 1;
68
+ }
69
+ result.push({ type: 'table', headers, rows });
70
+ continue;
71
+ }
72
+
73
+ const listMatch = /^(?:[-*]\s+|(\d+)\.\s+)(.+)$/.exec(trimmed);
74
+ if (listMatch) {
75
+ const ordered = Boolean(listMatch[1]);
76
+ const items: string[] = [];
77
+ while (index < lines.length) {
78
+ const item = /^(?:[-*]\s+|(\d+)\.\s+)(.+)$/.exec(lines[index]!.trim());
79
+ if (!item || Boolean(item[1]) !== ordered) break;
80
+ items.push(cleanText(item[2]!));
81
+ index += 1;
82
+ }
83
+ result.push({ type: 'list', ordered, items });
84
+ continue;
85
+ }
86
+
87
+ const paragraph: string[] = [];
88
+ while (index < lines.length) {
89
+ const next = lines[index]!.trim();
90
+ if (!next || /^(#{1,6})\s+/.test(next) || next.startsWith('```') || /^(?:[-*]\s+|\d+\.\s+)/.test(next)) break;
91
+ if (next.includes('|') && /^\s*\|?\s*:?-{3,}/.test(lines[index + 1] ?? '')) break;
92
+ paragraph.push(cleanText(next));
93
+ index += 1;
94
+ }
95
+ result.push({ type: 'paragraph', text: paragraph.join(' ') });
96
+ }
97
+
98
+ return result;
99
+ });
100
+ </script>
101
+
102
+ <template>
103
+ <article class="markdown-document">
104
+ <template v-for="(block, index) in blocks" :key="`${block.type}-${index}`">
105
+ <h1 v-if="block.type === 'heading' && block.level === 1" class="markdown-document__h1">{{ block.text }}</h1>
106
+ <h2 v-else-if="block.type === 'heading' && block.level === 2" class="markdown-document__h2">{{ block.text }}</h2>
107
+ <h3 v-else-if="block.type === 'heading'" class="markdown-document__h3">{{ block.text }}</h3>
108
+ <p v-else-if="block.type === 'paragraph'" class="markdown-document__paragraph">{{ block.text }}</p>
109
+ <ol v-else-if="block.type === 'list' && block.ordered" class="markdown-document__list">
110
+ <li v-for="item in block.items" :key="item">{{ item }}</li>
111
+ </ol>
112
+ <ul v-else-if="block.type === 'list'" class="markdown-document__list">
113
+ <li v-for="item in block.items" :key="item">{{ item }}</li>
114
+ </ul>
115
+ <pre v-else-if="block.type === 'code'" class="markdown-document__code"><code>{{ block.text }}</code></pre>
116
+ <div v-else-if="block.type === 'table'" class="markdown-document__table-wrap">
117
+ <table class="markdown-document__table">
118
+ <thead><tr><th v-for="header in block.headers" :key="header">{{ header }}</th></tr></thead>
119
+ <tbody><tr v-for="(row, rowIndex) in block.rows" :key="rowIndex"><td v-for="(cell, cellIndex) in row" :key="cellIndex">{{ cell }}</td></tr></tbody>
120
+ </table>
121
+ </div>
122
+ </template>
123
+ </article>
124
+ </template>
125
+
126
+ <style scoped>
127
+ .markdown-document { color: #475569; font-size: 14px; line-height: 1.75; }
128
+ .markdown-document__h1 { margin: 0 0 18px; color: #1f2937; font-size: 24px; }
129
+ .markdown-document__h2 { margin: 24px 0 10px; color: #1f2937; font-size: 19px; }
130
+ .markdown-document__h3 { margin: 18px 0 8px; color: #334155; font-size: 16px; }
131
+ .markdown-document__paragraph { margin: 8px 0; }
132
+ .markdown-document__list { display: grid; gap: 5px; margin: 8px 0; padding-left: 24px; }
133
+ .markdown-document__code { margin: 12px 0; padding: 14px; overflow: auto; border-radius: 8px; background: #f3f6fa; color: #334155; white-space: pre-wrap; }
134
+ .markdown-document__table-wrap { margin: 12px 0; overflow-x: auto; }
135
+ .markdown-document__table { width: 100%; border-collapse: collapse; }
136
+ .markdown-document__table th,
137
+ .markdown-document__table td { padding: 9px 12px; border: 1px solid #e2e8f0; text-align: left; vertical-align: top; }
138
+ .markdown-document__table th { background: #f8fafc; color: #334155; }
139
+ </style>