@shiplens/cli 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -62
- package/docs/02_SHIPLENS_CLI_/345/205/250/345/221/275/344/273/244/345/217/202/350/200/203/346/211/213/345/206/214.md +205 -0
- package/lib/api.js +59 -35
- package/lib/assets/skill.js +164 -87
- package/lib/cli.js +55 -55
- package/lib/commands/auth.js +41 -36
- package/lib/commands/context.js +18 -13
- package/lib/commands/dashboards.js +24 -16
- package/lib/commands/doctor.js +34 -43
- package/lib/commands/heatmap.js +9 -14
- package/lib/commands/init.js +54 -62
- package/lib/commands/mcp.js +2 -2
- package/lib/commands/pages.js +23 -19
- package/lib/commands/projects.js +13 -13
- package/lib/commands/query.js +12 -10
- package/lib/commands/sql.js +5 -4
- package/lib/commands/summary.js +7 -7
- package/lib/injector.js +22 -36
- package/lib/mcp-config.js +5 -5
- package/lib/taxonomy.js +25 -22
- package/package.json +5 -4
- package/prompts/README.md +11 -5
- package/prompts/prompts_cli_zh.md +743 -0
- package/prompts/prompts_cli_en.md +0 -671
package/lib/commands/heatmap.js
CHANGED
|
@@ -2,33 +2,28 @@ async function handleHeatmap(args, flags, ctx) {
|
|
|
2
2
|
const appId = ctx.resolveAppId();
|
|
3
3
|
const templateId = flags.template || args[0];
|
|
4
4
|
if (!templateId) {
|
|
5
|
-
throw new Error('
|
|
5
|
+
throw new Error('请通过 --template <id> 指定目标页面模板 ID');
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
const env = flags.env || 'production';
|
|
9
9
|
const domHash = flags['dom-hash'] || '';
|
|
10
10
|
|
|
11
|
-
const resp = await ctx.client.
|
|
12
|
-
app_id: appId,
|
|
13
|
-
template_id: templateId,
|
|
14
|
-
env,
|
|
15
|
-
dom_hash: domHash,
|
|
16
|
-
});
|
|
11
|
+
const resp = await ctx.client.heatmap(appId, templateId, env, domHash);
|
|
17
12
|
|
|
18
13
|
ctx.output(resp, () => {
|
|
19
|
-
console.log(`🔥
|
|
14
|
+
console.log(`🔥 页面热力图分析 (模板: ${resp.template_id}, DOM 哈希: ${resp.dom_hash || '-'}):`);
|
|
20
15
|
if (resp.skeleton_svg_url) {
|
|
21
|
-
console.log(`🖼️
|
|
16
|
+
console.log(`🖼️ 骨架图 SVG 预览地址: ${resp.skeleton_svg_url}`);
|
|
22
17
|
}
|
|
23
|
-
console.log(`👆
|
|
18
|
+
console.log(`👆 总点击数: ${resp.clicks_total || 0}\n`);
|
|
24
19
|
if (resp.elements && resp.elements.length > 0) {
|
|
25
|
-
console.log('
|
|
20
|
+
console.log('高频点击元素分布:');
|
|
26
21
|
resp.elements.forEach((el, i) => {
|
|
27
|
-
console.log(`[${i + 1}]
|
|
28
|
-
console.log(`
|
|
22
|
+
console.log(`[${i + 1}] 元素: ${el.label || '-'} (选择器: ${el.selector})`);
|
|
23
|
+
console.log(` 点击数: ${el.clicks} | 点击转化率: ${((el.click_rate || 0) * 100).toFixed(2)}% | 坐标: (${el.x_pct}, ${el.y_pct})`);
|
|
29
24
|
});
|
|
30
25
|
} else {
|
|
31
|
-
console.log('
|
|
26
|
+
console.log('(当前模板暂无点击元素数据)');
|
|
32
27
|
}
|
|
33
28
|
});
|
|
34
29
|
}
|
package/lib/commands/init.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const readline = require('readline');
|
|
2
|
-
const { detectProject, detectExistingApp, injectSDK, injectSkill, installSDKDependency, getGitEmail
|
|
2
|
+
const { detectProject, detectExistingApp, injectSDK, injectSkill, installSDKDependency, getGitEmail } = require('../injector');
|
|
3
3
|
const { saveLocalConfig } = require('../config');
|
|
4
4
|
const { saveDeviceEnv } = require('../device-env');
|
|
5
5
|
const { resolveTaxonomyFromIDs, formatTaxonomySummary } = require('../taxonomy');
|
|
@@ -13,7 +13,7 @@ async function handleInit(args, flags, ctx) {
|
|
|
13
13
|
const description = flags.description || detected.description || '';
|
|
14
14
|
const framework = flags.framework || detected.framework;
|
|
15
15
|
|
|
16
|
-
//
|
|
16
|
+
// 4 级分类判定:优先命令行显式参数,否则使用 detectProject 智能推断结果
|
|
17
17
|
let taxonomy = detected.taxonomy;
|
|
18
18
|
if (flags.genre || flags.subgenre || flags.tags) {
|
|
19
19
|
const rawTags = flags.tags ? (Array.isArray(flags.tags) ? flags.tags : flags.tags.split(',').map((t) => t.trim())) : (taxonomy ? taxonomy.feature_tag_ids : []);
|
|
@@ -24,18 +24,20 @@ async function handleInit(args, flags, ctx) {
|
|
|
24
24
|
const subgenreId = taxonomy?.subgenre?.id || 'browser_web_utility';
|
|
25
25
|
const featureTagIds = taxonomy?.feature_tag_ids || [];
|
|
26
26
|
if (featureTagIds.length > 10) {
|
|
27
|
-
throw new Error('
|
|
27
|
+
throw new Error('最多只能提交 10 个四级特性标签');
|
|
28
28
|
}
|
|
29
29
|
const industry = flags.industry || subgenreId || 'saas';
|
|
30
30
|
|
|
31
|
-
// 1.
|
|
31
|
+
// 1. 检查本地是否已存在 SDK 或已有项目编号 (防意外覆盖分支)
|
|
32
32
|
const existing = detectExistingApp(wd);
|
|
33
33
|
if (existing.has_existing && !flags.force) {
|
|
34
34
|
if (ctx.isJSON) {
|
|
35
35
|
const result = {
|
|
36
36
|
ok: false,
|
|
37
37
|
code: 'PROJECT_EXISTS_LOCALLY',
|
|
38
|
-
message:
|
|
38
|
+
message: `检测到本地已存在 Shiplens 统计配置 (项目 ID: ${existing.app_id}),位于 ${existing.source_file}。` +
|
|
39
|
+
`选项 1 [推荐]: 保持现有统计,保留原项目编号与历史数据;` +
|
|
40
|
+
`选项 2: 重新创建并覆盖 (追加 --force,向云端申请全新项目编号并覆盖本地,旧看板将停止接收新数据)。`,
|
|
39
41
|
existing_app_id: existing.app_id,
|
|
40
42
|
source_file: existing.source_file,
|
|
41
43
|
project_name: existing.project_name || projectName,
|
|
@@ -45,43 +47,44 @@ async function handleInit(args, flags, ctx) {
|
|
|
45
47
|
process.exitCode = 1;
|
|
46
48
|
return;
|
|
47
49
|
} else if (!process.stdin.isTTY) {
|
|
48
|
-
console.log(`\n⚠️
|
|
49
|
-
console.log(`📌
|
|
50
|
-
console.log(`📊
|
|
51
|
-
console.log(`🛑
|
|
52
|
-
console.log(`💡
|
|
53
|
-
console.log(
|
|
50
|
+
console.log(`\n⚠️ 检测到当前项目已存在 Shiplens 配置!`);
|
|
51
|
+
console.log(`📌 本地已存在项目编号: ${existing.app_id} (来源: ${existing.source_file})`);
|
|
52
|
+
console.log(`📊 现有数据看板: ${ctx.client.baseURL}/dashboard/${existing.app_id}`);
|
|
53
|
+
console.log(`🛑 检测到非交互式终端环境 (CI/Agent),已自动保留现有配置。`);
|
|
54
|
+
console.log(`💡 选项 1 [推荐]: 保持现有统计,保留原项目 ID 与历史数据;`);
|
|
55
|
+
console.log(`💡 选项 2: 若要强制重新创建新项目并覆盖本地配置,请追加 --force 参数:`);
|
|
56
|
+
console.log(` npx.cmd --yes @shiplens/cli init --force\n`);
|
|
54
57
|
return;
|
|
55
58
|
} else {
|
|
56
|
-
console.log(`\n⚠️
|
|
57
|
-
console.log(`📌
|
|
58
|
-
console.log(`📊
|
|
59
|
-
console.log(`\n💡
|
|
60
|
-
console.log(`💡
|
|
61
|
-
console.log(` npx @shiplens/cli init --force\n`);
|
|
59
|
+
console.log(`\n⚠️ 检测到当前项目可能正在统计中!`);
|
|
60
|
+
console.log(`📌 本地已存在项目编号: ${existing.app_id} (来源: ${existing.source_file})`);
|
|
61
|
+
console.log(`📊 现有数据看板: ${ctx.client.baseURL}/dashboard/${existing.app_id}`);
|
|
62
|
+
console.log(`\n💡 选项 1 [推荐]: 保持现有统计,继续使用原项目 ID,历史数据和现有看板完整保留;`);
|
|
63
|
+
console.log(`💡 选项 2: 强制重新创建并覆盖 (追加 --force),申请全新空白项目 ID 覆盖本地 (注意:旧看板停止接收新数据,新旧数据无法合并):`);
|
|
64
|
+
console.log(` npx.cmd --yes @shiplens/cli init --force\n`);
|
|
62
65
|
|
|
63
66
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
64
67
|
const confirmed = await new Promise((resolve) => {
|
|
65
|
-
rl.question('
|
|
68
|
+
rl.question('是否确认覆盖并重新创建全新项目?(y/N): ', (ans) => {
|
|
66
69
|
rl.close();
|
|
67
70
|
resolve(ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes');
|
|
68
71
|
});
|
|
69
72
|
});
|
|
70
73
|
if (!confirmed) {
|
|
71
|
-
console.log('🛑
|
|
74
|
+
console.log('🛑 操作已取消,已保留原项目配置与统计。');
|
|
72
75
|
return;
|
|
73
76
|
}
|
|
74
77
|
}
|
|
75
78
|
}
|
|
76
79
|
|
|
77
|
-
// 2.
|
|
80
|
+
// 2. 自动释放/注入通用 AI Skill 技能书 (.agents/skills/shiplens/SKILL.md 及 rules)
|
|
78
81
|
const skillFile = injectSkill(wd);
|
|
79
82
|
|
|
80
|
-
// 3.
|
|
83
|
+
// 3. 上报/同步到服务端注册项目
|
|
81
84
|
let appId = '';
|
|
82
85
|
let dashboardUrl = '';
|
|
83
86
|
let accountStatus = 'not_logged_in_unlinked';
|
|
84
|
-
let accountStatusText = '
|
|
87
|
+
let accountStatusText = '未登录 (首次安装默认状态或本地不存在有效凭据)';
|
|
85
88
|
|
|
86
89
|
try {
|
|
87
90
|
const connResp = await ctx.client.connect({
|
|
@@ -99,24 +102,24 @@ async function handleInit(args, flags, ctx) {
|
|
|
99
102
|
if (ctx.resolvedAuth && ctx.resolvedAuth.is_present) {
|
|
100
103
|
if (connResp.account_linked !== false && connResp.linked !== false && !connResp.unlinked) {
|
|
101
104
|
accountStatus = 'logged_in_linked';
|
|
102
|
-
accountStatusText = '
|
|
105
|
+
accountStatusText = '已登录 (项目已与账号关联)';
|
|
103
106
|
} else {
|
|
104
107
|
accountStatus = 'logged_in_unlinked';
|
|
105
|
-
accountStatusText = '
|
|
108
|
+
accountStatusText = '已登录 (项目和账号未关联)';
|
|
106
109
|
}
|
|
107
110
|
} else {
|
|
108
111
|
accountStatus = 'not_logged_in_unlinked';
|
|
109
|
-
accountStatusText = '
|
|
112
|
+
accountStatusText = '未登录 (首次安装默认状态或本地不存在有效凭据)';
|
|
110
113
|
}
|
|
111
114
|
} catch (netErr) {
|
|
112
115
|
netErr.code = netErr.code || 'CONNECT_FAILED';
|
|
113
116
|
throw netErr;
|
|
114
117
|
}
|
|
115
118
|
|
|
116
|
-
// 4.
|
|
119
|
+
// 4. 自动插桩注入 SDK 代码
|
|
117
120
|
const injectedFile = injectSDK(wd, framework, appId);
|
|
118
121
|
|
|
119
|
-
// 5.
|
|
122
|
+
// 5. 写入本地状态机 .shiplens.json
|
|
120
123
|
saveLocalConfig(wd, {
|
|
121
124
|
app_id: appId,
|
|
122
125
|
project_name: projectName,
|
|
@@ -129,17 +132,17 @@ async function handleInit(args, flags, ctx) {
|
|
|
129
132
|
last_synced_at: new Date().toISOString(),
|
|
130
133
|
});
|
|
131
134
|
|
|
132
|
-
// 6.
|
|
135
|
+
// 6. 安装依赖 (4-Tier 阶梯并发竞速)
|
|
133
136
|
let installResult = { success: true, manager: 'skipped' };
|
|
134
137
|
if (!flags['no-install'] && detected.package_manager) {
|
|
135
138
|
installResult = await installSDKDependency(wd, detected.package_manager);
|
|
136
139
|
if (!installResult.success) {
|
|
137
|
-
const errMsg = `SDK
|
|
138
|
-
|
|
139
|
-
` 1.
|
|
140
|
-
` 2.
|
|
141
|
-
` 3.
|
|
142
|
-
` 4.
|
|
140
|
+
const errMsg = `SDK 安装失败: 无法从 NPM 官方源、备用镜像源、GitHub 源码镜像或官方 CDN 镜像下载安装 @shiplens/sdk。\n` +
|
|
141
|
+
`排查步骤建议:\n` +
|
|
142
|
+
` 1. 检查本地网络连接或 HTTP 代理 / VPN 状态;\n` +
|
|
143
|
+
` 2. 尝试手动使用备用镜像安装: npm install @shiplens/sdk --registry=https://registry.npmmirror.com;\n` +
|
|
144
|
+
` 3. 或手动通过官方源安装: npm install @shiplens/sdk;\n` +
|
|
145
|
+
` 4. 访问 https://shiplens.dev 查看最新 SDK 状态与手动安装指南。`;
|
|
143
146
|
|
|
144
147
|
if (ctx.isJSON) {
|
|
145
148
|
ctx.output({
|
|
@@ -157,7 +160,7 @@ async function handleInit(args, flags, ctx) {
|
|
|
157
160
|
}
|
|
158
161
|
}
|
|
159
162
|
|
|
160
|
-
// 7.
|
|
163
|
+
// 7. 绑定邮箱 (若用户显式指定或传 auto)
|
|
161
164
|
let targetEmail = flags.email;
|
|
162
165
|
if (targetEmail === 'auto') {
|
|
163
166
|
targetEmail = getGitEmail();
|
|
@@ -180,12 +183,6 @@ async function handleInit(args, flags, ctx) {
|
|
|
180
183
|
}
|
|
181
184
|
}
|
|
182
185
|
|
|
183
|
-
// 8. Auto Git commit
|
|
184
|
-
let gitCommitResult = { committed: false };
|
|
185
|
-
if (!flags['no-commit']) {
|
|
186
|
-
gitCommitResult = autoGitCommit(wd, 'feat: Integrate Shiplens SDK and configure dashboard');
|
|
187
|
-
}
|
|
188
|
-
|
|
189
186
|
const elapsed = Date.now() - start;
|
|
190
187
|
const taxonomyFormatted = formatTaxonomySummary(taxonomy);
|
|
191
188
|
|
|
@@ -208,44 +205,39 @@ async function handleInit(args, flags, ctx) {
|
|
|
208
205
|
auth_status: authStatus,
|
|
209
206
|
shiplens_env_written: authStatus === 'magic_link_sent',
|
|
210
207
|
activation_required: authStatus === 'pending',
|
|
211
|
-
free_tier_info: '50,000
|
|
208
|
+
free_tier_info: '每月 50,000 事件 (约 5,000 独立访客) 永久免费统计额度',
|
|
212
209
|
bound_email: targetEmail || undefined,
|
|
213
|
-
git_committed: gitCommitResult.committed,
|
|
214
|
-
git_commit_hash: gitCommitResult.hash || undefined,
|
|
215
210
|
elapsed_ms: elapsed,
|
|
216
211
|
atomic_completed: true,
|
|
217
212
|
};
|
|
218
213
|
|
|
219
214
|
ctx.output(result, () => {
|
|
220
215
|
console.log(`\n==================================================`);
|
|
221
|
-
console.log(`✅ Shiplens SDK
|
|
222
|
-
console.log(`📦
|
|
223
|
-
console.log(
|
|
216
|
+
console.log(`✅ Shiplens 数据统计 SDK 已成功安装并完成代码插桩!(耗时 ${elapsed} ms)\n`);
|
|
217
|
+
console.log(`📦 项目与数据看板信息`);
|
|
218
|
+
console.log(`项目名称: ${projectName}`);
|
|
224
219
|
if (description) {
|
|
225
|
-
console.log(
|
|
220
|
+
console.log(`项目详情: ${description}`);
|
|
226
221
|
}
|
|
227
222
|
if (taxonomyFormatted) {
|
|
228
|
-
console.log(
|
|
223
|
+
console.log(`项目类别:`);
|
|
229
224
|
console.log(taxonomyFormatted);
|
|
230
225
|
}
|
|
231
|
-
console.log(
|
|
232
|
-
console.log(
|
|
233
|
-
console.log(
|
|
234
|
-
console.log(
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
console.log(`🧠 AI Skill Ready: ${skillFile}`);
|
|
239
|
-
console.log(`🔒 [SHIPLENS_ATOMIC_COMPLETED] Setup completed atomically. No extra commands needed.`);
|
|
226
|
+
console.log(`应用 ID (App ID): ${appId}`);
|
|
227
|
+
console.log(`代码注入: 已自动在入口文件 ${injectedFile} 中配置集成 (框架: ${framework})`);
|
|
228
|
+
console.log(`实时数据看板 / 项目激活链接: 🔗 ${dashboardUrl}`);
|
|
229
|
+
console.log(`用户个人账号: ${accountStatusText}`);
|
|
230
|
+
console.log(`🧠 AI 技能就绪: ${skillFile}`);
|
|
231
|
+
console.log(`🔒 [SHIPLENS_ATOMIC_COMPLETED] 所有操作已原子化闭环,无需执行 git status / doctor 等额外检查。`);
|
|
240
232
|
console.log(`==================================================\n`);
|
|
241
233
|
|
|
242
234
|
if (authStatus === 'magic_link_sent' && targetEmail) {
|
|
243
|
-
console.log(`📧
|
|
244
|
-
console.log(`👉
|
|
235
|
+
console.log(`📧 激活邮件已发送至: ${targetEmail}`);
|
|
236
|
+
console.log(`👉 请点击邮件中的链接完成注册与设备授权,即可永久激活每月 50,000 事件免费统计额度。`);
|
|
245
237
|
} else {
|
|
246
238
|
console.log(`> [!IMPORTANT]`);
|
|
247
|
-
console.log(`>
|
|
248
|
-
console.log(`>
|
|
239
|
+
console.log(`> **项目待激活(数据统计尚未生效)**`);
|
|
240
|
+
console.log(`> **请告诉我您的邮箱**:点击邮件中的链接即可完成注册,激活每月 50,000 事件的免费额度(约5000人访问),同时绑定项目和账号;激活后您与我直接对话,我就有权限查询并分析产品数据。`);
|
|
249
241
|
}
|
|
250
242
|
});
|
|
251
243
|
}
|
package/lib/commands/mcp.js
CHANGED
|
@@ -39,9 +39,9 @@ function emitResponse(result) {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
async function handleMcp(subcommand) {
|
|
42
|
-
if (subcommand !== 'serve') throw new Error('
|
|
42
|
+
if (subcommand !== 'serve') throw new Error('支持的 mcp 子命令: serve');
|
|
43
43
|
const deviceEnv = getDeviceEnv();
|
|
44
|
-
if (!deviceEnv) throw new Error('shiplens.env
|
|
44
|
+
if (!deviceEnv) throw new Error('未找到 shiplens.env,请先执行 shiplens auth bind 并完成邮件激活');
|
|
45
45
|
let buffer = '';
|
|
46
46
|
let sessionId = '';
|
|
47
47
|
process.stdin.setEncoding('utf8');
|
package/lib/commands/pages.js
CHANGED
|
@@ -4,22 +4,23 @@ async function handlePages(args, flags, ctx) {
|
|
|
4
4
|
const env = flags.env || 'production';
|
|
5
5
|
const limit = parseInt(flags.limit || '10', 10);
|
|
6
6
|
|
|
7
|
-
const resp = await ctx.client.
|
|
7
|
+
const resp = await ctx.client.pages(appId, range, env, limit);
|
|
8
8
|
|
|
9
9
|
ctx.output(resp, () => {
|
|
10
|
-
console.log(`📄
|
|
10
|
+
console.log(`📄 页面证据分析 (时间范围: ${range}, 前 ${limit} 页):\n`);
|
|
11
11
|
const pages = Array.isArray(resp.pages) ? resp.pages : (Array.isArray(resp) ? resp : []);
|
|
12
12
|
if (pages.length === 0) {
|
|
13
|
-
console.log('
|
|
13
|
+
console.log('(暂无页面数据,请确认 SDK 已正常上报)');
|
|
14
14
|
return;
|
|
15
15
|
}
|
|
16
|
-
|
|
17
|
-
console.log('
|
|
16
|
+
// 表格化输出
|
|
17
|
+
console.log('序号 | 路径/标题 | PV | UV | 平均停留(s)');
|
|
18
|
+
console.log('--- | ---------------------------------- | ------- | ------- | ----------');
|
|
18
19
|
pages.forEach((p, i) => {
|
|
19
20
|
const path = (p.path || p.template_id || '-').padEnd(34);
|
|
20
21
|
const pv = String(p.pv || p.pageviews || 0).padEnd(7);
|
|
21
22
|
const uv = String(p.uv || p.unique_visitors || 0).padEnd(7);
|
|
22
|
-
const dur = String(p.avg_duration_s || p.avg_session_duration_s || 0).padEnd(
|
|
23
|
+
const dur = String(p.avg_duration_s || p.avg_session_duration_s || 0).padEnd(10);
|
|
23
24
|
console.log(`[${String(i + 1).padEnd(2)}] | ${path} | ${pv} | ${uv} | ${dur}`);
|
|
24
25
|
});
|
|
25
26
|
});
|
|
@@ -30,27 +31,30 @@ async function handlePaths(args, flags, ctx) {
|
|
|
30
31
|
const range = flags.range || '7d';
|
|
31
32
|
const env = flags.env || 'production';
|
|
32
33
|
|
|
33
|
-
const resp = await ctx.client.
|
|
34
|
+
const resp = await ctx.client.paths(appId, range, env);
|
|
34
35
|
|
|
35
36
|
ctx.output(resp, () => {
|
|
36
|
-
console.log(`🔀
|
|
37
|
+
console.log(`🔀 页面流转路径分析 (时间范围: ${range}):\n`);
|
|
37
38
|
|
|
39
|
+
// 入口页面
|
|
38
40
|
if (resp.entry_pages && resp.entry_pages.length > 0) {
|
|
39
|
-
console.log('▶
|
|
41
|
+
console.log('▶ 主要入口页面:');
|
|
40
42
|
resp.entry_pages.slice(0, 5).forEach((e, i) =>
|
|
41
|
-
console.log(` [${i + 1}] ${e.path || e.page}
|
|
43
|
+
console.log(` [${i + 1}] ${e.path || e.page} — ${e.sessions || e.count || 0} 次会话`)
|
|
42
44
|
);
|
|
43
45
|
}
|
|
46
|
+
// 退出页面
|
|
44
47
|
if (resp.exit_pages && resp.exit_pages.length > 0) {
|
|
45
|
-
console.log('\n◀
|
|
48
|
+
console.log('\n◀ 主要退出页面:');
|
|
46
49
|
resp.exit_pages.slice(0, 5).forEach((e, i) =>
|
|
47
|
-
console.log(` [${i + 1}] ${e.path || e.page}
|
|
50
|
+
console.log(` [${i + 1}] ${e.path || e.page} — 退出率 ${((e.exit_rate || 0) * 100).toFixed(1)}%`)
|
|
48
51
|
);
|
|
49
52
|
}
|
|
53
|
+
// 路径流转
|
|
50
54
|
if (resp.paths && resp.paths.length > 0) {
|
|
51
|
-
console.log('\n↔
|
|
55
|
+
console.log('\n↔ 高频页面流转路径:');
|
|
52
56
|
resp.paths.slice(0, 8).forEach((p, i) =>
|
|
53
|
-
console.log(` [${i + 1}] ${p.from || p.source} → ${p.to || p.target} (${p.count || p.sessions || 0}
|
|
57
|
+
console.log(` [${i + 1}] ${p.from || p.source} → ${p.to || p.target} (${p.count || p.sessions || 0} 次)`)
|
|
54
58
|
);
|
|
55
59
|
} else {
|
|
56
60
|
console.log(JSON.stringify(resp, null, 2));
|
|
@@ -63,18 +67,18 @@ async function handleCanvas(args, flags, ctx) {
|
|
|
63
67
|
const range = flags.range || '7d';
|
|
64
68
|
const env = flags.env || 'production';
|
|
65
69
|
|
|
66
|
-
const resp = await ctx.client.
|
|
70
|
+
const resp = await ctx.client.behaviorCanvas(appId, range, env);
|
|
67
71
|
|
|
68
72
|
ctx.output(resp, () => {
|
|
69
|
-
console.log(`🎨
|
|
73
|
+
console.log(`🎨 用户行为画布拓扑 (时间范围: ${range}):\n`);
|
|
70
74
|
const nodeCount = (resp.nodes || []).length;
|
|
71
75
|
const edgeCount = (resp.edges || resp.links || []).length;
|
|
72
76
|
if (nodeCount > 0 || edgeCount > 0) {
|
|
73
|
-
console.log(`📍 ${nodeCount}
|
|
77
|
+
console.log(`📍 共 ${nodeCount} 个节点, ${edgeCount} 条边`);
|
|
74
78
|
if (resp.nodes && resp.nodes.length > 0) {
|
|
75
|
-
console.log('\
|
|
79
|
+
console.log('\n主要节点:');
|
|
76
80
|
resp.nodes.slice(0, 10).forEach((n, i) =>
|
|
77
|
-
console.log(` [${i + 1}] ${n.label || n.path || n.id} (
|
|
81
|
+
console.log(` [${i + 1}] ${n.label || n.path || n.id} (访问量: ${n.value || n.count || 0})`)
|
|
78
82
|
);
|
|
79
83
|
}
|
|
80
84
|
} else {
|
package/lib/commands/projects.js
CHANGED
|
@@ -9,19 +9,19 @@ async function handleProjects(subcommand, args, flags, ctx) {
|
|
|
9
9
|
ctx.output(resp, () => {
|
|
10
10
|
const projects = resp.projects || [];
|
|
11
11
|
if (projects.length === 0) {
|
|
12
|
-
console.log('
|
|
12
|
+
console.log('暂无关联项目。可使用 shiplens init 初始化一个新项目。');
|
|
13
13
|
return;
|
|
14
14
|
}
|
|
15
|
-
console.log(`📦
|
|
15
|
+
console.log(`📦 共有 ${projects.length} 个项目:\n`);
|
|
16
16
|
projects.forEach((p, i) => {
|
|
17
17
|
console.log(`[${i + 1}] ${p.project_name || p.app_name || 'Untitled'} (${p.app_id})`);
|
|
18
|
-
if (p.description) console.log(`
|
|
18
|
+
if (p.description) console.log(` 简介: ${p.description}`);
|
|
19
19
|
if (p.taxonomy?.subgenre?.name) {
|
|
20
20
|
const tags = (p.taxonomy.feature_tags || []).map((tag) => tag.name).join(', ');
|
|
21
|
-
console.log(`
|
|
21
|
+
console.log(` 分类: ${p.taxonomy.genre?.name || '-'} / ${p.taxonomy.subgenre.name}${tags ? ` · ${tags}` : ''}`);
|
|
22
22
|
}
|
|
23
|
-
console.log(`
|
|
24
|
-
if (p.created_at) console.log(`
|
|
23
|
+
console.log(` 行业: ${p.industry || '-'} | 状态: ${p.status || 'active'} | Schema更新时间戳: ${p.schema_last_modified_at || 0}`);
|
|
24
|
+
if (p.created_at) console.log(` 创建时间: ${p.created_at}`);
|
|
25
25
|
console.log('');
|
|
26
26
|
});
|
|
27
27
|
});
|
|
@@ -32,7 +32,7 @@ async function handleProjects(subcommand, args, flags, ctx) {
|
|
|
32
32
|
const appId = ctx.resolveAppId();
|
|
33
33
|
const res = await ctx.client.bindProject(appId, flags.name);
|
|
34
34
|
ctx.output(res, () => {
|
|
35
|
-
console.log(`✅
|
|
35
|
+
console.log(`✅ 项目 ${appId} 已成功绑定至当前账号!`);
|
|
36
36
|
});
|
|
37
37
|
break;
|
|
38
38
|
}
|
|
@@ -40,36 +40,36 @@ async function handleProjects(subcommand, args, flags, ctx) {
|
|
|
40
40
|
case 'delete': {
|
|
41
41
|
const appId = ctx.resolveAppId();
|
|
42
42
|
if (!flags.force) {
|
|
43
|
-
console.log(`⚠️
|
|
43
|
+
console.log(`⚠️ 警告:正在删除项目 ${appId} 及其全部历史事件数据。该操作不可逆!`);
|
|
44
44
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
45
45
|
const confirmed = await new Promise((resolve) => {
|
|
46
|
-
rl.question(
|
|
46
|
+
rl.question(`请输入项目 ID 或 'yes' 确认删除: `, (ans) => {
|
|
47
47
|
rl.close();
|
|
48
48
|
resolve(ans.trim() === 'yes' || ans.trim() === appId);
|
|
49
49
|
});
|
|
50
50
|
});
|
|
51
51
|
if (!confirmed) {
|
|
52
|
-
console.log('🛑
|
|
52
|
+
console.log('🛑 操作已取消。');
|
|
53
53
|
return;
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
const res = await ctx.client.deleteProject(appId);
|
|
58
58
|
|
|
59
|
-
//
|
|
59
|
+
// 清理本地专属业务上下文文件
|
|
60
60
|
const localContextPath = path.join(process.cwd(), '.shiplens', 'contexts', `${appId}.md`);
|
|
61
61
|
if (fs.existsSync(localContextPath)) {
|
|
62
62
|
try { fs.unlinkSync(localContextPath); } catch {}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
ctx.output(res, () => {
|
|
66
|
-
console.log(`🗑️
|
|
66
|
+
console.log(`🗑️ 项目 ${appId} 已被成功删除。`);
|
|
67
67
|
});
|
|
68
68
|
break;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
default:
|
|
72
|
-
throw new Error(
|
|
72
|
+
throw new Error(`未知的 projects 子命令: ${subcommand}。支持 list, bind, delete`);
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
package/lib/commands/query.js
CHANGED
|
@@ -11,28 +11,29 @@ async function handleQuery(args, flags, ctx) {
|
|
|
11
11
|
|
|
12
12
|
let queryReq;
|
|
13
13
|
|
|
14
|
-
//
|
|
14
|
+
// 支持 --file:从 JSON 文件读取完整 AnalyticsQueryRequest,最大灵活度
|
|
15
15
|
if (flags.file) {
|
|
16
16
|
if (!fs.existsSync(flags.file)) {
|
|
17
|
-
const err = new Error(
|
|
17
|
+
const err = new Error(`查询配置文件不存在: ${flags.file}`);
|
|
18
18
|
err.code = 'INVALID_ARGS';
|
|
19
19
|
throw err;
|
|
20
20
|
}
|
|
21
21
|
try {
|
|
22
22
|
queryReq = JSON.parse(fs.readFileSync(flags.file, 'utf8'));
|
|
23
23
|
} catch (e) {
|
|
24
|
-
const err = new Error(
|
|
24
|
+
const err = new Error(`无法解析查询配置文件 (${flags.file}): ${e.message}`);
|
|
25
25
|
err.code = 'INVALID_ARGS';
|
|
26
26
|
throw err;
|
|
27
27
|
}
|
|
28
|
+
// 允许用命令行 range / env 覆盖文件中的值
|
|
28
29
|
if (!queryReq.range) queryReq.range = range;
|
|
29
30
|
if (!queryReq.env) queryReq.env = env;
|
|
30
31
|
} else {
|
|
31
|
-
//
|
|
32
|
+
// 支持 --metrics 多指标(逗号分隔)或单指标 --metric
|
|
32
33
|
const metricsRaw = flags.metrics || flags.metric || 'pageviews';
|
|
33
34
|
const metricList = String(metricsRaw).split(',').map((m) => m.trim()).filter(Boolean);
|
|
34
35
|
|
|
35
|
-
//
|
|
36
|
+
// 解析 --filter key=val(可多次指定)
|
|
36
37
|
const filters = {};
|
|
37
38
|
const filterList = Array.isArray(flags.filter)
|
|
38
39
|
? flags.filter
|
|
@@ -42,12 +43,13 @@ async function handleQuery(args, flags, ctx) {
|
|
|
42
43
|
if (k && v.length > 0) filters[k.trim()] = v.join('=').trim();
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
// 每个指标生成一个 panel
|
|
45
47
|
queryReq = {
|
|
46
48
|
env,
|
|
47
49
|
range,
|
|
48
50
|
panels: metricList.map((metric, idx) => ({
|
|
49
51
|
panel_id: `panel_cli_${idx}`,
|
|
50
|
-
title: `${metric}
|
|
52
|
+
title: `${metric} 趋势`,
|
|
51
53
|
metric,
|
|
52
54
|
chart_type: chartType,
|
|
53
55
|
time_grain: grain,
|
|
@@ -58,22 +60,22 @@ async function handleQuery(args, flags, ctx) {
|
|
|
58
60
|
};
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
const resp = await ctx.client.
|
|
63
|
+
const resp = await ctx.client.queryAnalytics(appId, queryReq);
|
|
62
64
|
|
|
63
65
|
ctx.output(resp, () => {
|
|
64
66
|
const metricNames = (queryReq.panels || []).map((p) => p.metric).join(', ');
|
|
65
|
-
console.log(`📊
|
|
67
|
+
console.log(`📊 查询指标: ${metricNames} | 时间范围: ${queryReq.range}`);
|
|
66
68
|
if (resp.data && resp.data.length > 0) {
|
|
67
69
|
resp.data.forEach((row) => console.log('•', JSON.stringify(row)));
|
|
68
70
|
} else if (resp.panels && resp.panels.length > 0) {
|
|
69
71
|
resp.panels.forEach((p) => {
|
|
70
|
-
console.log(`\n📈
|
|
72
|
+
console.log(`\n📈 面板: ${p.title || p.panel_id}`);
|
|
71
73
|
if (Array.isArray(p.data)) {
|
|
72
74
|
p.data.forEach((row) => console.log(' •', JSON.stringify(row)));
|
|
73
75
|
}
|
|
74
76
|
});
|
|
75
77
|
} else {
|
|
76
|
-
console.log('
|
|
78
|
+
console.log('(暂无匹配数据)');
|
|
77
79
|
}
|
|
78
80
|
});
|
|
79
81
|
}
|
package/lib/commands/sql.js
CHANGED
|
@@ -2,7 +2,7 @@ async function handleSQL(args, flags, ctx) {
|
|
|
2
2
|
const appId = ctx.resolveAppId();
|
|
3
3
|
let queryStr = flags.query || flags.sql || args.join(' ');
|
|
4
4
|
|
|
5
|
-
//
|
|
5
|
+
// 支持 --stdin:从标准输入读取 SQL(兼容管道,如 echo "SELECT..." | shiplens sql --stdin)
|
|
6
6
|
if (flags.stdin || flags['read-stdin']) {
|
|
7
7
|
const chunks = [];
|
|
8
8
|
for await (const chunk of process.stdin) {
|
|
@@ -13,18 +13,19 @@ async function handleSQL(args, flags, ctx) {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
if (!queryStr) {
|
|
16
|
-
const err = new Error('SQL
|
|
16
|
+
const err = new Error('SQL 查询语句不能为空,请使用 --query "SELECT ..." 或通过管道传入 --stdin');
|
|
17
17
|
err.code = 'INVALID_SQL';
|
|
18
18
|
throw err;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
const resp = await ctx.client.executeSQL(
|
|
21
|
+
const resp = await ctx.client.executeSQL(appId, queryStr);
|
|
22
22
|
|
|
23
23
|
ctx.output(resp, () => {
|
|
24
24
|
const rowCount = resp.row_count !== undefined ? resp.row_count : (resp.rows ? resp.rows.length : 0);
|
|
25
|
-
console.log(`🔍 SQL
|
|
25
|
+
console.log(`🔍 SQL 执行成功 (耗时: ${resp.elapsed_ms || 0} ms, 返回 ${rowCount} 行):\n`);
|
|
26
26
|
|
|
27
27
|
if (resp.columns && resp.columns.length > 0) {
|
|
28
|
+
// 计算每列宽度,对齐输出
|
|
28
29
|
const colWidths = resp.columns.map((col) => {
|
|
29
30
|
let maxW = col.length;
|
|
30
31
|
if (resp.rows) {
|
package/lib/commands/summary.js
CHANGED
|
@@ -6,16 +6,16 @@ async function handleSummary(args, flags, ctx) {
|
|
|
6
6
|
const resp = await ctx.client.summary(appId, range, env);
|
|
7
7
|
|
|
8
8
|
ctx.output(resp, () => {
|
|
9
|
-
console.log(`📈
|
|
10
|
-
console.log(`•
|
|
11
|
-
console.log(`•
|
|
12
|
-
console.log(`•
|
|
13
|
-
console.log(`•
|
|
9
|
+
console.log(`📈 产品核心数据摘要 (时间范围: ${range}):`);
|
|
10
|
+
console.log(`• 总浏览量 (PV): ${resp.total_pv || 0}`);
|
|
11
|
+
console.log(`• 独立访客 (UV): ${resp.total_uv || 0}`);
|
|
12
|
+
console.log(`• 平均会话时长: ${resp.avg_session_duration_s || 0} 秒`);
|
|
13
|
+
console.log(`• 跳出率: ${((resp.bounce_rate || 0) * 100).toFixed(1)}%`);
|
|
14
14
|
if (resp.top_countries && resp.top_countries.length > 0) {
|
|
15
|
-
console.log('• Top
|
|
15
|
+
console.log('• Top 国家/地区:', resp.top_countries);
|
|
16
16
|
}
|
|
17
17
|
if (resp.top_devices && resp.top_devices.length > 0) {
|
|
18
|
-
console.log('• Top
|
|
18
|
+
console.log('• Top 访问设备:', resp.top_devices);
|
|
19
19
|
}
|
|
20
20
|
});
|
|
21
21
|
}
|