android-midscene-automation 0.1.43 → 0.2.2

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.
Files changed (56) hide show
  1. 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
  2. package/CHANGELOG.md +13 -0
  3. package/FAQ.md +8 -8
  4. package/README.md +143 -17
  5. package/USAGE.md +120 -8
  6. package/package.json +3 -2
  7. package/server/analytics.test.ts +46 -0
  8. package/server/analytics.ts +24 -0
  9. package/server/api-safety-analytics.test.ts +57 -0
  10. package/server/appium-auto-save.test.ts +6 -0
  11. package/server/appium-recorder/ai-recognition.test.ts +27 -0
  12. package/server/appium-recorder/ai-recognition.ts +16 -4
  13. package/server/appium-recorder/appium-runner.ts +11 -1
  14. package/server/appium-recorder/appium-version.test.ts +55 -0
  15. package/server/appium-recorder/managed-appium.ts +37 -1
  16. package/server/appium-recorder/repository.ts +6 -2
  17. package/server/appium-recorder/routes.ts +2 -12
  18. package/server/appium-recorder/run-history.ts +11 -0
  19. package/server/config-access.test.ts +27 -0
  20. package/server/config-access.ts +27 -0
  21. package/server/http-api.ts +63 -54
  22. package/server/operations/repository.ts +8 -0
  23. package/server/remote-agents/routes.ts +2 -12
  24. package/server/request-body.test.ts +40 -0
  25. package/server/request-body.ts +48 -0
  26. package/src/App.vue +98 -742
  27. package/src/analytics/summary.ts +75 -0
  28. package/src/api.ts +11 -0
  29. package/src/appium-recorder/AppiumPage.vue +4 -1
  30. package/src/appium-recorder/ai-recognition.ts +7 -0
  31. package/src/appium-recorder/components/AiRecognitionSettings.vue +27 -0
  32. package/src/appium-recorder/flow-labels.ts +1 -1
  33. package/src/appium-recorder/types.ts +2 -1
  34. package/src/components/AppSidebar.vue +31 -0
  35. package/src/components/analytics/AnalysisCharts.vue +189 -0
  36. package/src/components/analytics/ReportAnalysis.vue +78 -0
  37. package/src/components/config/AiPromptPresetsSettings.vue +55 -5
  38. package/src/components/config/AppiumConfigPanel.vue +4 -1
  39. package/src/components/config/AppiumModelSettings.vue +1 -1
  40. package/src/components/config/BaseConfigPanel.vue +6 -2
  41. package/src/components/config/MidsceneConfigPanel.vue +4 -5
  42. package/src/components/help/HelpCenter.vue +185 -0
  43. package/src/components/help/MarkdownDocument.vue +139 -0
  44. package/src/composables/use-device-preview.ts +279 -0
  45. package/src/composables/use-initial-resource.ts +17 -0
  46. package/src/composables/use-script-execution.ts +277 -0
  47. package/src/config/credentials.ts +2 -0
  48. package/src/config/use-appium-auto-save.ts +22 -11
  49. package/src/config/use-model-config.ts +157 -0
  50. package/src/main.ts +2 -2
  51. package/src/pages/AnalysisPage.vue +60 -0
  52. package/src/pages/ConfigPage.vue +36 -5
  53. package/src/style.css +716 -91
  54. package/src/types.ts +7 -1
  55. 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
  56. package/src/ui-refresh.css +0 -677
@@ -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>
@@ -12,8 +12,10 @@ defineProps<{
12
12
  testStatus?: string;
13
13
  promptOptimizerTestStatus?: string;
14
14
  saveStatus?: string;
15
+ saveFailed?: boolean;
15
16
  }>();
16
- defineEmits<{ testModel: []; testPromptOptimizerModel: [] }>();
17
+ defineEmits<{
18
+ retrySave: []; testModel: []; testPromptOptimizerModel: [] }>();
17
19
  </script>
18
20
 
19
21
  <template>
@@ -22,6 +24,7 @@ defineEmits<{ testModel: []; testPromptOptimizerModel: [] }>();
22
24
  <header class="appium-section-heading">
23
25
  <div><h2 id="appium-models-title">模型服务</h2><p>不同任务使用独立模型,配置修改需测试通过后保存。</p></div>
24
26
  <span v-if="saveStatus" class="config-save-status" role="status">{{ saveStatus }}</span>
