@sybz-components/portal-dev 1.0.9 → 1.0.11

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 CHANGED
@@ -9,6 +9,8 @@
9
9
 
10
10
  macOS 会直接复用当前打开的 Google Chrome,在现有窗口中新建标签页完成登录和本地联调,不会启动独立的 Chrome 实例。Windows / Linux 使用独立浏览器实例作为兼容方案。
11
11
 
12
+ Windows 会优先读取 `CHROME_PATH`,然后自动查找 Chrome 和 Microsoft Edge 的常见安装位置。配置文件优先使用 `%APPDATA%`;自动启动前端项目时通过 `cmd.exe` 运行 npm、pnpm 或 Bun,结束时会清理对应的进程树。
13
+
12
14
  ## 安装
13
15
 
14
16
  全局安装:
@@ -50,6 +52,39 @@ portal-dev config --portal custom
50
52
 
51
53
  ## 使用
52
54
 
55
+ 列出所有已配置的石景山、成华和自定义网站账号,输入序号后直接登录:
56
+
57
+ ```bash
58
+ portal-dev login
59
+ ```
60
+
61
+ 示例:
62
+
63
+ ```text
64
+ 请选择要登录的账号:
65
+ 1. 石景山 - 测试账号
66
+ 2. 成华 - 管理员
67
+ 3. 自定义网站 - 内部系统
68
+ 请输入序号(1-3):
69
+ ```
70
+
71
+ ### 本地调试
72
+
73
+ 修改 `portal-dev` 源码后不需要发布或全局安装,在本包目录直接运行:
74
+
75
+ ```bash
76
+ pnpm dev sjs
77
+ pnpm dev login
78
+ ```
79
+
80
+ 该命令直接执行当前源码,效果与发布后的 `portal-dev sjs` 一致。其他参数也可以原样传入,例如:
81
+
82
+ ```bash
83
+ pnpm dev --portal sjs
84
+ pnpm dev config --portal sjs
85
+ pnpm dev --help
86
+ ```
87
+
53
88
  只登录石景山门户,不读取或启动任何前端项目:
54
89
 
55
90
  ```bash
@@ -74,13 +109,6 @@ portal-dev --portal sjs
74
109
  portal-dev --portal sjs 测试账号
75
110
  ```
76
111
 
77
- 默认快捷命令:
78
-
79
- ```bash
80
- portal-dev ds # 登录石景山
81
- portal-dev sc # 登录成华
82
- ```
83
-
84
112
  配置并登录普通网站:
85
113
 
86
114
  ```bash
@@ -90,51 +118,63 @@ portal-dev --portal custom 我的账号
90
118
 
91
119
  配置时依次输入登录页 URL、用户名和密码。登录时会自动查找常见的用户名、邮箱、密码输入框,以及“登录”、“Login”或“Sign in”按钮;该模式不会执行验证码识别、样板间搜索或 Token 跳转。
92
120
 
93
- 别名保存在 JSON 中,可以通过向导新增登录或联调命令:
94
-
95
- ```bash
96
- portal-dev config command
97
- ```
98
-
99
- 例如配置一个别名为 `sjs-dev` 的联调命令后,只需运行:
121
+ 执行 `portal-dev config` 时,可以直接为账号设置快捷别名。石景山账号还可选择“只登录”或“本地联调”模式。例如将某个账号的别名配置为 `sjs-dev`:
100
122
 
101
123
  ```bash
124
+ portal-dev config --portal sjs
102
125
  portal-dev sjs-dev
103
126
  ```
104
127
 
105
- 执行时会登录指定账号、启动本地项目、进入样板间搜索智能体,从匹配的 iframe 地址取得 Token,然后保留查询参数打开本地页面。
128
+ 执行时会登录指定账号、在已配置 `project` 时启动本地项目、进入样板间搜索智能体,从匹配的 iframe 地址取得 Token,然后保留查询参数打开本地页面。
129
+
130
+ `project` 是可选项,只在需要 CLI 自动启动前端开发服务时配置。不配置时不会启动项目,联调流程仍使用默认本地地址 `http://localhost:5173`。
106
131
 
107
- 配置文件中的账号和快捷命令格式:
132
+ 配置文件中,别名、模式和联调参数都直接属于对应账号,不再有独立的 `commands`:
108
133
 
109
134
  ```json
110
135
  {
111
- "version": 4,
136
+ "version": 5,
112
137
  "profiles": {
113
- "sjs": [{ "name": "我的账号", "username": "user1", "password": "******" }],
114
- "chenghua": [{ "name": "测试账号", "username": "user2", "password": "******" }],
138
+ "sjs": [
139
+ {
140
+ "name": "测试账号",
141
+ "username": "user1",
142
+ "password": "******",
143
+ "alias": "sjs-dev",
144
+ "mode": "dev",
145
+ "project": "/Users/你的名字/projects/agent-web",
146
+ "localOrigin": "http://localhost:5173",
147
+ "localPath": "/exhibition-hall",
148
+ "roomName": "3D智能展厅智能体",
149
+ "iframeHost": "hia.sjsdoubao.com:31118",
150
+ "iframePath": "/exhibition-hall"
151
+ }
152
+ ],
153
+ "chenghua": [
154
+ {
155
+ "name": "成华账号",
156
+ "username": "user2",
157
+ "password": "******",
158
+ "alias": "ch",
159
+ "mode": "login"
160
+ }
161
+ ],
115
162
  "custom": [
116
- { "name": "内部系统", "loginUrl": "https://example.com/login", "username": "user3", "password": "******" }
163
+ {
164
+ "name": "内部系统",
165
+ "loginUrl": "https://example.com/login",
166
+ "username": "user3",
167
+ "password": "******",
168
+ "alias": "internal",
169
+ "mode": "login"
170
+ }
117
171
  ]
118
- },
119
- "commands": [
120
- { "alias": "ds", "mode": "login", "portal": "sjs" },
121
- { "alias": "sc", "mode": "login", "portal": "chenghua" },
122
- {
123
- "alias": "sjs-dev",
124
- "mode": "dev",
125
- "portal": "sjs",
126
- "account": "测试账号",
127
- "project": "/Users/你的名字/projects/agent-web",
128
- "localOrigin": "http://localhost:5173",
129
- "localPath": "/exhibition-hall",
130
- "roomName": "3D智能展厅智能体",
131
- "iframeHost": "hia.sjsdoubao.com:31118",
132
- "iframePath": "/exhibition-hall"
133
- }
134
- ]
172
+ }
135
173
  }
136
174
  ```
