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.
- package/{AI_RECOGNITION_GUIDE.md → AI/350/257/206/345/210/253/346/223/215/344/275/234/350/257/264/346/230/216.md} +78 -6
- package/CHANGELOG.md +5 -0
- package/FAQ.md +8 -8
- package/README.md +135 -17
- package/USAGE.md +120 -8
- package/package.json +3 -2
- package/server/appium-recorder/ai-recognition.test.ts +27 -0
- package/server/appium-recorder/ai-recognition.ts +16 -4
- package/server/appium-recorder/appium-runner.ts +11 -1
- package/server/appium-recorder/repository.ts +6 -2
- package/src/App.vue +18 -0
- package/src/appium-recorder/ai-recognition.ts +7 -0
- package/src/appium-recorder/components/AiRecognitionSettings.vue +27 -0
- package/src/appium-recorder/flow-labels.ts +1 -1
- package/src/appium-recorder/types.ts +2 -1
- package/src/components/config/AiPromptPresetsSettings.vue +55 -5
- package/src/components/help/HelpCenterDialog.vue +160 -0
- package/src/components/help/MarkdownDocument.vue +139 -0
- package/src/style.css +37 -0
- package//346/265/201/347/250/213/350/212/202/347/202/271/346/223/215/344/275/234/350/257/264/346/230/216.md +408 -0
|
@@ -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
|
|
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
|
|
39
|
+
throw new AiInvalidResultError(`AI 无法判断:${'reason' in value && typeof value.reason === 'string' ? value.reason.slice(0, 2000) : '未返回确定的判断结果'}`);
|
|
28
40
|
}
|
|
29
|
-
throw new
|
|
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
|
|
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')
|
|
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
|
-
<
|
|
42
|
-
|
|
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>
|