27
+ <el-button v-if="saveFailed" link type="primary" @click="$emit('retrySave')">重试保存</el-button>
25
28
  </header>
26
29
  <div class="appium-model-grid">
27
30
  <AppiumModelSettings v-model="configForm.appium.model" title="AI 识别模型" badge="视觉"
@@ -27,7 +27,7 @@ defineEmits<{ test: [] }>();
27
27
  <p class="appium-model-description">{{ description }}</p>
28
28
  <el-form label-position="top" :disabled="!!testingModelKey">
29
29
  <el-form-item label="Base URL"><el-input v-model="model.baseUrl" placeholder="兼容 OpenAI 的 API 地址" /></el-form-item>
30
- <el-form-item label="API Key"><el-input v-model="model.apiKey" type="password" show-password /></el-form-item>
30
+ <el-form-item label="API Key"><el-input v-model="model.apiKey" type="password" placeholder="已保存的 Key 显示为掩码,可输入新 Key 替换" show-password /></el-form-item>
31
31
  <el-form-item label="Model Name"><el-input v-model="model.name" :placeholder="namePlaceholder" /></el-form-item>
32
32
  <el-alert v-if="testStatus" class="appium-model-status" :title="testStatus"
33
33
  :type="testStatus.startsWith('测试通过') ? 'success' : 'warning'" :closable="false" show-icon />