137
175
 
176
+ 首次读取旧版 `version: 4` 配置时,CLI 会自动将 `commands` 合并到对应 profile 并升级为 `version: 5`。
177
+
138
178
  查看命令帮助:
139
179
 
140
180
  ```bash
@@ -178,4 +218,4 @@ pnpm portal:skill:install
178
218
  pnpm portal:release
179
219
  ```
180
220
 
181
- `portal:release` 会执行检查、升级 patch 版本、创建版本提交和 Git tag、发布 npm 包,最后将当前分支及 tags 推送到 Git 远端。执行前请确认当前分支、远端和工作区状态。
221
+ `portal:release` 会检查工作区和 npm 登录状态、升级 patch 版本并公开发布到 npm。只有确认新版本能从 npm registry 查询到之后,才会创建版本提交和 `portal-dev-v<版本号>` Git tag,并将当前分支及 tags 推送到 Git 远端。发布失败时会恢复本地版本号,避免留下无法安装的空版本。执行前请先提交当前改动,并确认当前分支和远端正确。
@@ -2,10 +2,44 @@
2
2
  import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'
3
3
  import { spawn } from 'node:child_process'
4
4
  import { dirname, resolve } from 'node:path'
5
+ import { createInterface } from 'node:readline/promises'
5
6
  import { fileURLToPath } from 'node:url'
7
+ import { readPortalConfig } from '../src/config-file.mjs'
6
8
 
7
9
  const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..')
8
- const args = process.argv.slice(2)
10
+ let args = process.argv.slice(2)
11
+
12
+ if (args[0] === 'login') {
13
+ const portalNames = { sjs: '石景山', chenghua: '成华', custom: '自定义网站' }
14
+ const { profiles } = readPortalConfig()
15
+ const options = Object.keys(portalNames).flatMap((portal) =>
16
+ (Array.isArray(profiles[portal]) ? profiles[portal] : [])
17
+ .filter(
18
+ (account) =>
19
+ account?.name &&
20
+ typeof account.username === 'string' &&
21
+ typeof account.password === 'string' &&
22
+ (portal !== 'custom' || account.loginUrl),
23
+ )
24
+ .map((account) => ({ portal, account })),
25
+ )
26
+
27
+ if (!options.length) throw new Error('没有可登录的已配置账号,请先运行 portal-dev config')
28
+
29
+ console.log('请选择要登录的账号:')
30
+ options.forEach(({ portal, account }, index) => {
31
+ console.log(` ${index + 1}. ${portalNames[portal]} - ${account.name}`)
32
+ })
33
+
34
+ const readline = createInterface({ input: process.stdin, output: process.stdout })
35
+ const answer = (await readline.question(`请输入序号(1-${options.length}):`)).trim()
36
+ readline.close()
37
+ const selectedIndex = Number(answer) - 1
38
+ if (!Number.isInteger(selectedIndex) || !options[selectedIndex]) throw new Error(`无效的选项:${answer || '(空)'}`)
39
+
40
+ const selected = options[selectedIndex]
41
+ args = ['--profile', selected.account.name, '--portal', selected.portal]
42
+ }
9
43
 
