@dalehkx/quote-cli 0.3.4 → 0.3.5

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/src/auth.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  * 认证模块 — 处理登录、token 存储与自动续期
3
3
  */
4
4
  import { Store } from './store.mjs';
5
- import { APP_USER_AGENT, API_BASE_DEFAULT } from './constants.mjs';
5
+ import { APP_USER_AGENT, API_BASE_DEFAULT, TOKEN_REFRESH_AHEAD_MS } from './constants.mjs';
6
6
 
7
7
  function getStore() {
8
8
  return new Store();
@@ -221,7 +221,14 @@ export async function refreshToken(apiBase) {
221
221
 
222
222
  const json = await res.json();
223
223
  if (json.errorCode !== 0 && !json.data?.accessToken) {
224
- throw new Error(json.message || 'Token 续期失败,请重新登录: quote login');
224
+ // 只有明确的认证失效错误码才清空本地凭证
225
+ // 网络抖动、服务端临时错误等不清空,保留 token 让用户重试
226
+ const authErrorCodes = new Set([401, 652, 653, 654]);
227
+ if (authErrorCodes.has(json.errorCode) || res.status === 401) {
228
+ logout();
229
+ throw new Error('登录已过期,请重新执行: quote login');
230
+ }
231
+ throw new Error(json.message || `续签失败 [${json.errorCode}],请稍后重试`);
225
232
  }
226
233
 
227
234
  const tokenData = json.data || json;
@@ -229,8 +236,11 @@ export async function refreshToken(apiBase) {
229
236
  return tokenData;
230
237
  }
231
238
 
239
+ // 并发续签锁:同一时刻只允许一个 refreshToken 调用在飞
240
+ let _refreshPromise = null;
241
+
232
242
  /**
233
- * 获取有效的 accessToken(过期自动续期)
243
+ * 获取有效的 accessToken(过期自动续期,带并发锁防止重复刷新)
234
244
  */
235
245
  export async function getValidToken(apiBase) {
236
246
  const config = getStore().getConfig();
@@ -239,13 +249,29 @@ export async function getValidToken(apiBase) {
239
249
  throw new Error('未登录,请先执行: quote login');
240
250
  }
241
251
 
242
- // 提前 60 秒判定过期
243
252
  const now = Date.now();
244
- if (config.tokenExpiresAt && now > config.tokenExpiresAt - 60_000) {
245
- const tokenData = await refreshToken(apiBase);
253
+
254
+ // 已过期,阻塞等刷新完才能继续(加锁防并发)
255
+ if (config.tokenExpiresAt && now > config.tokenExpiresAt) {
256
+ if (!_refreshPromise) {
257
+ _refreshPromise = refreshToken(apiBase).finally(() => { _refreshPromise = null; });
258
+ }
259
+ const tokenData = await _refreshPromise;
246
260
  return tokenData.accessToken;
247
261
  }
248
262
 
263
+ // 进入预刷新窗口,同步等待刷新完成后继续(CLI 进程生命期短,不能只触发后台任务)
264
+ if (config.tokenExpiresAt && now > config.tokenExpiresAt - TOKEN_REFRESH_AHEAD_MS) {
265
+ if (!_refreshPromise) {
266
+ _refreshPromise = refreshToken(apiBase)
267
+ .catch(() => {})
268
+ .finally(() => { _refreshPromise = null; });
269
+ }
270
+ await _refreshPromise;
271
+ // 刷新成功后从磁盘读取最新 token;失败时静默继续使用旧 token(_request 层会兜底重试)
272
+ return getStore().getConfig().accessToken || config.accessToken;
273
+ }
274
+
249
275
  return config.accessToken;
250
276
  }
251
277
 
@@ -82,6 +82,7 @@ export function registerInquiryCommands(program) {
82
82
  .command('list')
83
83
  .description('查看询价单列表')
84
84
  .option('-s, --status <status>', '按状态筛选 (pending|quoted|ordered|closed)')
85
+ .option('-v, --verbose', '显示更多字段(创建时间)')
85
86
  .action(async (opts) => {
86
87
  const adapter = getAdapter();
87
88
  const items = await adapter.listInquiries({ status: opts.status });
@@ -92,7 +93,14 @@ export function registerInquiryCommands(program) {
92
93
  console.log(`共 ${items.length} 条询价单:\n`);
93
94
  for (const item of items) {
94
95
  const replies = await adapter.listReplies(item.id);
95
- console.log(` ${item.id} | ${item.product} | 数量:${item.quantity} | 状态:${item.status} | 报价:${replies.length}条`);
96
+ const vehicleStr = item.vehicle ? ` | 车型:${item.vehicle}` : '';
97
+ const verboseStr = opts.verbose
98
+ ? [
99
+ item.createdAt ? `创建:${item.createdAt.slice(0, 10)}` : '',
100
+ item.vin ? `VIN:${item.vin}` : '',
101
+ ].filter(Boolean).map(s => ` | ${s}`).join('')
102
+ : '';
103
+ console.log(` ${item.id} | ${item.product}${vehicleStr} | 数量:${item.quantity} | 状态:${item.status} | 报价:${replies.length}条${verboseStr}`);
96
104
  }
97
105
  });
98
106
 
@@ -141,6 +149,7 @@ export function registerInquiryCommands(program) {
141
149
  // isFirstCheck=true 时只建立基准,不输出报价
142
150
  let lastRawStatusId = '';
143
151
  let lastQuoteCount = -1; // -1 表示尚未初始化
152
+ let pollCount = 0; // 累计轮询次数,用于兜底定期拉报价
144
153
 
145
154
  /**
146
155
  * 阶段二:调 detailV2 拉完整报价,输出给用户
@@ -153,8 +162,8 @@ export function registerInquiryCommands(program) {
153
162
  let replies = [];
154
163
  for (let attempt = 0; attempt < 5; attempt++) {
155
164
  replies = await adapter.listReplies(id);
156
- if (replies.length > 0 || silent) break;
157
- await new Promise(r => setTimeout(r, 3000));
165
+ if (replies.length > 0) break; // silent 只控制输出,不跳过重试
166
+ if (attempt < 4) await new Promise(r => setTimeout(r, 3000));
158
167
  }
159
168
  const count = replies.length;
160
169
  const time = new Date().toLocaleTimeString();
@@ -172,6 +181,19 @@ export function registerInquiryCommands(program) {
172
181
  return count;
173
182
  };
174
183
 
184
+ /**
185
+ * 检查是否达到退出条件(exitOnQuotes 或 json 模式首次即退)
186
+ * 满足条件则输出结果并退出
187
+ */
188
+ const checkExitCondition = async (count, fromFirstCheck = false) => {
189
+ const shouldExit = (exitOnQuotes > 0 && count >= exitOnQuotes)
190
+ || (opts.json && count > 0 && fromFirstCheck);
191
+ if (!shouldExit) return false;
192
+ await fetchAndReport(false);
193
+ if (!opts.json) console.log(`\n已收到 ${count} 条报价,退出监听`);
194
+ process.exit(0);
195
+ };
196
+
175
197
  /**
176
198
  * 阶段一:轻量轮询,只查状态
177
199
  */
@@ -192,10 +214,13 @@ export function registerInquiryCommands(program) {
192
214
  const statusChanged = poll.rawStatusId !== lastRawStatusId;
193
215
 
194
216
  if (isFirstCheck) {
195
- // 首次:静默拉一次,建立基准值
217
+ // 首次:静默拉一次,建立基准值(会重试直到有数据或 5 次用完)
196
218
  const count = await fetchAndReport(true);
197
219
  lastQuoteCount = count;
198
220
  lastRawStatusId = poll.rawStatusId;
221
+ pollCount = 1;
222
+ // JSON 模式或 exit-on-quotes 已满足:首次有报价直接退出
223
+ await checkExitCondition(count, true);
199
224
  if (!opts.json) {
200
225
  if (count > 0) {
201
226
  console.log(`[${time}] 初始状态: ${poll.rawStatusId},已有 ${count} 条报价,继续监听新报价...`);
@@ -203,15 +228,19 @@ export function registerInquiryCommands(program) {
203
228
  console.log(`[${time}] 初始状态: ${poll.rawStatusId},等待报价中...`);
204
229
  }
205
230
  }
206
- } else if ((statusChanged && poll.status !== 'pending') || poll.status === 'quoted') {
207
- // 状态跳变到有报价,或持续处于已报价状态(可能新增供应商)
231
+ } else if (poll.status !== 'pending' || pollCount % 3 === 0) {
232
+ // 状态离开 pending,或每 3 轮强制拉一次(兜底:防止状态接口与报价接口不同步)
233
+ pollCount++;
208
234
  const count = await fetchAndReport(false);
209
- if (count > 0) {
235
+ if (count > lastQuoteCount && count > 0) {
236
+ lastQuoteCount = count;
210
237
  if (!opts.json) console.log(`\n已收到 ${count} 条报价,退出监听`);
211
238
  process.exit(0);
212
239
  }
213
- // 重试后仍为 0,继续轮询等数据同步
240
+ // 报价数未增加,更新状态记录继续轮询
241
+ if (statusChanged) lastRawStatusId = poll.rawStatusId;
214
242
  } else {
243
+ pollCount++;
215
244
  if (statusChanged) {
216
245
  if (!opts.json) console.log(`\n[${time}] 状态变更: ${lastRawStatusId || '-'} → ${poll.rawStatusId}`);
217
246
  lastRawStatusId = poll.rawStatusId;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * install 向导 —— 一条命令完成 CLI 全局安装 + Skill 安装 + 登录
3
+ *
4
+ * 用法:npx cass-quote install
5
+ */
6
+ import { execFileSync, execFile } from 'node:child_process';
7
+ import { createRequire } from 'node:module';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import os from 'node:os';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const { version } = require('../../package.json');
14
+
15
+ const PKG = '@dalehkx/quote-cli';
16
+ const isWindows = process.platform === 'win32';
17
+
18
+ function execCmd(cmd, args, opts) {
19
+ if (isWindows) return execFileSync('cmd.exe', ['/c', cmd, ...args], opts);
20
+ return execFileSync(cmd, args, opts);
21
+ }
22
+
23
+ function runSilent(cmd, args, opts = {}) {
24
+ return execCmd(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], ...opts });
25
+ }
26
+
27
+ function runAsync(cmd, args, opts = {}) {
28
+ const actualCmd = isWindows ? 'cmd.exe' : cmd;
29
+ const actualArgs = isWindows ? ['/c', cmd, ...args] : args;
30
+ return new Promise((resolve, reject) => {
31
+ execFile(actualCmd, actualArgs, { stdio: ['ignore', 'pipe', 'pipe'], ...opts }, (err, stdout) => {
32
+ if (err) reject(err);
33
+ else resolve(stdout.toString().trim());
34
+ });
35
+ });
36
+ }
37
+
38
+ function fmt(str, ...vals) {
39
+ let i = 0;
40
+ return str.replace(/%s/g, () => vals[i++] ?? '');
41
+ }
42
+
43
+ function getGlobalVersion() {
44
+ try {
45
+ const out = runSilent('npm', ['list', '-g', PKG], { timeout: 15000 });
46
+ const match = out.toString().match(/@(\d+\.\d+\.\d+[^\s]*)/);
47
+ return match ? match[1] : null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ function semverLt(a, b) {
54
+ const pa = a.replace(/-.*$/, '').split('.').map(Number);
55
+ const pb = b.replace(/-.*$/, '').split('.').map(Number);
56
+ for (let i = 0; i < 3; i++) {
57
+ if ((pa[i] || 0) < (pb[i] || 0)) return true;
58
+ if ((pa[i] || 0) > (pb[i] || 0)) return false;
59
+ }
60
+ return false;
61
+ }
62
+
63
+ async function stepInstallCli() {
64
+ const installed = getGlobalVersion();
65
+
66
+ if (installed && !semverLt(installed, version)) {
67
+ console.log(` ✓ CLI 已是最新版本 (v${installed}),跳过`);
68
+ return;
69
+ }
70
+
71
+ if (installed) {
72
+ process.stdout.write(fmt(' 正在升级 %s (v%s → v%s)...', PKG, installed, version));
73
+ } else {
74
+ process.stdout.write(fmt(' 正在全局安装 %s...', PKG));
75
+ }
76
+
77
+ try {
78
+ await runAsync('npm', ['install', '-g', '--ignore-scripts', PKG], { timeout: 120000 });
79
+ console.log(' 完成');
80
+ } catch {
81
+ console.log('');
82
+ console.error(fmt(' ✗ 安装失败,请手动执行: npm install -g %s', PKG));
83
+ process.exit(1);
84
+ }
85
+ }
86
+
87
+ function installSkill() {
88
+ // 找到全局安装包内的 skill 目录(不同平台路径不同)
89
+ let skillSrc;
90
+ try {
91
+ const prefix = execFileSync('npm', ['prefix', '-g'], {
92
+ stdio: ['ignore', 'pipe', 'pipe'],
93
+ }).toString().trim();
94
+ // 尝试两种常见路径:标准 npm 和 Homebrew/nvm 等
95
+ const candidates = [
96
+ path.join(prefix, 'lib', 'node_modules', PKG, 'skill'),
97
+ path.join(prefix, 'node_modules', PKG, 'skill'),
98
+ ];
99
+ skillSrc = candidates.find(p => fs.existsSync(p));
100
+ } catch { /* ignore */ }
101
+
102
+ if (!skillSrc) {
103
+ console.log(' ✗ 未找到 skill 目录,跳过');
104
+ return;
105
+ }
106
+
107
+ const skillDest = path.join(os.homedir(), '.claude', 'skills', 'cass-quote');
108
+ copyDir(skillSrc, skillDest);
109
+ console.log(` ✓ Skill 已安装到 ${skillDest}`);
110
+ }
111
+
112
+ function copyDir(src, dest) {
113
+ fs.mkdirSync(dest, { recursive: true });
114
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
115
+ const srcPath = path.join(src, entry.name);
116
+ const destPath = path.join(dest, entry.name);
117
+ if (entry.isDirectory()) copyDir(srcPath, destPath);
118
+ else fs.copyFileSync(srcPath, destPath);
119
+ }
120
+ }
121
+
122
+ async function stepLogin() {
123
+ try {
124
+ runSilent('quote', ['whoami'], { timeout: 5000 });
125
+ console.log(' ✓ 已登录,跳过');
126
+ return;
127
+ } catch {
128
+ // 未登录,继续
129
+ }
130
+
131
+ console.log('');
132
+ console.log(' 请登录 casstime 账号(执行 quote login)');
133
+ try {
134
+ execCmd('quote', ['login'], { stdio: 'inherit', timeout: 120000 });
135
+ } catch {
136
+ console.error(' ✗ 登录失败,稍后可手动执行: quote login');
137
+ }
138
+ }
139
+
140
+ export function registerInstallCommand(program) {
141
+ program
142
+ .command('install')
143
+ .description('安装向导:全局安装 CLI + Skill + 登录(推荐首次使用)')
144
+ .option('--skip-login', '跳过登录步骤')
145
+ .action(async (opts) => {
146
+ console.log('\ncass-quote 安装向导\n');
147
+
148
+ console.log('1/3 CLI');
149
+ await stepInstallCli();
150
+
151
+ console.log('\n2/3 Skill(Claude Code 集成)');
152
+ installSkill();
153
+
154
+ if (!opts.skipLogin) {
155
+ console.log('\n3/3 登录');
156
+ await stepLogin();
157
+ }
158
+
159
+ console.log('\n安装完成!');
160
+ console.log(' 现在可以对 Claude Code 说:「帮我创建一条询价单」\n');
161
+ });
162
+ }
@@ -4,7 +4,7 @@
4
4
  import { loginWithPassword, loginWithCellphone, sendLoginCode, sendRegisterCode, registerUser, saveCompanyInfo, fetchAreas, checkAccountExists, isLoggedIn, logout } from '../auth.mjs';
5
5
  import { Store } from '../store.mjs';
6
6
  import { createInterface } from 'readline';
7
- import { API_BASE_DEFAULT, TEST_PHONE } from '../constants.mjs';
7
+ import { API_BASE_DEFAULT } from '../constants.mjs';
8
8
 
9
9
  export function registerLoginCommands(program) {
10
10
  program
@@ -15,7 +15,6 @@ export function registerLoginCommands(program) {
15
15
  .option('--sms', '使用短信验证码登录')
16
16
  .option('-c, --code <code>', '验证码(配合 --sms 非交互登录:先 --send-code 发码,再带 --code 传入)')
17
17
  .option('--send-code', '仅发送登录验证码,不等待输入(配合 --sms -u 使用)')
18
- .option('--test', `测试模式:使用测试账号 ${TEST_PHONE} 发送验证码(短信打到团队群)`)
19
18
  .option('--api-base <url>', 'API 地址', API_BASE_DEFAULT)
20
19
  .action(async (opts) => {
21
20
  const store = new Store();
@@ -30,22 +29,13 @@ export function registerLoginCommands(program) {
30
29
  try {
31
30
  let tokenData;
32
31
 
33
- if (opts.test || opts.sms) {
32
+ if (opts.sms) {
34
33
  // 短信验证码登录
35
34
  const savedPhone = config.cellphone || '';
36
- const cellphone = opts.test
37
- ? TEST_PHONE
38
- : opts.username
35
+ const cellphone = opts.username
39
36
  || await ask(savedPhone ? `手机号 [${savedPhone}]: ` : '手机号: ').then(v => v.trim() || savedPhone);
40
37
 
41
- if (!cellphone) { console.error('✗ 请输入手机号'); rl.close(); process.exit(1); }
42
-
43
- if (opts.test) {
44
- console.log(`[测试模式] 使用测试账号: ${TEST_PHONE}`);
45
- console.log(`[测试模式] 验证码将发送到团队群,请在群里查收`);
46
- }
47
-
48
- process.stdout.write('正在检查账号...');
38
+ if (!cellphone) { console.error('✗ 请输入手机号'); rl.close(); process.exit(1); } process.stdout.write('正在检查账号...');
49
39
  const exists = await checkAccountExists(apiBase, cellphone);
50
40
  if (!exists) {
51
41
  console.log('');
@@ -1,45 +1,253 @@
1
+ import { createInterface } from 'readline';
1
2
  import { getAdapter } from '../adapter/index.mjs';
3
+ import { Store } from '../store.mjs';
4
+
5
+ // ── 交互工具 ────────────────────────────────────────────────
6
+
7
+ function makeRl() {
8
+ return createInterface({ input: process.stdin, output: process.stdout });
9
+ }
10
+
11
+ /** 展示列表并让用户输入序号,返回选中项(序号从 1 开始)*/
12
+ async function selectFromList(items, renderFn, prompt) {
13
+ items.forEach((item, i) => {
14
+ process.stdout.write(` ${String(i + 1).padStart(2)}. ${renderFn(item, i)}\n`);
15
+ });
16
+ const rl = makeRl();
17
+ const ask = (q) => new Promise((r) => rl.question(q, r));
18
+ const idx = parseInt(await ask(prompt), 10) - 1;
19
+ rl.close();
20
+ const selected = items[idx];
21
+ if (!selected) { console.error('✗ 无效选择'); process.exit(1); }
22
+ return selected;
23
+ }
24
+
25
+ // ── 命令注册 ─────────────────────────────────────────────────
2
26
 
3
27
  export function registerOrderCommands(program) {
4
28
  const order = program.command('order').description('订单管理');
5
29
 
30
+ // ── order confirm ────────────────────────────────────────
6
31
  order
7
32
  .command('confirm')
8
33
  .description('确认下单(选择某个报价下单)')
9
34
  .requiredOption('-i, --inquiry <id>', '询价单 ID')
10
- .requiredOption('-r, --reply <id>', '报价 ID')
35
+ .option('-r, --reply <id>', '报价 ID(quotationProductId);不传则从报价列表中交互式选择')
36
+ .option('-a, --address-id <id>', '收货地址 ID(不传则交互式选择)')
37
+ .option('-l, --logistics <code>', '物流公司 code(不传则交互式选择;传 default 自动用推荐物流)')
11
38
  .action(async (opts) => {
12
39
  const adapter = getAdapter();
13
- const result = await adapter.confirmOrder(opts.inquiry, opts.reply);
14
40
 
15
- if (!result) {
16
- console.error(`下单失败:请检查询价单 ${opts.inquiry} 和报价 ${opts.reply} 是否存在且匹配`);
41
+ // ── 0. 确定报价 ────────────────────────────────────
42
+ let replyId = opts.reply || '';
43
+ if (!replyId) {
44
+ console.log('\n正在获取报价列表...');
45
+ const replies = await adapter.listReplies(opts.inquiry);
46
+ if (replies.length === 0) {
47
+ console.error('✗ 该询价单暂无报价,请等待供应商报价后再下单');
48
+ process.exit(1);
49
+ }
50
+ if (replies.length === 1) {
51
+ replyId = replies[0].id;
52
+ console.log(` 已选报价: ${replies[0].supplier} CNY ${replies[0].price} ${replies[0].partNum || ''}`);
53
+ } else {
54
+ console.log('\n可用报价:');
55
+ const chosen = await selectFromList(
56
+ replies,
57
+ (r) => {
58
+ const supplier = r.supplier.padEnd(14);
59
+ const price = `CNY ${String(r.price).padEnd(8)}`;
60
+ const part = r.partNum ? r.partNum.padEnd(16) : ''.padEnd(16);
61
+ const brand = r.brand ? `${r.brand} ` : '';
62
+ const loc = r.location || r.facilityId || '';
63
+ return `${supplier} ${price} ${part} ${brand}${loc}`;
64
+ },
65
+ `选择报价 (1-${replies.length}): `
66
+ );
67
+ replyId = chosen.id;
68
+ }
69
+ }
70
+
71
+ // ── 1. 确定收货地址 ────────────────────────────────
72
+ let addressId = opts.addressId || '';
73
+ if (!addressId) {
74
+ const addresses = await adapter.listAddresses();
75
+ if (addresses.length === 0) {
76
+ console.error('✗ 未找到收货地址,请先在 casstime App / 网页端添加,或用 --address-id 指定');
77
+ process.exit(1);
78
+ }
79
+ if (addresses.length === 1) {
80
+ addressId = addresses[0].id;
81
+ console.log(` 收货地址: ${addresses[0].receiverName} ${addresses[0].address}`);
82
+ } else {
83
+ console.log('\n收货地址:');
84
+ const chosen = await selectFromList(
85
+ addresses,
86
+ (a) => `${a.receiverName.padEnd(10)} ${a.contactNumber.padEnd(13)} ${a.address}`,
87
+ `选择地址 (1-${addresses.length}): `
88
+ );
89
+ addressId = chosen.id;
90
+ }
91
+ }
92
+
93
+ // ── 2. 生成结算预览(purchase_confirm + tosettle + INIT)──
94
+ console.log('\n正在生成结算单,请稍候...');
95
+ let preview;
96
+ try {
97
+ preview = await adapter.previewSettle(opts.inquiry, replyId, addressId);
98
+ } catch (err) {
99
+ console.error(`✗ 生成结算单失败:${err.message}`);
100
+ process.exit(1);
101
+ }
102
+
103
+ const { logisticsOptions, totalPrice } = preview;
104
+
105
+ // ── 3. 确定物流 ─────────────────────────────────────
106
+ let logisticsCode = opts.logistics || '';
107
+
108
+ if (!logisticsCode && logisticsOptions.length === 0) {
109
+ // 没有物流选项,直接静默使用空(服务端兜底)
110
+ logisticsCode = '';
111
+ } else if (!logisticsCode) {
112
+ // 交互式选择
113
+ console.log('\n可用物流:');
114
+ const defaultIdx = logisticsOptions.findIndex(o => o.isDefault);
115
+ const chosen = await selectFromList(
116
+ logisticsOptions,
117
+ (o, i) => {
118
+ const tag = o.isDefault ? '★推荐 ' : ' ';
119
+ const shift = o.shift ? ` ${o.shift}` : '';
120
+ return `${tag}${o.name.padEnd(12)} ${o.deliver}${shift}`;
121
+ },
122
+ `选择物流 (1-${logisticsOptions.length})${defaultIdx >= 0 ? `,直接回车使用推荐 [${defaultIdx + 1}]` : ''}: `
123
+ );
124
+ logisticsCode = chosen.code;
125
+ }
126
+
127
+ // ── 4. 提交下单 ────────────────────────────────────
128
+ console.log('\n正在提交订单...');
129
+ let result;
130
+ try {
131
+ result = await adapter.confirmOrder(opts.inquiry, replyId, {
132
+ addressId,
133
+ logisticsCode: logisticsCode === 'default' ? '' : logisticsCode,
134
+ _preview: preview, // 把已经计算好的预览直接传进去,避免重复请求
135
+ });
136
+ } catch (err) {
137
+ console.error(`✗ 下单失败:${err.message}`);
17
138
  process.exit(1);
18
139
  }
19
140
 
20
- const { order: record, inquiry, reply } = result;
21
- console.log(`✓ 订单已确认: ${record.id}`);
22
- console.log(` 询价: ${inquiry.product} (${inquiry.id})`);
23
- console.log(` 供应商: ${reply.supplier} | 价格: ${reply.currency} ${reply.price}`);
24
- console.log(JSON.stringify(record, null, 2));
141
+ const { order: record, reply } = result;
142
+ console.log(`\n✓ 下单成功`);
143
+ console.log(` 订单号: ${record.id}`);
144
+ console.log(` 供应商: ${reply.supplier}${reply.brand ? ' / ' + reply.brand : ''}`);
145
+ console.log(` 单价: CNY ${reply.price}${reply.partNum ? ' 零件号: ' + reply.partNum : ''}`);
146
+ console.log(` 应付合计: CNY ${record.totalPrice}`);
147
+ console.log(` 状态: 待付款(请前往 casstime App 或网页端完成支付)`);
25
148
  });
26
149
 
150
+ // ── order addresses ──────────────────────────────────────
27
151
  order
28
- .command('list')
29
- .description('查看所有订单')
152
+ .command('addresses')
153
+ .description('查看当前账号的收货地址列表')
30
154
  .action(async () => {
31
155
  const adapter = getAdapter();
32
- const items = await adapter.listOrders();
156
+ const items = await adapter.listAddresses();
157
+ if (items.length === 0) {
158
+ console.log('暂无收货地址,请先在 casstime App 或网页端添加');
159
+ return;
160
+ }
161
+ console.log(`共 ${items.length} 条收货地址:\n`);
162
+ for (const addr of items) {
163
+ console.log(` ${addr.id}`);
164
+ console.log(` ${addr.receiverName} ${addr.contactNumber}`);
165
+ console.log(` ${addr.address}\n`);
166
+ }
167
+ });
168
+
169
+ // ── order logistics ──────────────────────────────────────
170
+ order
171
+ .command('logistics')
172
+ .description('查询某报价的可用物流选项(用于 --logistics 参数参考)')
173
+ .requiredOption('-i, --inquiry <id>', '询价单 ID')
174
+ .option('-r, --reply <id>', '报价 ID(quotationProductId);不传则从报价列表中选第一条有效报价')
175
+ .option('-a, --address-id <id>', '收货地址 ID(不传则使用第一条地址)')
176
+ .action(async (opts) => {
177
+ const adapter = getAdapter();
178
+
179
+ // 未传 -r,自动取第一条有效报价
180
+ let replyId = opts.reply || '';
181
+ if (!replyId) {
182
+ const replies = await adapter.listReplies(opts.inquiry);
183
+ const first = replies[0];
184
+ if (!first) {
185
+ console.error('✗ 该询价单暂无报价');
186
+ process.exit(1);
187
+ }
188
+ replyId = first.id;
189
+ console.log(` 使用报价: ${first.supplier} CNY ${first.price} ${first.partNum || ''}`);
190
+ }
191
+
192
+ let addressId = opts.addressId || '';
193
+ if (!addressId) {
194
+ const addresses = await adapter.listAddresses();
195
+ if (!addresses.length) {
196
+ console.error('✗ 未找到收货地址');
197
+ process.exit(1);
198
+ }
199
+ addressId = addresses[0].id;
200
+ }
201
+
202
+ console.log('正在生成结算单以获取物流选项...');
203
+ let preview;
204
+ try {
205
+ preview = await adapter.previewSettle(opts.inquiry, replyId, addressId);
206
+ } catch (err) {
207
+ console.error(`✗ ${err.message}`);
208
+ process.exit(1);
209
+ }
210
+
211
+ const { logisticsOptions, totalPrice } = preview;
212
+ console.log(`\n应付合计: CNY ${totalPrice}\n`);
213
+
214
+ if (logisticsOptions.length === 0) {
215
+ console.log('该报价暂无可选物流(将由平台自动分配)');
216
+ return;
217
+ }
218
+
219
+ console.log(`共 ${logisticsOptions.length} 个物流选项:\n`);
220
+ for (const o of logisticsOptions) {
221
+ const tag = o.isDefault ? ' ★推荐' : '';
222
+ const shift = o.shift ? ` ${o.shift}` : '';
223
+ console.log(` ${o.code.padEnd(16)} ${o.name.padEnd(12)} ${o.deliver}${shift}${tag}`);
224
+ }
225
+ console.log(`\n使用方法: quote order confirm -i ${opts.inquiry} -r ${replyId} -a ${addressId} -l <code>`);
226
+ });
227
+
228
+ // ── order list ───────────────────────────────────────────
229
+ order
230
+ .command('list')
231
+ .description('查看订单列表')
232
+ .option('--mine', '只显示当前登录账号的订单')
233
+ .action(async (opts) => {
234
+ const adapter = getAdapter();
235
+ const filters = {};
236
+ if (opts.mine) {
237
+ const config = new Store().getConfig();
238
+ if (config.userLoginId) filters.createdBy = config.userLoginId;
239
+ }
240
+ const items = await adapter.listOrders(filters);
33
241
  if (items.length === 0) {
34
242
  console.log('暂无订单');
35
243
  return;
36
244
  }
37
245
  console.log(`共 ${items.length} 条订单:\n`);
38
246
  for (const item of items) {
39
- const product = item.inquiry ? item.inquiry.product : '?';
40
- const supplier = item.reply ? item.reply.supplier : '?';
41
- const price = item.reply ? `${item.reply.currency} ${item.reply.price}` : '?';
42
- console.log(` ${item.id} | ${product} | ${supplier} | ${price} | ${item.confirmedAt}`);
247
+ const product = item.inquiry?.product ?? '?';
248
+ const supplier = item.reply?.supplier ?? '?';
249
+ const price = item.reply ? `CNY ${item.reply.price}` : '?';
250
+ console.log(` ${item.id} ${product} ${supplier} ${price} ${item.statusDesc || item.status} ${item.confirmedAt}`);
43
251
  }
44
252
  });
45
253
  }
package/src/constants.mjs CHANGED
@@ -7,5 +7,6 @@ export const API_BASE_DEFAULT = 'https://ec-hwbeta.casstime.com/terminal-api-v2'
7
7
  // 模拟移动端 UA,用于通过 API 网关版本校验
8
8
  export const APP_USER_AGENT = 'cassapp/7.9.0.0 iOS/26.5 Apple/iPhone 13';
9
9
 
10
- // 测试模式账号 — 短信发到团队群里(beta 环境已注册)
11
- export const TEST_PHONE = '18162213812';
10
+ // token 刷新策略
11
+ // 距过期不足此时间(毫秒)时触发后台预刷新,本次请求仍使用旧 token
12
+ export const TOKEN_REFRESH_AHEAD_MS = 5 * 60 * 1000; // 5 分钟