@@ -31,7 +31,7 @@ defineEmits<{
31
31
  </div>
32
32
  </template>
33
33
 
34
- <el-form label-position="top">
34
+ <el-form label-position="top" class="runtime-config-form">
35
35
  <el-form-item label="Android SDK 路径">
36
36
  <el-input
37
37
  v-model="configForm.runtime.androidSdkPath"
@@ -69,7 +69,7 @@ defineEmits<{
69
69
  </el-form-item>
70
70
  <div class="app-preset-actions">
71
71
  <el-button v-if="appPresetForm.id" :disabled="isSavingAppPreset" @click="$emit('cancelAppPresetEdit')">取消编辑</el-button>
72
- <el-button native-type="submit" :loading="isSavingAppPreset">{{ appPresetForm.id ? '保存修改' : '添加' }}</el-button>
72
+ <el-button type="primary" native-type="submit" :loading="isSavingAppPreset">{{ appPresetForm.id ? '保存修改' : '添加' }}</el-button>
73
73
  </div>
74
74
  </el-form>
75
75
 
@@ -92,6 +92,10 @@ defineEmits<{
92
92
  </template>
93
93
 
94
94
  <style scoped>
95
+ .runtime-config-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; }
96
+ .runtime-config-form :deep(.el-form-item) { margin-bottom: 0; }
97
+ @media (max-width: 1000px) { .runtime-config-form { grid-template-columns: minmax(0, 1fr); } }
98
+
95
99
  .app-preset-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 24px; align-items: start; }
96
100
  .app-preset-actions { display: flex; justify-content: flex-end; margin-top: 4px; }
97
101
  .app-preset-layout .app-preset-list { margin-top: 0; padding-left: 24px; border-left: 1px solid var(--ui-border); }
@@ -89,7 +89,7 @@ const openModelConfigGuide = () => {
89
89
  <section>
90
90
  <div class="panel-header panel-header--sub">
91
91
  <span>Midscene 模型</span>
92
- <el-button
92
+ <el-button type="primary" plain
93
93
  :loading="testingModelKey === 'midscene'"
94
94
  :disabled="!!testingModelKey"
95
95
  @click="$emit('testModel', 'midscene')"
@@ -118,7 +118,7 @@ const openModelConfigGuide = () => {
118
118
  />
119
119
  </el-form-item>
120
120
  <el-form-item label="API Key">
121
- <el-input v-model="configForm.midscene.model.apiKey" show-password />
121
+ <el-input v-model="configForm.midscene.model.apiKey" placeholder="已保存的 Key 显示为掩码,可输入新 Key 替换" show-password />
122
122
  </el-form-item>
123
123
  </template>
124
124
  <template v-else>
@@ -168,7 +168,7 @@ const openModelConfigGuide = () => {
168
168
  <section>
169
169
  <div class="panel-header panel-header--sub">
170
170
  <span>脚本优化模型</span>
171
- <el-button
171
+ <el-button type="primary" plain
172
172
  :loading="testingModelKey === 'scriptOptimizer'"
173
173
  :disabled="!!testingModelKey"
174
174
  @click="$emit('testModel', 'scriptOptimizer')"
@@ -181,7 +181,7 @@ const openModelConfigGuide = () => {
181
181
  <el-input v-model="configForm.scriptOptimizer.model.baseUrl" />
182
182
  </el-form-item>
183
183
  <el-form-item label="API Key">
184
- <el-input v-model="configForm.scriptOptimizer.model.apiKey" show-password />
184
+ <el-input v-model="configForm.scriptOptimizer.model.apiKey" placeholder="已保存的 Key 显示为掩码,可输入新 Key 替换" show-password />
185
185
  </el-form-item>
186
186
  <el-form-item label="Model Name">
187
187
  <el-input v-model="configForm.scriptOptimizer.model.name" />
@@ -197,6 +197,5 @@ const openModelConfigGuide = () => {
197
197
  </template>
198
198
 
199
199
  <style scoped>
200
- .config-midscene-card :deep(.el-card__body) { background: var(--ui-bg-soft, #f8f9fb); }
201
200
  .config-model-input { width: 100%; }
202
201
  </style>
@@ -0,0 +1,185 @@
1
+ <script setup lang="ts">
2
+ import { computed, shallowRef, watch } from 'vue';
3
+ import { InfoFilled, QuestionFilled, Tools, Refresh, Monitor, Cpu, Cellphone } from '@element-plus/icons-vue';
4
+ import { getAppiumVersion } from '../../api';
5
+ import type { AppiumVersionInfo } from '../../types';
6
+ import changelog from '../../../CHANGELOG.md?raw';
7
+ import faq from '../../../FAQ.md?raw';
8
+ import aiRecognitionGuide from '../../../AI识别操作说明.md?raw';
9
+ import appiumGuide from '../../../流程节点操作说明.md?raw';
10
+ import operationGuide from '../../../USAGE.md?raw';
11
+ import packageText from '../../../package.json?raw';
12
+ import MarkdownDocument from './MarkdownDocument.vue';
13
+
14
+ type PackageInfo = { version?: string; dependencies?: Record<string, string> };
15
+ type OperationSection = { id: string; title: string; content: string };
16
+
17
+ const props = defineProps<{ active: boolean }>();
18
+ const appiumVersion = shallowRef<AppiumVersionInfo | null>(null);
19
+ const loadingVersion = shallowRef(false);
20
+ const versionError = shallowRef('');
21
+ let checkedAt = 0;
22
+ async function refreshVersion(force = true) {
23
+ if (!force && Date.now() - checkedAt < 60_000) return;
24
+ if (loadingVersion.value) return;
25
+ loadingVersion.value = true;
26
+ versionError.value = '';
27
+ try {
28
+ appiumVersion.value = await getAppiumVersion();
29
+ checkedAt = Date.now();
30
+ } catch {
31
+ versionError.value = '无法读取版本信息,请检查后端连接后刷新';
32
+ } finally {
33
+ loadingVersion.value = false;
34
+ }
35
+ }
36
+ watch(() => props.active, active => { if (active) void refreshVersion(false); }, { immediate: true });
37
+
38
+ const activeTab = shallowRef('about');
39
+ type OperationType = 'midscene' | 'appium' | 'aiRecognition' | 'faq';
40
+
41
+ const operationType = shallowRef<OperationType>('midscene');
42
+ const activeOperation = shallowRef('');
43
+ const packageInfo = JSON.parse(packageText) as PackageInfo;
44
+ const dependencyVersion = (name: string) => packageInfo.dependencies?.[name]?.match(/(\d+\.\d+\.\d+)/)?.[1] ?? '未知';
45
+ const appVersion = packageInfo.version ?? '未知';
46
+ const midsceneVersion = dependencyVersion('@midscene/core');
47
+
48
+ const versions = computed(() => [
49
+ { name: '应用版本', version: `v${appVersion}`, description: '移动自动化控制台', icon: Monitor },
50
+ { name: 'Midscene', version: `v${midsceneVersion}`, description: 'AI 驱动的界面理解与自动化', icon: Cpu },
51
+ { name: 'Appium', version: appiumVersion.value?.version ? `v${appiumVersion.value.version}` : loadingVersion.value ? '检测中…' : '未获取',
52
+ description: loadingVersion.value ? '正在读取当前环境版本' : versionError.value || appiumVersion.value?.message || '等待检测', icon: Cellphone },
53
+ ]);
54
+
55
+ const technologies = computed(() => [
56
+ { name: 'Vue', version: dependencyVersion('vue'), description: '界面与交互框架' },
57
+ { name: 'Element Plus', version: dependencyVersion('element-plus'), description: '界面组件库' },
58
+ { name: 'Vue Flow', version: dependencyVersion('@vue-flow/core'), description: 'Appium 流程画布' },
59
+ { name: 'OpenCV.js', version: dependencyVersion('@techstark/opencv-js'), description: '本地图像检测' },
60
+ { name: 'Vite', version: dependencyVersion('vite'), description: '开发与构建工具' },
61
+ { name: 'TypeScript', version: dependencyVersion('typescript'), description: '类型与工程开发语言' },
62
+ ]);
63
+
64
+ const extractOperationSections = (startHeading: string, endHeading: string) => {
65
+ const start = operationGuide.indexOf(`## ${startHeading}`);
66
+ const end = operationGuide.indexOf(`## ${endHeading}`, start + 1);
67
+ if (start < 0) return [];
68
+ const source = operationGuide.slice(start, end < 0 ? undefined : end);
69
+ const matches = [...source.matchAll(/^###\s+(.+)$/gm)];
70
+ return matches.map<OperationSection>((match, index) => ({
71
+ id: `${startHeading}-${index}`,
72
+ title: match[1]!.trim(),
73
+ content: source.slice(match.index! + match[0].length, matches[index + 1]?.index).trim(),
74
+ }));
75
+ };
76
+
77
+ const extractDocumentSections = (source: string, prefix: string) => {
78
+ const matches = [...source.matchAll(/^##\s+(.+)$/gm)];
79
+ return matches.map<OperationSection>((match, index) => ({
80
+ id: `${prefix}-${index}`,
81
+ title: match[1]!.trim(),
82
+ content: source.slice(match.index! + match[0].length, matches[index + 1]?.index).trim(),
83
+ }));
84
+ };
85
+
86
+ const midsceneOperations = computed(() => extractOperationSections('Midscene 操作说明', 'Appium 使用前准备'));
87
+ const appiumOperations = computed(() => extractDocumentSections(appiumGuide, 'appium'));
88
+ const aiRecognitionOperations = computed(() => extractDocumentSections(aiRecognitionGuide, 'ai-recognition'));
89
+ const faqOperations = computed(() => extractDocumentSections(faq, 'faq'));
90
+ const visibleOperations = computed(() => {
91
+ if (operationType.value === 'midscene') return midsceneOperations.value;
92
+ if (operationType.value === 'appium') return appiumOperations.value;
93
+ if (operationType.value === 'aiRecognition') return aiRecognitionOperations.value;
94
+ return faqOperations.value;
95
+ });
96
+
97
+ const switchOperationType = (value: OperationType) => {
98
+ operationType.value = value;
99
+ activeOperation.value = '';
100
+ };
101
+ </script>
102
+
103
+ <template>
104
+ <section class="help-center-page" aria-label="关于">
105
+ <el-tabs v-model="activeTab" class="help-center">
106
+ <el-tab-pane name="about">
107
+ <template #label><el-icon><InfoFilled /></el-icon><span>关于</span></template>
108
+ <div class="help-center__content">
109
+ <div class="help-center__version-heading">
110
+ <h2>版本信息</h2>
111
+ <el-button text :icon="Refresh" :loading="loadingVersion" @click="refreshVersion(true)">刷新版本</el-button>
112
+ </div>
113
+ <div class="help-center__versions" aria-live="polite" :aria-busy="loadingVersion">
114
+ <article v-for="item in versions" :key="item.name" class="help-center__version-card">
115
+ <div class="help-center__version-label"><el-icon><component :is="item.icon" /></el-icon><span>{{ item.name }}</span></div>
116
+ <strong>{{ item.version }}</strong>
117
+ <p>{{ item.description }}</p>
118
+ </article>
119
+ </div>
120
+ <h3 class="help-center__section-title">技术栈</h3>
121
+ <div class="help-center__technology-grid">
122
+ <div v-for="item in technologies" :key="item.name" class="help-center__technology">
123
+ <div><strong>{{ item.name }}</strong><el-tag size="small" effect="plain">{{ item.version }}</el-tag></div>
124
+ <p>{{ item.description }}</p>
125
+ </div>
126
+ </div>
127
+ </div>
128
+ </el-tab-pane>
129
+ <el-tab-pane name="guide">
130
+ <template #label><el-icon><QuestionFilled /></el-icon><span>操作说明</span></template>
131
+ <div class="help-center__operations">
132
+ <el-radio-group :model-value="operationType" class="help-center__operation-types" @change="switchOperationType">
133
+ <el-radio-button value="midscene">Midscene</el-radio-button>
134
+ <el-radio-button value="appium">Appium</el-radio-button>
135
+ <el-radio-button value="aiRecognition">AI 识别</el-radio-button>
136
+ <el-radio-button value="faq">常见问题</el-radio-button>
137
+ </el-radio-group>
138
+ <el-collapse v-model="activeOperation" accordion class="help-center__operation-menu">
139
+ <el-collapse-item v-for="item in visibleOperations" :key="item.id" :name="item.id">
140
+ <template #title><span class="help-center__operation-title">{{ item.title }}</span></template>
141
+ <MarkdownDocument :content="item.content" />
142
+ </el-collapse-item>
143
+ </el-collapse>
144
+ </div>
145
+ </el-tab-pane>
146
+ <el-tab-pane name="changelog">
147
+ <template #label><el-icon><Tools /></el-icon><span>更新记录</span></template>
148
+ <div class="help-center__document"><MarkdownDocument :content="changelog" /></div>
149
+ </el-tab-pane>
150
+ </el-tabs>
151
+ </section>
152
+ </template>
153
+
154
+ <style scoped>
155
+ .help-center-page { padding: 12px 24px 24px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); }
156
+
157
+ .help-center :deep(.el-tabs__header) { margin-bottom: 0; }
158
+ .help-center :deep(.el-tabs__item) { display: inline-flex; align-items: center; gap: 6px; }
159
+ .help-center__content,
160
+ .help-center__document { padding: 22px 6px 18px 2px; }
161
+ .help-center__version-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
162
+ .help-center__version-heading h2 { margin: 0; font-size: 17px; font-weight: 600; }
163
+ .help-center__versions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
164
+ .help-center__version-card { min-width: 0; padding: 20px; border: 1px solid var(--ui-border); border-radius: 10px; background: var(--ui-surface); }
165
+ .help-center__version-label { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; color: var(--ui-text-secondary); font-size: 14px; }
166
+ .help-center__version-label .el-icon { padding: 8px; box-sizing: content-box; border-radius: 8px; background: var(--el-color-primary-light-9); color: var(--ui-primary); font-size: 18px; }
167
+ .help-center__version-card strong { display: block; color: var(--ui-text); font-size: 28px; font-weight: 600; line-height: 1.3; overflow-wrap: anywhere; }
168
+ .help-center__version-card p { margin: 10px 0 0; color: var(--ui-text-secondary); font-size: 12px; line-height: 1.6; }
169
+ .help-center__section-title { margin: 24px 0 12px; color: var(--ui-text); font-size: 17px; }
170
+ .help-center__technology-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
171
+ .help-center__technology { padding: 14px 16px; border: 1px solid var(--ui-border); border-radius: 9px; background: #fff; }
172
+ .help-center__technology > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
173
+ .help-center__technology p { margin: 6px 0 0; color: var(--ui-text-secondary); font-size: 13px; }
174
+ .help-center__operations { padding: 18px 6px 18px 2px; }
175
+ .help-center__operation-types { margin-bottom: 16px; }
176
+ .help-center__operation-menu { border-top: 1px solid var(--ui-border); }
177
+ .help-center__operation-title { color: var(--ui-text); font-size: 14px; font-weight: 600; }
178
+ .help-center__operation-menu :deep(.el-collapse-item__header) { min-height: 48px; padding: 0 14px; border-right: 1px solid var(--ui-border); border-left: 1px solid var(--ui-border); }
179
+ .help-center__operation-menu :deep(.el-collapse-item__wrap) { border-right: 1px solid var(--ui-border); border-left: 1px solid var(--ui-border); }
180
+ .help-center__operation-menu :deep(.el-collapse-item__content) { padding: 4px 18px 18px; }
181
+ @media (max-width: 760px) {
182
+ .help-center__versions,
183
+ .help-center__technology-grid { grid-template-columns: 1fr; }
184
+ }
185
+ </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>