10
44
  if (args[0] === 'skill' && args[1] === 'install') {
11
45
  const homeDir = process.env.HOME || process.env.USERPROFILE
@@ -34,15 +68,15 @@ if (args[0] === 'config') {
34
68
  } else if (args.includes('--help') || args.includes('-h')) {
35
69
  console.log(`用法:
36
70
  portal-dev
71
+ portal-dev login
37
72
  portal-dev config [--portal sjs|chenghua|custom]
38
- portal-dev config command
39
73
  portal-dev <快捷命令别名>
40
74
  portal-dev --portal sjs|chenghua|custom [账号名称]
41
75
  portal-dev skill install [Codex skills 目录]
42
76
 
43
77
  首次使用只需运行一次 portal-dev config。
44
- 默认快捷命令:portal-dev ds 登录石景山,portal-dev sc 登录成华。
45
- 运行 portal-dev config command 可新增或修改快捷命令。`)
78
+ 运行 portal-dev login 可列出所有已配置账号,输入序号直接登录。
79
+ 每个账号都可在 portal-dev config 时配置别名和登录/联调模式。`)
46
80
  process.exit(0)
47
81
  } else {
48
82
  const child = spawn(process.execPath, [resolve(packageDir, 'src/portal-dev.mjs'), ...args], {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sybz-components/portal-dev",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "成华和石景山门户自动登录与本地前端联调 CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,9 +20,10 @@
20
20
  "registry": "https://registry.npmjs.org"
21
21
  },
22
22
  "scripts": {
23
+ "dev": "node ./bin/portal-dev.mjs",
23
24
  "skill:install": "node ./bin/portal-dev.mjs skill install",
24
25
  "check": "node --check bin/portal-dev.mjs && node --check src/config-file.mjs && node --check src/configure.mjs && node --check src/macos-chrome.mjs && node --check src/portal-dev.mjs && node --check src/recognize-captcha.mjs",
25
- "release": "pnpm check && npm version patch && npm publish && git push --follow-tags"
26
+ "release": "node ./scripts/release.mjs"
26
27
  },
27
28
  "dependencies": {
28
29
  "@napi-rs/canvas": "^1.0.3",
@@ -11,6 +11,14 @@ macOS 会复用用户当前打开的 Google Chrome,并在现有窗口的标签
11
11
 
12
12
  ## 执行
13
13
 
14
+ 需要让用户从所有已配置账号中选择时执行:
15
+
16
+ ```bash
17
+ portal-dev login
18
+ ```
19
+
20
+ 该命令会显示石景山、成华和自定义网站的可登录账号,输入序号后直接登录。
21
+
14
22
  缺少账号配置时先执行:
15
23
 
16
24
  ```bash
@@ -35,17 +43,10 @@ portal-dev --portal chenghua
35
43
  portal-dev --portal custom <账号名称>
36
44
  ```
37
45
 
38
- 默认快捷命令:
39
-
40
- ```bash
41
- portal-dev ds
42
- portal-dev sc
43
- ```
44
-
45
- 只有用户明确要求门户本地联调时才执行 JSON 中 `mode: "dev"` 的快捷命令,例如 `portal-dev sjs-dev`。项目、本地地址、路由、智能体名称和 iframe 识别参数均从该命令配置读取。脚本从门户 iframe URL 取得 Token 后,必须保留查询参数打开本地地址,且不得输出 Token。
46
+ 每个 profile 可以直接配置 `alias` 和 `mode`。只有用户明确要求门户本地联调时才执行 `mode: "dev"` 的快捷别名,例如 `portal-dev sjs-dev`。`project` 仅用于需要 CLI 自动启动前端项目的情况,未配置时直接使用默认本地地址 `http://localhost:5173`。本地地址、路由、智能体名称和 iframe 识别参数均从该 profile 读取。脚本从门户 iframe URL 取得 Token 后,必须保留查询参数打开本地地址,且不得输出 Token。
46
47
 
47
48
  保持进程运行并告诉用户当前状态;只有用户明确要求停止时才终止。不得输出用户名、密码或门户 Token。
48
49
 
49
50
  ## 配置
50
51
 
51
- 账号密码只从当前用户的专属配置文件读取,每个门户可保存多个账号。`portal-dev config` 会新增账号或按账号名称更新已有账号;登录命令不写账号名称时使用数组第一个账号,在命令末尾追加账号名称时使用指定账号。普通网站使用 `portal-dev config --portal custom` 配置登录 URL、账号名称、用户名和密码。缺少凭据时,引导用户在本机终端运行 `portal-dev config`,不索要或展示具体密码。
52
+ 账号密码只从当前用户的专属配置文件读取,每个门户可保存多个账号。`portal-dev config` 会新增账号或按账号名称更新已有账号,并在 profile 中直接配置别名和模式;登录命令不写账号名称时使用数组第一个账号,在命令末尾追加账号名称时使用指定账号。普通网站使用 `portal-dev config --portal custom` 配置登录 URL、账号名称、用户名和密码。缺少凭据时,引导用户在本机终端运行 `portal-dev config`,不索要或展示具体密码。
@@ -2,33 +2,54 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'n
2
2
  import { homedir } from 'node:os'
3
3
  import { dirname, resolve } from 'node:path'
4
4
 
5
- const defaultCommands = () => [
6
- { alias: 'ds', mode: 'login', portal: 'sjs' },
7
- { alias: 'sc', mode: 'login', portal: 'chenghua' },
8
- ]
9
-
10
5
  export const getPortalConfigPath = () => {
11
6
  const configRoot =
12
- process.platform === 'win32' ? resolve(homedir(), 'AppData', 'Roaming') : resolve(homedir(), '.config')
7
+ process.platform === 'win32'
8
+ ? process.env.APPDATA || resolve(homedir(), 'AppData', 'Roaming')
9
+ : resolve(homedir(), '.config')
13
10
  return resolve(configRoot, 'sybz-components', 'portal-dev.json')
14
11
  }
15
12
 
16
13
  export const readPortalConfig = () => {
17
14
  const configPath = getPortalConfigPath()
18
- if (!existsSync(configPath)) return { version: 4, profiles: {}, commands: defaultCommands() }
15
+ if (!existsSync(configPath)) return { version: 5, profiles: {} }
19
16
  try {
20
17
  const config = JSON.parse(readFileSync(configPath, 'utf8'))
21
18
  if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('根节点必须是对象')
22
- return {
23
- ...config,
24
- profiles: config.profiles && typeof config.profiles === 'object' ? config.profiles : {},
25
- commands: Array.isArray(config.commands) ? config.commands : defaultCommands(),
19
+ const profiles = config.profiles && typeof config.profiles === 'object' ? structuredClone(config.profiles) : {}
20
+
21
+ for (const command of Array.isArray(config.commands) ? config.commands : []) {
22
+ if (!command?.alias || !['sjs', 'chenghua', 'custom'].includes(command.portal)) continue
23
+ const accounts = Array.isArray(profiles[command.portal]) ? profiles[command.portal] : []
24
+ const accountIndex = command.account
25
+ ? accounts.findIndex((account) => account?.name === command.account)
26
+ : accounts.length
27
+ ? 0
28
+ : -1
29
+ if (accountIndex < 0) continue
30
+ const { portal: _portal, account: _account, ...profileOptions } = command
31
+ accounts[accountIndex] = { ...accounts[accountIndex], ...profileOptions }
32
+ profiles[command.portal] = accounts
26
33
  }
34
+
35
+ const normalizedConfig = { version: 5, profiles }
36
+ if (config.version !== 5 || Object.hasOwn(config, 'commands')) writeConfig(configPath, normalizedConfig)
37
+ return normalizedConfig
27
38
  } catch (error) {
28
39
  throw new Error(`门户配置文件格式错误:${configPath}\n${error.message}`)
29
40
  }
30
41
  }
31
42
 
43
+ const writeConfig = (configPath, config) => {
44
+ mkdirSync(dirname(configPath), { recursive: true, mode: 0o700 })
45
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
46
+ try {
47
+ chmodSync(configPath, 0o600)
48
+ } catch {
49
+ // Windows 等不支持 POSIX 权限的环境由系统用户目录权限保护。
50
+ }
51
+ }
52
+
32
53
  export const writePortalAccount = (portal, account) => {
33
54
  const configPath = getPortalConfigPath()
34
55
  const config = readPortalConfig()
@@ -44,35 +65,9 @@ export const writePortalAccount = (portal, account) => {
44
65
  if (accountIndex >= 0) nextAccounts[accountIndex] = account
45
66
  else nextAccounts.push(account)
46
67
  const nextConfig = {
47
- ...config,
48
- version: 4,
68
+ version: 5,
49
69
  profiles: { ...normalizedProfiles, [portal]: nextAccounts },
50
- commands: config.commands,
51
- }
52
- mkdirSync(dirname(configPath), { recursive: true, mode: 0o700 })
53
- writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`, { mode: 0o600 })
54
- try {
55
- chmodSync(configPath, 0o600)
56
- } catch {
57
- // Windows 等不支持 POSIX 权限的环境由系统用户目录权限保护。
58
70
  }
71
+ writeConfig(configPath, nextConfig)
59
72
  return { configPath, accountCount: nextAccounts.length }
60
73
  }
61
-
62
- export const writePortalCommand = (command) => {
63
- const configPath = getPortalConfigPath()
64
- const config = readPortalConfig()
65
- const commandIndex = config.commands.findIndex((item) => item.alias === command.alias)
66
- const nextCommands = [...config.commands]
67
- if (commandIndex >= 0) nextCommands[commandIndex] = command
68
- else nextCommands.push(command)
69
- const nextConfig = { ...config, version: 4, commands: nextCommands }
70
- mkdirSync(dirname(configPath), { recursive: true, mode: 0o700 })
71
- writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`, { mode: 0o600 })
72
- try {
73
- chmodSync(configPath, 0o600)
74
- } catch {
75
- // Windows 等不支持 POSIX 权限的环境由系统用户目录权限保护。
76
- }
77
- return { configPath, commandCount: nextCommands.length }
78
- }
package/src/configure.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createInterface } from 'node:readline/promises'
2
- import { writePortalAccount, writePortalCommand } from './config-file.mjs'
2
+ import { writePortalAccount } from './config-file.mjs'
3
3
 
4
4
  const args = process.argv.slice(2)
5
5
  const readArg = (name) => {
@@ -52,52 +52,6 @@ const askPassword = (message) => {
52
52
  })
53
53
  }
54
54
 
55
- if (args[0] === 'command') {
56
- const commandRl = createInterface({ input: process.stdin, output: process.stdout })
57
- const alias = (await commandRl.question('请输入快捷命令别名(例如:ds、sc、sjs-dev):')).trim()
58
- if (!alias || !/^[a-zA-Z][\w-]*$/.test(alias)) {
59
- commandRl.close()
60
- throw new Error('别名只能包含字母、数字、下划线和短横线,并且必须以字母开头')
61
- }
62
- const modeAnswer = (await commandRl.question('请选择命令类型(1=只登录,2=石景山联调,默认1):')).trim()
63
- const mode = modeAnswer === '2' ? 'dev' : 'login'
64
- const portalAnswer =
65
- mode === 'dev' ? 'sjs' : (await commandRl.question('请选择门户(1=石景山,2=成华,3=自定义,默认石景山):')).trim()
66
- const portal =
67
- portalAnswer === '3' || portalAnswer === 'custom'
68
- ? 'custom'
69
- : portalAnswer === '2' || portalAnswer === 'chenghua'
70
- ? 'chenghua'
71
- : 'sjs'
72
- const account = (await commandRl.question('请输入账号名称(直接回车使用该门户第一个账号):')).trim()
73
- const command = { alias, mode, portal, ...(account ? { account } : {}) }
74
-
75
- if (mode === 'dev') {
76
- const project = (await commandRl.question('请输入前端项目绝对路径:')).trim()
77
- if (!project) {
78
- commandRl.close()
79
- throw new Error('联调命令必须配置前端项目路径')
80
- }
81
- command.project = project
82
- command.localOrigin =
83
- (await commandRl.question('请输入本地服务地址(默认 http://localhost:5173):')).trim() || 'http://localhost:5173'
84
- command.localPath =
85
- (await commandRl.question('请输入本地接收 Token 的路由(默认 /exhibition-hall):')).trim() || '/exhibition-hall'
86
- command.roomName =
87
- (await commandRl.question('请输入样板间智能体名称(默认 3D智能展厅智能体):')).trim() || '3D智能展厅智能体'
88
- command.iframeHost =
89
- (await commandRl.question('请输入门户 iframe 域名(默认 hia.sjsdoubao.com:31118):')).trim() ||
90
- 'hia.sjsdoubao.com:31118'
91
- command.iframePath =
92
- (await commandRl.question('请输入门户 iframe 路径(默认 /exhibition-hall):')).trim() || '/exhibition-hall'
93
- }
94
- commandRl.close()
95
- const { configPath, commandCount } = writePortalCommand(command)
96
- console.log(`快捷命令配置完成:portal-dev ${alias}`)
97
- console.log(`当前共 ${commandCount} 个快捷命令,配置文件:${configPath}`)
98
- process.exit(0)
99
- }
100
-
101
55
  const rl = createInterface({ input: process.stdin, output: process.stdout })
102
56
  const portalArg = readArg('--portal')
103
57
  const portalAnswer = portalArg || (await rl.question('请选择门户(1=石景山,2=成华,3=自定义,直接回车默认石景山):'))
@@ -113,6 +67,28 @@ if (!name) {
113
67
  rl.close()
114
68
  throw new Error('账号名称不能为空')
115
69
  }
70
+ const alias = (await rl.question('请输入快捷命令别名(可直接回车跳过):')).trim()
71
+ if (alias && !/^[a-zA-Z][\w-]*$/.test(alias)) {
72
+ rl.close()
73
+ throw new Error('别名只能包含字母、数字、下划线和短横线,并且必须以字母开头')
74
+ }
75
+ const modeAnswer = portal === 'sjs' ? (await rl.question('请选择模式(1=只登录,2=本地联调,默认1):')).trim() : '1'
76
+ const mode = modeAnswer === '2' ? 'dev' : 'login'
77
+ const profileOptions = { ...(alias ? { alias } : {}), mode }
78
+ if (mode === 'dev') {
79
+ const project = (await rl.question('请输入需要自动启动的前端项目绝对路径(可直接回车跳过):')).trim()
80
+ if (project) profileOptions.project = project
81
+ profileOptions.localOrigin =
82
+ (await rl.question('请输入本地服务地址(默认 http://localhost:5173):')).trim() || 'http://localhost:5173'
83
+ profileOptions.localPath =
84
+ (await rl.question('请输入本地接收 Token 的路由(默认 /exhibition-hall):')).trim() || '/exhibition-hall'
85
+ profileOptions.roomName =
86
+ (await rl.question('请输入样板间智能体名称(默认 3D智能展厅智能体):')).trim() || '3D智能展厅智能体'
87
+ profileOptions.iframeHost =
88
+ (await rl.question('请输入门户 iframe 域名(默认 hia.sjsdoubao.com:31118):')).trim() || 'hia.sjsdoubao.com:31118'
89
+ profileOptions.iframePath =
90
+ (await rl.question('请输入门户 iframe 路径(默认 /exhibition-hall):')).trim() || '/exhibition-hall'
91
+ }
116
92
  let loginUrl
117
93
  if (portal === 'custom') {
118
94
  loginUrl = (await rl.question('请输入登录页 URL(例如:https://example.com/login):')).trim()
@@ -134,8 +110,9 @@ const { configPath, accountCount } = writePortalAccount(portal, {
134
110
  username,
135
111
  password,
136
112
  ...(loginUrl ? { loginUrl } : {}),
113
+ ...profileOptions,
137
114
  })
138
115
 
139
116
  console.log(`配置完成:${portalName}现有 ${accountCount} 个账号`)
140
117
  console.log(`配置文件:${configPath}`)
141
- console.log(`现在可以在任意目录运行:portal-dev --portal ${portal}`)
118
+ console.log(`现在可以在任意目录运行:portal-dev ${alias || `--portal ${portal} ${name}`}`)
@@ -3,6 +3,18 @@ import { promisify } from 'node:util'
3
3
 
4
4
  const execFileAsync = promisify(execFile)
5
5
  const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
6
+ const waitForExitSignal = () =>
7
+ new Promise((resolve) => {
8
+ const keepAlive = setInterval(() => undefined, 60_000)
9
+ const finish = () => {
10
+ clearInterval(keepAlive)
11
+ process.removeListener('SIGINT', finish)
12
+ process.removeListener('SIGTERM', finish)
13
+ resolve()
14
+ }
15
+ process.once('SIGINT', finish)
16
+ process.once('SIGTERM', finish)
17
+ })
6
18
  const escapeAppleScript = (value) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, ' ')
7
19
 
8
20
  const runAppleScript = async (script) => {
@@ -74,6 +86,28 @@ const fillSearch = async (tab, roomName) =>
74
86
  })()`,
75
87
  )) === 'true'
76
88
 
89
+ const waitForPortalReady = async (tab) => {
90
+ let stableCount = 0
91
+ for (let index = 0; index < 120; index += 1) {
92
+ const ready =
93
+ (await execute(
94
+ tab,
95
+ `(() => {
96
+ const visible = (element) => { const style = getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && rect.width > 0 && rect.height > 0; };
97
+ const loading = Array.from(document.querySelectorAll('.el-loading-mask, .ant-spin-spinning, .n-spin-body, .loading-mask, .loading-overlay, .v-loading-mask, [aria-busy="true"]')).some(visible);
98
+ return document.readyState === 'complete' && !loading ? 'true' : 'false';
99
+ })()`,
100
+ )) === 'true'
101
+ stableCount = ready ? stableCount + 1 : 0
102
+ if (stableCount >= 2) {
103
+ await sleep(800)
104
+ return
105
+ }
106
+ await sleep(500)
107
+ }
108
+ throw new Error('门户页面 loading 等待超时')
109
+ }
110
+
77
111
  const fillAndSubmit = async (tab, { username, password, captchaText, custom }) =>
78
112
  JSON.parse(
79
113
  await execute(
@@ -83,13 +117,15 @@ const fillAndSubmit = async (tab, { username, password, captchaText, custom }) =
83
117
  const visible = (element) => { const style = getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; };
84
118
  const inputs = Array.from(document.querySelectorAll('input')).filter(visible);
85
119
  const setValue = (element, value) => { const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; setter.call(element, value); element.dispatchEvent(new Event('input', {bubbles:true})); element.dispatchEvent(new Event('change', {bubbles:true})); };
86
- const usernameInput = inputs.find((input) => input.autocomplete === 'username' || ['username','account','login','user','email'].includes(input.name) || /用户名|账号|邮箱|手机/.test(input.placeholder));
87
- const passwordInput = inputs.find((input) => input.autocomplete === 'current-password' || input.name === 'password' || input.type === 'password' || input.placeholder.includes('密码'));
88
- const captchaInput = inputs.find((input) => /验证码/.test(input.placeholder) || /captcha|code/i.test(input.name));
89
- if (!usernameInput || !passwordInput || (!${custom} && !captchaInput)) return JSON.stringify({submitted:false});
120
+ const attribute = (input, name) => input.getAttribute(name) || '';
121
+ const passwordInput = inputs.find((input) => attribute(input, 'autocomplete') === 'current-password' || /^password$/i.test(attribute(input, 'name')) || input.type === 'password' || /密码/.test(attribute(input, 'placeholder')));
122
+ const captchaInput = inputs.find((input) => /验证码/.test(attribute(input, 'placeholder')) || /captcha|code/i.test(attribute(input, 'name')));
123
+ const usernameInput = inputs.find((input) => attribute(input, 'autocomplete') === 'username' || /^(username|account|login|user|email|phone)$/i.test(attribute(input, 'name')) || /用户名|账号|邮箱|手机/.test(attribute(input, 'placeholder'))) || inputs.find((input) => input !== passwordInput && input !== captchaInput && /^(text|email|tel|number)$/.test(input.type));
124
+ const missing = [!usernameInput && '账号输入框', !passwordInput && '密码输入框', !${custom} && !captchaInput && '验证码输入框'].filter(Boolean);
125
+ if (missing.length) return JSON.stringify({submitted:false, reason:'未找到' + missing.join('、')});
90
126
  setValue(usernameInput, values.username); setValue(passwordInput, values.password); if (captchaInput) setValue(captchaInput, values.captchaText);
91
127
  const button = Array.from(document.querySelectorAll('button, input[type="submit"], [role="button"], .btn-box .btn')).filter(visible).find((element) => element.type === 'submit' || /登录|Login|Sign in/i.test(element.textContent || element.value || ''));
92
- if (!button) return JSON.stringify({submitted:false}); button.click(); return JSON.stringify({submitted:true});
128
+ if (!button) return JSON.stringify({submitted:false, reason:'未找到登录按钮'}); button.click(); return JSON.stringify({submitted:true});
93
129
  })()`,
94
130
  ),
95
131
  )
@@ -107,7 +143,7 @@ const login = async (tab, config, recognizeCaptcha, custom) => {
107
143
  console.log(`已识别图形验证码(第 ${attempt}/3 次)`)
108
144
  }
109
145
  const result = await fillAndSubmit(tab, { ...config, captchaText, custom })
110
- if (!result.submitted) throw new Error('登录表单字段不完整或未找到登录按钮')
146
+ if (!result.submitted) throw new Error(result.reason || '登录表单字段不完整或未找到登录按钮')
111
147
  await sleep(1800)
112
148
  const state = await pageState(tab)
113
149
  if (custom || !state.url.includes('/passport/login/')) return
@@ -145,9 +181,13 @@ export const runInExistingChrome = async ({
145
181
  await execute(tab, `location.href = 'https://www.chenghua-ai.com/chat/pages/application'; ''`)
146
182
  }
147
183
  console.log(`${portalName}登录流程已完成,按 Ctrl+C 结束。`)
148
- await new Promise(() => undefined)
184
+ await waitForExitSignal()
185
+ return
149
186
  }
150
187
 
188
+ console.log('正在等待门户页面加载完成。')
189
+ await waitForPortalReady(tab)
190
+ console.log('门户页面已加载,正在进入智能体样板间。')
151
191
  let sampleRoomClicked = false
152
192
  let searched = false
153
193
  for (let index = 0; index < 180; index += 1) {
@@ -158,7 +198,8 @@ export const runInExistingChrome = async ({
158
198
  await openTab(destination.href)
159
199
  console.log(`门户本地调试已就绪:${localOrigin}${targetUrl.pathname}`)
160
200
  console.log('Token 未打印、未写入文件。按 Ctrl+C 结束。')
161
- return new Promise(() => undefined)
201
+ await waitForExitSignal()
202
+ return
162
203
  }
163
204
  if (!sampleRoomClicked) sampleRoomClicked = await clickText(tab, '智能体样板间')
164
205
  else if (!searched) searched = await fillSearch(tab, roomName)
@@ -10,15 +10,29 @@ const sleep = (milliseconds) => new Promise((resolvePromise) => setTimeout(resol
10
10
  const args = process.argv.slice(2)
11
11
  const portalConfig = readPortalConfig()
12
12
  const commandAlias = args[0] && args[0] !== 'dev' && !args[0].startsWith('-') ? args[0] : undefined
13
- const savedCommand = commandAlias ? portalConfig.commands.find((command) => command?.alias === commandAlias) : undefined
14
- if (commandAlias && !savedCommand)
15
- throw new Error(`未找到快捷命令“${commandAlias}”,请运行 portal-dev config command 创建`)
16
13
  const readArg = (name) => {
17
14
  const index = args.indexOf(name)
18
15
  return index >= 0 ? args[index + 1] : undefined
19
16
  }
17
+ const selectedProfileName = readArg('--profile')
18
+ const savedCommand = commandAlias
19
+ ? Object.entries(portalConfig.profiles)
20
+ .flatMap(([portal, profiles]) =>
21
+ (Array.isArray(profiles) ? profiles : []).map((profile) => ({ ...profile, portal })),
22
+ )
23
+ .find((profile) => profile.alias === commandAlias)
24
+ : selectedProfileName
25
+ ? Object.entries(portalConfig.profiles)
26
+ .flatMap(([portal, profiles]) =>
27
+ (Array.isArray(profiles) ? profiles : []).map((profile) => ({ ...profile, portal })),
28
+ )
29
+ .find((profile) => profile.portal === readArg('--portal') && profile.name === selectedProfileName)
30
+ : undefined
31
+ if (commandAlias && !savedCommand)
32
+ throw new Error(`未找到快捷命令“${commandAlias}”,请运行 portal-dev config 为账号配置别名`)
33
+ if (selectedProfileName && !savedCommand) throw new Error(`未找到账号“${selectedProfileName}”`)
20
34
  const readPositionalAccount = () => {
21
- const valueOptions = new Set(['--portal', '--project'])
35
+ const valueOptions = new Set(['--portal', '--project', '--profile', '--host', '--port'])
22
36
  for (let index = 0; index < args.length; index += 1) {
23
37
  const argument = args[index]
24
38
  if (valueOptions.has(argument)) {
@@ -50,7 +64,8 @@ const findProject = () => {
50
64
  throw new Error('未找到前端项目,请先进入项目目录,或使用 --project <路径> 指定')
51
65
  }
52
66
 
53
- const projectDir = devMode ? findProject() : undefined
67
+ const configuredProject = savedCommand?.project || readArg('--project')
68
+ const projectDir = devMode && (configuredProject || args[0] === 'dev') ? findProject() : undefined
54
69
  if (projectDir && !existsSync(resolve(projectDir, 'package.json')))
55
70
  throw new Error(`目标项目不存在或缺少 package.json:${projectDir}`)
56
71
  const configuredAccounts = portalConfig.profiles[portal]
@@ -66,7 +81,8 @@ if (!portalAccounts.length) {
66
81
  }
67
82
 
68
83
  const selectAccount = () => {
69
- const accountName = savedCommand?.account || readPositionalAccount()
84
+ if (savedCommand) return savedCommand
85
+ const accountName = readPositionalAccount()
70
86
  if (accountName) {
71
87
  const matched = portalAccounts.find((account) => account.name === accountName)
72
88
  if (!matched) throw new Error(`未找到账号“${accountName}”,请检查账号名称或重新配置`)
@@ -112,11 +128,16 @@ const chromeCandidates = {
112
128
  `${process.env.PROGRAMFILES || ''}\\Google\\Chrome\\Application\\chrome.exe`,
113
129
  `${process.env['PROGRAMFILES(X86)'] || ''}\\Google\\Chrome\\Application\\chrome.exe`,
114
130
  `${process.env.LOCALAPPDATA || ''}\\Google\\Chrome\\Application\\chrome.exe`,
131
+ `${process.env.PROGRAMFILES || ''}\\Microsoft\\Edge\\Application\\msedge.exe`,
132
+ `${process.env['PROGRAMFILES(X86)'] || ''}\\Microsoft\\Edge\\Application\\msedge.exe`,
133
+ `${process.env.LOCALAPPDATA || ''}\\Microsoft\\Edge\\Application\\msedge.exe`,
115
134
  ],
116
135
  linux: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser'],
117
136
  }
118
- const executablePath = chromeCandidates[process.platform]?.find(existsSync)
119
- if (!executablePath) throw new Error('未找到 Chrome 或 Edge,请先安装浏览器')
137
+ const executablePath = [process.env.CHROME_PATH, ...(chromeCandidates[process.platform] || [])]
138
+ .filter(Boolean)
139
+ .find(existsSync)
140
+ if (!executablePath) throw new Error('未找到 Chrome 或 Edge,请先安装浏览器,或通过 CHROME_PATH 指定可执行文件')
120
141
 
121
142
  const localUrl = new URL(localOrigin)
122
143
  const isReady = async () => {
@@ -128,7 +149,15 @@ const isReady = async () => {
128
149
  }
129
150
 
130
151
  let devServer
131
- if (devMode && !(await isReady())) {
152
+ const stopDevServer = () => {
153
+ if (!devServer?.pid) return
154
+ if (process.platform === 'win32') {
155
+ spawn('taskkill.exe', ['/pid', String(devServer.pid), '/t', '/f'], { stdio: 'ignore' }).unref()
156
+ } else {
157
+ devServer.kill('SIGTERM')
158
+ }
159
+ }
160
+ if (devMode && projectDir && !(await isReady())) {
132
161
  const packageJson = JSON.parse(readFileSync(resolve(projectDir, 'package.json'), 'utf8'))
133
162
  if (!packageJson.scripts?.dev) throw new Error(`目标项目没有 dev script:${projectDir}`)
134
163
  const runner = existsSync(resolve(projectDir, 'bun.lock'))
@@ -136,14 +165,24 @@ if (devMode && !(await isReady())) {
136
165
  : existsSync(resolve(projectDir, 'pnpm-lock.yaml'))
137
166
  ? 'pnpm'
138
167
  : 'npm'
168
+ const runnerArgs = ['run', 'dev', '--', '--host', localUrl.hostname, '--port', localUrl.port || '80']
139
169
  console.log(`正在启动项目开发服务:${projectDir}`)
140
- devServer = spawn(runner, ['run', 'dev', '--', '--host', localUrl.hostname, '--port', localUrl.port || '80'], {
141
- cwd: projectDir,
142
- env: process.env,
143
- stdio: 'inherit',
144
- })
170
+ devServer =
171
+ process.platform === 'win32'
172
+ ? spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', runner, ...runnerArgs], {
173
+ cwd: projectDir,
174
+ env: process.env,
175
+ stdio: 'inherit',
176
+ })
177
+ : spawn(runner, runnerArgs, {
178
+ cwd: projectDir,
179
+ env: process.env,
180
+ stdio: 'inherit',
181
+ })
145
182
  for (let index = 0; index < 120 && !(await isReady()); index += 1) await sleep(500)
146
183
  if (!(await isReady())) throw new Error(`本地开发服务启动超时:${localOrigin}`)
184
+ } else if (devMode && !projectDir && !(await isReady())) {
185
+ console.log(`未配置 project,不自动启动前端项目,后续将直接打开默认地址:${localOrigin}`)
147
186
  }
148
187
 
149
188
  if (process.platform === 'darwin') {
@@ -160,11 +199,23 @@ if (process.platform === 'darwin') {
160
199
  portal === 'custom' ? `自定义网站“${portalAccount.name}”` : `${portal === 'chenghua' ? '成华' : '石景山'}门户`,
161
200
  recognizeCaptcha,
162
201
  })
202
+ stopDevServer()
203
+ process.exit(0)
163
204
  }
164
205
 
165
206
  const browser = await chromium.launch({ headless: false, executablePath })
166
207
  const context = await browser.newContext()
167
208
  const page = await context.newPage()
209
+ let closing = false
210
+ const shutdown = async () => {
211
+ if (closing) return
212
+ closing = true
213
+ stopDevServer()
214
+ await browser.close().catch(() => undefined)
215
+ process.exit(0)
216
+ }
217
+ process.on('SIGINT', shutdown)
218
+ process.on('SIGTERM', shutdown)
168
219
  await page.goto(config.loginUrl, { waitUntil: 'domcontentloaded' })
169
220
 
170
221
  const visibleLocator = async (selectors) => {
@@ -188,6 +239,42 @@ const clickText = async (text) => {
188
239
  return false
189
240
  }
190
241
 
242
+ const waitForPortalReady = async () => {
243
+ console.log('正在等待门户页面加载完成。')
244
+ let stableCount = 0
245
+ for (let index = 0; index < 120; index += 1) {
246
+ const ready = await page
247
+ .evaluate(() => {
248
+ const visible = (element) => {
249
+ const style = getComputedStyle(element)
250
+ const rect = element.getBoundingClientRect()
251
+ return (
252
+ style.display !== 'none' &&
253
+ style.visibility !== 'hidden' &&
254
+ style.opacity !== '0' &&
255
+ rect.width > 0 &&
256
+ rect.height > 0
257
+ )
258
+ }
259
+ const loading = Array.from(
260
+ document.querySelectorAll(
261
+ '.el-loading-mask, .ant-spin-spinning, .n-spin-body, .loading-mask, .loading-overlay, .v-loading-mask, [aria-busy="true"]',
262
+ ),
263
+ ).some(visible)
264
+ return document.readyState === 'complete' && !loading
265
+ })
266
+ .catch(() => false)
267
+ stableCount = ready ? stableCount + 1 : 0
268
+ if (stableCount >= 2) {
269
+ await sleep(800)
270
+ console.log('门户页面已加载,正在进入智能体样板间。')
271
+ return
272
+ }
273
+ await sleep(500)
274
+ }
275
+ throw new Error('门户页面 loading 等待超时')
276
+ }
277
+
191
278
  const login = async () => {
192
279
  for (let attempt = 1; attempt <= 3; attempt += 1) {
193
280
  let captcha
@@ -209,6 +296,7 @@ const login = async () => {
209
296
  'input[name="username"]',
210
297
  'input[placeholder*="用户名"]',
211
298
  'input[placeholder*="账号"]',
299
+ 'input[autocomplete="off"]:not([type="password"]):not([placeholder*="验证码"])',
212
300
  ])
213
301
  const passwordInput = await visibleLocator([
214
302
  'input[autocomplete="current-password"]',
@@ -301,6 +389,7 @@ if (!devMode) {
301
389
  await new Promise(() => undefined)
302
390
  }
303
391
 
392
+ await waitForPortalReady()
304
393
  let sampleRoomClicked = false
305
394
  let searched = false
306
395
  let targetUrl
@@ -341,11 +430,4 @@ await context.newPage().then((localPage) => localPage.goto(destination.href))
341
430
  console.log(`门户本地调试已就绪:${localOrigin}${targetUrl.pathname}`)
342
431
  console.log('Token 未打印、未写入文件。按 Ctrl+C 结束。')
343
432
 
344
- const shutdown = async () => {
345
- devServer?.kill('SIGTERM')
346
- await browser.close().catch(() => undefined)
347
- process.exit(0)
348
- }
349
- process.on('SIGINT', shutdown)
350
- process.on('SIGTERM', shutdown)
351
433
  await new Promise(() => undefined)