@dalehkx/quote-cli 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/quote.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { createRequire } from 'node:module';
3
4
  import { Command } from 'commander';
4
5
  import { registerInquiryCommands } from '../src/commands/inquiry.mjs';
5
6
  import { registerReplyCommands } from '../src/commands/reply.mjs';
@@ -8,12 +9,23 @@ import { registerOrderCommands } from '../src/commands/order.mjs';
8
9
  import { registerConfigCommands } from '../src/commands/config.mjs';
9
10
  import { registerLoginCommands } from '../src/commands/login.mjs';
10
11
 
12
+ const { version } = createRequire(import.meta.url)('../package.json');
13
+
11
14
  const program = new Command();
12
15
 
13
16
  program
14
17
  .name('quote')
15
18
  .description('通用询报价 CLI 工具')
16
- .version('0.1.0');
19
+ .version(version);
20
+
21
+ registerInquiryCommands(program);
22
+ registerReplyCommands(program);
23
+ registerCompareCommand(program);
24
+ registerOrderCommands(program);
25
+ registerConfigCommands(program);
26
+ registerLoginCommands(program);
27
+
28
+ program.parse();
17
29
 
18
30
  registerInquiryCommands(program);
19
31
  registerReplyCommands(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dalehkx/quote-cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "通用询报价 CLI 工具",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@ import { Store } from '../store.mjs';
9
9
  export class ApiAdapter {
10
10
  constructor(config = {}) {
11
11
  this.baseUrl = config.apiBase || '';
12
+ this._store = new Store(); // reads QUOTE_CONFIG_DIR when set, otherwise ~/.quote
12
13
  }
13
14
 
14
15
  async _headers() {
@@ -22,7 +23,7 @@ export class ApiAdapter {
22
23
 
23
24
  async _request(method, path, body, query = {}) {
24
25
  if (!this.baseUrl) {
25
- throw new Error('API 模式未配置 apiBase,请执行: quote config set --mode api --api-base <url>');
26
+ throw new Error('未配置 apiBase,请执行: quote config set --api-base <url>');
26
27
  }
27
28
 
28
29
  const base = this.baseUrl.endsWith('/') ? this.baseUrl : `${this.baseUrl}/`;
@@ -46,10 +47,6 @@ export class ApiAdapter {
46
47
  return json.data !== undefined ? json.data : json;
47
48
  }
48
49
 
49
- _getStore() {
50
- return new Store();
51
- }
52
-
53
50
  // ─── Inquiry ─────────────────────────────────────────────
54
51
 
55
52
  /**
@@ -86,11 +83,11 @@ export class ApiAdapter {
86
83
 
87
84
  /**
88
85
  * 创建询价
89
- * 优先走 simple_inquiry(自由文本,无需 VIN 和手机号)
90
- * 若账号不支持(654)则自动降级到 POST /inquiries
86
+ * isSimpleInquiryAllowed=true simple_inquiry(自由文本)
87
+ * 否则走 POST /inquiries(标准流程)
91
88
  */
92
89
  async createInquiry(data) {
93
- const config = this._getStore().getConfig();
90
+ const config = this._store.getConfig();
94
91
 
95
92
  let carBrandId = data.carBrandId || '';
96
93
  let carBrandName = data.carBrandName || data.vehicle || '';
@@ -110,86 +107,69 @@ export class ApiAdapter {
110
107
  ? data.products
111
108
  : [data.product];
112
109
 
113
- const simpleBody = {
114
- vin: data.vin || '',
115
- carBrandId,
116
- carBrandName,
117
- carModelName,
118
- userName: config.userLoginId || '',
119
- source: 'ANDROID',
120
- qualities: data.qualities || ['BRAND'],
121
- isOpenInvoice: false,
122
- isAnonymous: false,
123
- provinceGeoId: config.provinceGeoId || '',
124
- cityGeoId: config.cityGeoId || '',
125
- countyGeoId: config.countyGeoId || '',
126
- provinceGeoName: config.provinceGeoName || '',
127
- cityGeoName: config.cityGeoName || '',
128
- countyGeoName: config.countyGeoName || '',
129
- garageCompanyName: config.companyName || '',
130
- // 多配件:每个 product 一个 item
131
- simpleInquiryBatchItems: products.map((name, i) => ({
132
- content: name,
133
- mediaType: 'TEXT',
134
- itemNum: i + 1,
135
- description: i === 0 ? [
136
- data.oeNumber ? `OE: ${data.oeNumber}` : '',
137
- data.note || '',
138
- ].filter(Boolean).join(' ') : '',
139
- })),
140
- };
141
-
142
110
  let result;
143
- try {
111
+
112
+ if (config.isSimpleInquiryAllowed) {
113
+ const simpleBody = {
114
+ vin: data.vin || '',
115
+ carBrandId,
116
+ carBrandName,
117
+ carModelName,
118
+ userName: config.userLoginId || '',
119
+ source: 'ANDROID',
120
+ qualities: data.qualities || ['BRAND'],
121
+ isOpenInvoice: false,
122
+ isAnonymous: false,
123
+ provinceGeoId: config.provinceGeoId || '',
124
+ cityGeoId: config.cityGeoId || '',
125
+ countyGeoId: config.countyGeoId || '',
126
+ provinceGeoName: config.provinceGeoName || '',
127
+ cityGeoName: config.cityGeoName || '',
128
+ countyGeoName: config.countyGeoName || '',
129
+ garageCompanyName: config.companyName || '',
130
+ simpleInquiryBatchItems: products.map((name, i) => ({
131
+ content: name,
132
+ mediaType: 'TEXT',
133
+ itemNum: i + 1,
134
+ description: i === 0 ? [
135
+ data.oeNumber ? `OE: ${data.oeNumber}` : '',
136
+ data.note || '',
137
+ ].filter(Boolean).join(' ') : '',
138
+ })),
139
+ };
144
140
  result = await this._request('POST', '/inquiries/simple_inquiry', simpleBody);
145
- } catch (e) {
146
- // 654 = 未认证不允许简易询价
147
- // 999 = beta 环境 simple_inquiry 服务不稳定
148
- // 两种情况均降级到 POST /inquiries
149
- if (e.errorCode === 654 || e.errorCode === 999) {
150
- // 先检查剩余次数,给出明确错误
151
- try {
152
- const quota = await this._request('GET', '/inquiries/remain_inquiry_number');
153
- if (quota && quota.inquiryRemainNumber === 0) {
154
- throw new Error('询价次数已用完,请完成企业认证后继续使用: https://ec-hwbeta.casstime.com');
155
- }
156
- } catch (qe) {
157
- if (qe.message.includes('询价次数')) throw qe;
158
- }
159
- const fullBody = {
160
- vin: data.vin || 'UNKNOWN00000000000',
161
- carBrandId,
162
- carBrandName,
163
- carModelName,
164
- userName: config.userLoginId || '',
165
- contactNumber: config.cellphone || '',
166
- isOpenInvoice: false,
167
- source: 'ANDROID',
168
- isSelectBrandFlag: false,
169
- isAnonymous: false,
170
- qualities: data.qualities || ['BRAND'],
171
- provinceGeoId: config.provinceGeoId || '',
172
- cityGeoId: config.cityGeoId || '',
173
- countyGeoId: config.countyGeoId || '',
174
- provinceGeoName: config.provinceGeoName || '',
175
- cityGeoName: config.cityGeoName || '',
176
- countyGeoName: config.countyGeoName || '',
177
- userNeeds: products.map((name, i) => ({
178
- needsName: name,
179
- quantity: i === 0 ? (Number(data.quantity) || 1) : 1,
180
- isFastOe: false,
181
- isSuggest: false,
182
- imageUrls: [],
183
- originalNeed: name,
184
- inquirySource: 'MANUALLY',
185
- oeCode: i === 0 ? (data.oeNumber || '') : '',
186
- remark: i === 0 ? (data.note || '') : '',
187
- })),
188
- };
189
- result = await this._request('POST', '/inquiries', fullBody);
190
- } else {
191
- throw e;
192
- }
141
+ } else {
142
+ const fullBody = {
143
+ vin: data.vin || 'UNKNOWN00000000000',
144
+ carBrandId,
145
+ carBrandName,
146
+ carModelName,
147
+ userName: config.userLoginId || '',
148
+ contactNumber: config.cellphone || '',
149
+ isOpenInvoice: false,
150
+ source: 'ANDROID',
151
+ isSelectBrandFlag: false,
152
+ isAnonymous: false,
153
+ qualities: data.qualities || ['BRAND'],
154
+ provinceGeoId: config.provinceGeoId || '',
155
+ cityGeoId: config.cityGeoId || '',
156
+ countyGeoId: config.countyGeoId || '',
157
+ provinceGeoName: config.provinceGeoName || '',
158
+ cityGeoName: config.cityGeoName || '',
159
+ countyGeoName: config.countyGeoName || '',
160
+ userNeeds: products.map((name, i) => ({
161
+ needsName: name,
162
+ quantity: i === 0 ? (Number(data.quantity) || 1) : 1,
163
+ isFastOe: false,
164
+ isSuggest: false,
165
+ imageUrls: [],
166
+ originalNeed: name,
167
+ inquirySource: 'MANUALLY',
168
+ oeCode: i === 0 ? (data.oeNumber || '') : '',
169
+ remark: i === 0 ? (data.note || '') : '',
170
+ })),
171
+ };
172
+ result = await this._request('POST', '/inquiries', fullBody);
193
173
  }
194
174
 
195
175
  return {
@@ -229,7 +209,9 @@ export class ApiAdapter {
229
209
  });
230
210
 
231
211
  const items = result.content || result || [];
232
- return Array.isArray(items) ? items.map(this._mapInquiryItem) : [];
212
+ if (!Array.isArray(items) || items.length === 0) return [];
213
+
214
+ return items.map(item => this._mapInquiryItem(item));
233
215
  }
234
216
 
235
217
  /**
@@ -244,48 +226,63 @@ export class ApiAdapter {
244
226
 
245
227
  /**
246
228
  * 关闭询价单
229
+ * 平台未提供关闭 API,暂不支持
247
230
  */
248
- async closeInquiry(id) {
249
- try {
250
- await this._request('POST', '/inquiries/status', { inquiryId: id, status: 'CLOSED' });
251
- return { id, status: 'closed' };
252
- } catch (e) {
253
- throw new Error(`关闭询价单失败: ${e.message}`);
254
- }
231
+ async closeInquiry(_id) {
232
+ throw new Error('平台暂不支持通过 API 关闭询价单,请前往 casstime APP 或网页端操作');
233
+ }
234
+
235
+ /**
236
+ * 批量查询询价单状态(比 detailV2 更轻量,用于 watch 阶段一)
237
+ * 返回 { inquiryId, status, rawStatusId }
238
+ */
239
+ async pollInquiryStatus(inquiryId) {
240
+ const result = await this._request('POST', '/inquiries/status',
241
+ [{ inquiryId }]
242
+ );
243
+ const item = Array.isArray(result) ? result[0] : null;
244
+ if (!item) return null;
245
+ return {
246
+ inquiryId,
247
+ status: mapStatus(item.inquiryStatus || ''),
248
+ rawStatusId: item.inquiryStatus || '',
249
+ };
255
250
  }
256
251
 
257
252
  // ─── Reply(报价结果)────────────────────────────────────
258
253
 
254
+ /**
255
+ * 从 detailV2 原始数据中提取报价列表(避免重复请求)
256
+ */
257
+ async _fetchRepliesFromDetail(inquiryId, detail) {
258
+ const stores = detail.inquiryQuoteStores || [];
259
+ if (stores.length === 0) return [];
260
+
261
+ const results = await Promise.all(
262
+ stores.map(async (store) => {
263
+ try {
264
+ const result = await this._request('GET', '/inquiries/store/quotation', null, {
265
+ inquiryId,
266
+ storeId: store.storeId,
267
+ });
268
+ return (result.consultingQuotationProducts || []).map(this._mapQuotationProduct);
269
+ } catch {
270
+ return [];
271
+ }
272
+ })
273
+ );
274
+ return results.flat();
275
+ }
276
+
259
277
  /**
260
278
  * 获取某询价单的报价列表
261
- * 先从询价单详情里取已报价的 storeId,再逐一查报价
262
279
  */
263
280
  async listReplies(inquiryId) {
264
281
  try {
265
- // 从详情里取已报价的供应商列表
266
282
  const detail = await this._request('GET', `/inquiries/${inquiryId}/detailV2`, null, {
267
283
  platform: 'ANDROID',
268
284
  });
269
-
270
- const stores = detail.inquiryQuoteStores || [];
271
- if (stores.length === 0) return [];
272
-
273
- // 逐一查每家供应商的报价
274
- const results = await Promise.all(
275
- stores.map(async (store) => {
276
- try {
277
- const result = await this._request('GET', '/inquiries/store/quotation', null, {
278
- inquiryId,
279
- storeId: store.storeId,
280
- });
281
- return (result.consultingQuotationProducts || []).map(this._mapQuotationProduct);
282
- } catch {
283
- return [];
284
- }
285
- })
286
- );
287
-
288
- return results.flat();
285
+ return await this._fetchRepliesFromDetail(inquiryId, detail);
289
286
  } catch {
290
287
  return [];
291
288
  }
@@ -301,13 +298,14 @@ export class ApiAdapter {
301
298
  // ─── Compare ─────────────────────────────────────────────
302
299
 
303
300
  /**
304
- * 比价 — 获取报价并排序
301
+ * 比价 — 一次 detailV2 同时拿询价信息和报价列表,避免重复请求
305
302
  */
306
303
  async compareReplies(inquiryId, sort = 'price') {
307
- const inquiry = await this.getInquiry(inquiryId);
308
- const replies = await this.listReplies(inquiryId);
309
-
310
- if (!inquiry) return null;
304
+ const detail = await this._request('GET', `/inquiries/${inquiryId}/detailV2`, null, {
305
+ platform: 'ANDROID',
306
+ });
307
+ const inquiry = this._mapInquiryDetail(inquiryId, detail);
308
+ const replies = await this._fetchRepliesFromDetail(inquiryId, detail);
311
309
 
312
310
  const sorted = [...replies].sort((a, b) => {
313
311
  if (sort === 'price') return a.price - b.price;
@@ -360,16 +358,6 @@ export class ApiAdapter {
360
358
  }
361
359
  }
362
360
 
363
- // ─── Config ──────────────────────────────────────────────
364
-
365
- async getConfig() {
366
- return this._getStore().getConfig();
367
- }
368
-
369
- async setConfig(updates) {
370
- return this._getStore().setConfig(updates);
371
- }
372
-
373
361
  // ─── 数据映射 ────────────────────────────────────────────
374
362
 
375
363
  _mapInquiryItem(item) {
@@ -1,24 +1,20 @@
1
1
  import { Store } from '../store.mjs';
2
- import { LocalAdapter } from './local.mjs';
3
2
  import { ApiAdapter } from './api.mjs';
3
+ import { API_BASE_DEFAULT } from '../constants.mjs';
4
4
 
5
5
  let _adapter = null;
6
6
 
7
7
  /**
8
- * 获取数据适配器(单例)
9
- * 根据 config.mode 决定使用本地存储还是远程 API
8
+ * 获取 API 适配器(单例)
10
9
  */
11
10
  export function getAdapter() {
12
11
  if (_adapter) return _adapter;
13
-
14
- const store = new Store();
15
- const config = store.getConfig();
16
-
17
- if (config.mode === 'api') {
18
- _adapter = new ApiAdapter(config);
19
- } else {
20
- _adapter = new LocalAdapter();
21
- }
22
-
12
+ const config = new Store().getConfig();
13
+ _adapter = new ApiAdapter({ apiBase: config.apiBase || API_BASE_DEFAULT });
23
14
  return _adapter;
24
15
  }
16
+
17
+ /** 重置单例(供测试使用) */
18
+ export function resetAdapter() {
19
+ _adapter = null;
20
+ }
package/src/auth.mjs CHANGED
@@ -258,15 +258,15 @@ export function isLoggedIn() {
258
258
  }
259
259
 
260
260
  /**
261
- * 登出(清除 token
261
+ * 登出(仅清除认证 token,保留账号信息用于下次登录预填)
262
262
  */
263
263
  export function logout() {
264
264
  getStore().setConfig({
265
- accessToken: undefined,
266
- refreshToken: undefined,
267
- tokenType: undefined,
268
- tokenExpiresAt: undefined,
269
- userLoginId: undefined,
265
+ accessToken: null,
266
+ refreshToken: null,
267
+ tokenType: null,
268
+ tokenExpiresAt: null,
269
+ // 保留 cellphone / userLoginId,方便下次登录时预填账号
270
270
  });
271
271
  }
272
272
 
@@ -285,15 +285,16 @@ async function fetchAndCacheUserInfo(apiBase) {
285
285
  if (json.errorCode !== 0) return;
286
286
  const u = json.data || json;
287
287
  getStore().setConfig({
288
- companyName: u.companyName || u.displayName || '',
289
- garageCompanyId: u.garageCompanyId || '',
290
- cellphone: u.cellphone || '',
291
- provinceGeoId: u.provinceGeoId || '',
292
- cityGeoId: u.cityGeoId || '',
293
- countyGeoId: u.countyGeoId || '',
294
- provinceGeoName: u.provinceGeoName || '',
295
- cityGeoName: u.cityGeoName || '',
296
- countyGeoName: u.countyGeoName || '',
288
+ companyName: u.companyName || u.displayName || '',
289
+ garageCompanyId: u.garageCompanyId || '',
290
+ cellphone: u.cellphone || '',
291
+ provinceGeoId: u.provinceGeoId || '',
292
+ cityGeoId: u.cityGeoId || '',
293
+ countyGeoId: u.countyGeoId || '',
294
+ provinceGeoName: u.provinceGeoName || '',
295
+ cityGeoName: u.cityGeoName || '',
296
+ countyGeoName: u.countyGeoName || '',
297
+ isSimpleInquiryAllowed: u.isSimpleInquiryAllowed === true,
297
298
  });
298
299
  } catch {
299
300
  // 拉取失败不影响登录流程
@@ -1,4 +1,7 @@
1
- import { getAdapter } from '../adapter/index.mjs';
1
+ import { Store } from '../store.mjs';
2
+
3
+ // token 类字段脱敏显示
4
+ const SENSITIVE_KEYS = new Set(['accessToken', 'refreshToken', 'tokenType', 'tokenExpiresAt']);
2
5
 
3
6
  export function registerConfigCommands(program) {
4
7
  const config = program.command('config').description('配置管理');
@@ -10,40 +13,42 @@ export function registerConfigCommands(program) {
10
13
  .option('-n, --name <name>', '姓名/联系人')
11
14
  .option('-c, --company <company>', '公司名称')
12
15
  .option('--phone <phone>', '联系电话')
13
- .option('-m, --mode <mode>', '数据模式 (local|api)')
14
- .option('--api-base <url>', 'API 基础地址 (api 模式)')
15
- .option('--api-token <token>', 'API Token (api 模式)')
16
- .action(async (opts) => {
17
- const adapter = getAdapter();
16
+ .option('--api-base <url>', 'API 基础地址')
17
+ .action((opts) => {
18
+ const store = new Store();
18
19
  const updates = {};
19
- if (opts.role) updates.role = opts.role;
20
- if (opts.name) updates.name = opts.name;
20
+ if (opts.role) updates.role = opts.role;
21
+ if (opts.name) updates.name = opts.name;
21
22
  if (opts.company) updates.company = opts.company;
22
- if (opts.phone) updates.phone = opts.phone;
23
- if (opts.mode) updates.mode = opts.mode;
23
+ if (opts.phone) updates.phone = opts.phone;
24
24
  if (opts.apiBase) updates.apiBase = opts.apiBase;
25
- if (opts.apiToken) updates.apiToken = opts.apiToken;
26
25
 
27
26
  if (Object.keys(updates).length === 0) {
28
27
  console.error('请至少指定一个配置项');
29
28
  process.exit(1);
30
29
  }
31
30
 
32
- const result = await adapter.setConfig(updates);
33
- console.log('✓ 配置已更新:');
34
- console.log(JSON.stringify(result, null, 2));
31
+ store.setConfig(updates);
32
+ console.log('✓ 配置已更新');
35
33
  });
36
34
 
37
35
  config
38
36
  .command('show')
39
37
  .description('查看当前配置')
40
- .action(async () => {
41
- const adapter = getAdapter();
42
- const cfg = await adapter.getConfig();
38
+ .action(() => {
39
+ const store = new Store();
40
+ const cfg = store.getConfig();
43
41
  if (Object.keys(cfg).length === 0) {
44
- console.log('暂无配置,使用 quote config set 进行设置');
42
+ console.log('暂无配置,执行 quote login 登录后自动初始化');
45
43
  return;
46
44
  }
47
- console.log(JSON.stringify(cfg, null, 2));
45
+ // token 类字段只显示是否已设置,避免泄露
46
+ const display = Object.fromEntries(
47
+ Object.entries(cfg).map(([k, v]) => [
48
+ k,
49
+ SENSITIVE_KEYS.has(k) ? (v ? '[已设置]' : '[未设置]') : v,
50
+ ])
51
+ );
52
+ console.log(JSON.stringify(display, null, 2));
48
53
  });
49
54
  }
@@ -127,60 +127,87 @@ export function registerInquiryCommands(program) {
127
127
  .option('--exit-on-quotes <n>', '收到 n 条报价后自动退出', '0')
128
128
  .option('--json', '以 JSON 格式输出结果(适合脚本/AI 调用)')
129
129
  .action(async (id, opts) => {
130
- const adapter = getAdapter();
131
- const interval = Math.max(10, parseInt(opts.interval, 10)) * 1000;
132
- const timeout = parseInt(opts.timeout, 10) * 1000;
133
- const exitOnQuotes = parseInt(opts.exitOnQuotes, 10);
134
- const startTime = Date.now();
130
+ const adapter = getAdapter();
131
+ const interval = Math.max(10, parseInt(opts.interval, 10)) * 1000;
132
+ const timeout = parseInt(opts.timeout, 10) * 1000;
133
+ const exitOnQuotes = parseInt(opts.exitOnQuotes, 10);
134
+ const startTime = Date.now();
135
135
 
136
136
  if (!opts.json) {
137
137
  console.log(`监听询价单 ${id},每 ${interval / 1000} 秒检查一次,Ctrl+C 退出\n`);
138
138
  }
139
139
 
140
- let lastQuoteCount = 0;
141
- let lastStatus = '';
140
+ // 阶段一:记录上一次的状态和报价数,用于判断是否需要进入阶段二
141
+ let lastRawStatusId = '';
142
+ let lastQuoteCount = 0;
143
+
144
+ /**
145
+ * 阶段二:调 detailV2 拉完整报价,输出给用户
146
+ * 返回本次报价数量
147
+ */
148
+ const fetchAndReport = async () => {
149
+ const record = await adapter.getInquiry(id);
150
+ const replies = await adapter.listReplies(id);
151
+ const count = replies.length;
152
+ const time = new Date().toLocaleTimeString();
153
+
154
+ if (opts.json) {
155
+ console.log(JSON.stringify({ inquiryId: id, status: record.status, quotes: replies }));
156
+ } else {
157
+ console.log(`\n[${time}] 🔔 有报价!共 ${count} 条`);
158
+ replies.slice(lastQuoteCount).forEach(r => {
159
+ console.log(` ${r.supplier} | ¥${r.price} | ${r.brand || '-'} | ${r.delivery ? r.delivery + '天' : '货期未知'}`);
160
+ });
161
+ }
162
+ return count;
163
+ };
142
164
 
165
+ /**
166
+ * 阶段一:轻量轮询,只查状态
167
+ */
143
168
  const check = async () => {
144
169
  try {
145
- const record = await adapter.getInquiry(id);
146
- const replies = await adapter.listReplies(id);
147
- const count = replies.length;
148
- const time = new Date().toLocaleTimeString();
149
-
150
170
  // 超时退出
151
171
  if (timeout > 0 && Date.now() - startTime >= timeout) {
152
- if (opts.json) {
153
- console.log(JSON.stringify({ inquiryId: id, status: record.status, quotes: replies, timedOut: true }));
154
- } else {
155
- console.log(`\n[${time}] 等待超时,共 ${count} 条报价`);
156
- }
172
+ if (!opts.json) console.log(`\n等待超时,共 ${lastQuoteCount} 条报价`);
173
+ else console.log(JSON.stringify({ inquiryId: id, timedOut: true, quoteCount: lastQuoteCount }));
157
174
  process.exit(0);
158
175
  }
159
176
 
160
- // 达到目标报价数退出
161
- if (exitOnQuotes > 0 && count >= exitOnQuotes) {
162
- if (opts.json) {
163
- console.log(JSON.stringify({ inquiryId: id, status: record.status, quotes: replies }));
164
- } else {
165
- console.log(`\n[${time}] 已收到 ${count} 条报价,退出监听`);
166
- replies.forEach(r => console.log(` ${r.supplier} | ¥${r.price} | ${r.brand || '-'} | ${r.delivery ? r.delivery + '天' : '货期未知'}`));
177
+ const poll = await adapter.pollInquiryStatus(id);
178
+ if (!poll) return;
179
+
180
+ const time = new Date().toLocaleTimeString();
181
+ const statusChanged = poll.rawStatusId !== lastRawStatusId;
182
+
183
+ // 首次检查,或状态变成"已报价",或已经处于报价状态(可能有新供应商加入)
184
+ const shouldFetchDetail =
185
+ lastRawStatusId === '' || // 首次
186
+ (statusChanged && poll.status !== 'pending') || // 状态跳变到有报价
187
+ poll.status === 'quoted'; // 持续处于报价状态,可能新增
188
+
189
+ if (shouldFetchDetail) {
190
+ const count = await fetchAndReport();
191
+
192
+ if (count > lastQuoteCount && !opts.json) {
193
+ // 已在 fetchAndReport 里打印了新报价,这里只更新计数
167
194
  }
168
- process.exit(0);
169
- }
170
195
 
171
- if (count > lastQuoteCount) {
172
- if (!opts.json) {
173
- console.log(`\n[${time}] 🔔 新报价!共 ${count} 条`);
174
- replies.slice(lastQuoteCount).forEach(r => {
175
- console.log(` ${r.supplier} | ¥${r.price} | ${r.brand || '-'} | ${r.delivery ? r.delivery + '天' : '货期未知'}`);
176
- });
196
+ lastQuoteCount = count;
197
+ lastRawStatusId = poll.rawStatusId;
198
+
199
+ // 达到目标报价数退出
200
+ if (exitOnQuotes > 0 && count >= exitOnQuotes) {
201
+ if (!opts.json) console.log(`\n已收到 ${count} 条报价,退出监听`);
202
+ process.exit(0);
177
203
  }
178
- lastQuoteCount = count;
179
- } else if (record.status !== lastStatus) {
180
- if (!opts.json) console.log(`[${time}] 状态变更: ${lastStatus || '-'} → ${record.status} 报价: ${count} 条`);
181
- lastStatus = record.status;
182
204
  } else {
183
- if (!opts.json) process.stdout.write(`\r[${time}] 等待报价... 当前 ${count} 条`);
205
+ if (statusChanged) {
206
+ if (!opts.json) console.log(`\n[${time}] 状态变更: ${lastRawStatusId || '-'} → ${poll.rawStatusId}`);
207
+ lastRawStatusId = poll.rawStatusId;
208
+ } else {
209
+ if (!opts.json) process.stdout.write(`\r[${time}] 等待报价... 状态: ${poll.rawStatusId} 已有: ${lastQuoteCount} 条`);
210
+ }
184
211
  }
185
212
  } catch (e) {
186
213
  if (!opts.json) console.error(`\n[错误] ${e.message}`);
@@ -13,6 +13,8 @@ export function registerLoginCommands(program) {
13
13
  .option('-u, --username <name>', '用户名/手机号')
14
14
  .option('-p, --password <pwd>', '密码')
15
15
  .option('--sms', '使用短信验证码登录')
16
+ .option('-c, --code <code>', '验证码(配合 --sms 非交互登录:先 --send-code 发码,再带 --code 传入)')
17
+ .option('--send-code', '仅发送登录验证码,不等待输入(配合 --sms -u 使用)')
16
18
  .option('--test', `测试模式:使用测试账号 ${TEST_PHONE} 发送验证码(短信打到团队群)`)
17
19
  .option('--api-base <url>', 'API 地址', API_BASE_DEFAULT)
18
20
  .action(async (opts) => {
@@ -20,7 +22,7 @@ export function registerLoginCommands(program) {
20
22
  const config = store.getConfig();
21
23
 
22
24
  const apiBase = opts.apiBase || config.apiBase || API_BASE_DEFAULT;
23
- store.setConfig({ apiBase, mode: 'api' });
25
+ store.setConfig({ apiBase });
24
26
 
25
27
  const rl = createInterface({ input: process.stdin, output: process.stdout });
26
28
  const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
@@ -29,7 +31,14 @@ export function registerLoginCommands(program) {
29
31
  let tokenData;
30
32
 
31
33
  if (opts.test || opts.sms) {
32
- const cellphone = opts.test ? TEST_PHONE : (opts.username || await ask('手机号: '));
34
+ // 短信验证码登录
35
+ const savedPhone = config.cellphone || '';
36
+ const cellphone = opts.test
37
+ ? TEST_PHONE
38
+ : opts.username
39
+ || await ask(savedPhone ? `手机号 [${savedPhone}]: ` : '手机号: ').then(v => v.trim() || savedPhone);
40
+
41
+ if (!cellphone) { console.error('✗ 请输入手机号'); rl.close(); process.exit(1); }
33
42
 
34
43
  if (opts.test) {
35
44
  console.log(`[测试模式] 使用测试账号: ${TEST_PHONE}`);
@@ -46,16 +55,37 @@ export function registerLoginCommands(program) {
46
55
  }
47
56
  console.log(' 已确认');
48
57
 
49
- process.stdout.write('正在发送验证码...');
50
- await sendLoginCode(apiBase, cellphone);
51
- console.log(' 已发送');
58
+ // 仅发码模式:发完直接退出,不开 readline 等待
59
+ if (opts.sendCode) {
60
+ process.stdout.write('正在发送验证码...');
61
+ await sendLoginCode(apiBase, cellphone);
62
+ console.log(' 已发送,收到后执行:');
63
+ console.log(` quote login --sms -u ${cellphone} --code <验证码>`);
64
+ rl.close();
65
+ return;
66
+ }
67
+
68
+ // 非交互模式:已提供 --code,跳过发码+输入步骤
69
+ let verifyCode;
70
+ if (opts.code) {
71
+ verifyCode = opts.code;
72
+ } else {
73
+ process.stdout.write('正在发送验证码...');
74
+ await sendLoginCode(apiBase, cellphone);
75
+ console.log(' 已发送');
52
76
 
53
- const verifyCode = await ask('验证码: ');
77
+ verifyCode = await ask('验证码: ');
78
+ }
54
79
  rl.close();
55
80
 
56
81
  tokenData = await loginWithCellphone(apiBase, cellphone, verifyCode);
57
82
  } else {
58
- const username = opts.username || await ask('账号: ');
83
+ // 账号密码登录
84
+ const savedAccount = config.cellphone || config.userLoginId || '';
85
+ const username = opts.username
86
+ || await ask(savedAccount ? `账号 [${savedAccount}]: ` : '账号: ').then(v => v.trim() || savedAccount);
87
+
88
+ if (!username) { console.error('✗ 请输入账号'); rl.close(); process.exit(1); }
59
89
 
60
90
  process.stdout.write('正在检查账号...');
61
91
  const exists = await checkAccountExists(apiBase, username);
@@ -164,7 +194,7 @@ export function registerLoginCommands(program) {
164
194
  await registerUser(apiBase, { cellphone, password, username, verificationCode: verifyCode });
165
195
  console.log('✓ 注册成功,正在自动登录...');
166
196
 
167
- store.setConfig({ apiBase, mode: 'api' });
197
+ store.setConfig({ apiBase });
168
198
  const tokenData = await loginWithPassword(apiBase, cellphone, password);
169
199
  console.log(`✓ 登录成功 用户: ${tokenData.userLoginId || username}`);
170
200
 
package/src/store.mjs CHANGED
@@ -1,89 +1,49 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
 
5
5
  /**
6
- * 本地 JSON 文件存储层
7
- * 数据存放在 ~/.quote/ 下,全局唯一,不受 cwd 影响
6
+ * 本地配置存储
7
+ * 配置文件位于 ~/.quote/config.json,全局唯一,不受 cwd 影响
8
8
  */
9
9
  export class Store {
10
10
  constructor() {
11
- this.root = join(homedir(), '.quote');
12
- this.dirs = {
13
- inquiries: join(this.root, 'inquiries'),
14
- replies: join(this.root, 'replies'),
15
- orders: join(this.root, 'orders'),
16
- };
11
+ // QUOTE_CONFIG_DIR allows tests to redirect storage without touching ~/.quote
12
+ this.root = process.env.QUOTE_CONFIG_DIR || join(homedir(), '.quote');
17
13
  this.configPath = join(this.root, 'config.json');
18
14
  }
19
15
 
20
- /** 确保数据目录存在 */
21
- init() {
22
- for (const dir of Object.values(this.dirs)) {
23
- if (!existsSync(dir)) {
24
- mkdirSync(dir, { recursive: true });
25
- }
16
+ _ensureDir() {
17
+ if (!existsSync(this.root)) {
18
+ mkdirSync(this.root, { recursive: true });
26
19
  }
27
20
  }
28
21
 
29
- // ─── 通用 CRUD ───────────────────────────────────────────
30
-
31
- _filePath(collection, id) {
32
- return join(this.dirs[collection], `${id}.json`);
33
- }
34
-
35
- save(collection, record) {
36
- this.init();
37
- const filePath = this._filePath(collection, record.id);
38
- writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf-8');
39
- return record;
40
- }
41
-
42
- get(collection, id) {
43
- const filePath = this._filePath(collection, id);
44
- if (!existsSync(filePath)) return null;
45
- return JSON.parse(readFileSync(filePath, 'utf-8'));
46
- }
47
-
48
- list(collection, filterFn = () => true) {
49
- this.init();
50
- const dir = this.dirs[collection];
51
- if (!existsSync(dir)) return [];
52
- return readdirSync(dir)
53
- .filter(f => f.endsWith('.json'))
54
- .map(f => JSON.parse(readFileSync(join(dir, f), 'utf-8')))
55
- .filter(filterFn)
56
- .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
57
- }
58
-
59
- // ─── Config ──────────────────────────────────────────────
60
-
61
22
  getConfig() {
62
- this.init();
23
+ this._ensureDir();
63
24
  if (!existsSync(this.configPath)) return {};
64
- return JSON.parse(readFileSync(this.configPath, 'utf-8'));
25
+ try {
26
+ return JSON.parse(readFileSync(this.configPath, 'utf-8'));
27
+ } catch {
28
+ return {};
29
+ }
65
30
  }
66
31
 
32
+ /**
33
+ * 合并更新配置
34
+ * 值为 null 或 undefined 的 key 会从配置中删除
35
+ */
67
36
  setConfig(updates) {
68
- this.init();
69
- const config = { ...this.getConfig(), ...updates };
37
+ this._ensureDir();
38
+ const config = { ...this.getConfig() };
39
+ for (const [k, v] of Object.entries(updates)) {
40
+ if (v == null) {
41
+ delete config[k];
42
+ } else {
43
+ config[k] = v;
44
+ }
45
+ }
70
46
  writeFileSync(this.configPath, JSON.stringify(config, null, 2), 'utf-8');
71
47
  return config;
72
48
  }
73
-
74
- // ─── ID 生成 ─────────────────────────────────────────────
75
-
76
- nextId(prefix) {
77
- const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
78
- const existing = readdirSync(this.dirs[this._collectionForPrefix(prefix)] || this.root)
79
- .filter(f => f.startsWith(prefix))
80
- .length;
81
- const seq = String(existing + 1).padStart(3, '0');
82
- return `${prefix}-${date}-${seq}`;
83
- }
84
-
85
- _collectionForPrefix(prefix) {
86
- const map = { INQ: 'inquiries', QUO: 'replies', ORD: 'orders' };
87
- return map[prefix] || 'inquiries';
88
- }
89
49
  }
@@ -1,125 +0,0 @@
1
- import { Store } from '../store.mjs';
2
- import { createInquiry, createReply, createOrder } from '../models.mjs';
3
-
4
- /**
5
- * 本地 JSON 文件存储适配器
6
- * 将 Store 的底层操作包装为统一的业务接口
7
- */
8
- export class LocalAdapter {
9
- constructor() {
10
- this.store = new Store();
11
- }
12
-
13
- // ─── Inquiry ─────────────────────────────────────────────
14
-
15
- async createInquiry(data) {
16
- const config = this.store.getConfig();
17
- const id = this.store.nextId('INQ');
18
- const record = { id, ...createInquiry(data, config.name) };
19
- return this.store.save('inquiries', record);
20
- }
21
-
22
- async listInquiries(filter = {}) {
23
- return this.store.list('inquiries', (item) => {
24
- if (filter.status && item.status !== filter.status) return false;
25
- return true;
26
- });
27
- }
28
-
29
- async getInquiry(id) {
30
- return this.store.get('inquiries', id);
31
- }
32
-
33
- async closeInquiry(id) {
34
- const record = this.store.get('inquiries', id);
35
- if (!record) return null;
36
- record.status = 'closed';
37
- return this.store.save('inquiries', record);
38
- }
39
-
40
- // ─── Reply ───────────────────────────────────────────────
41
-
42
- async createReply(data) {
43
- const inquiry = this.store.get('inquiries', data.inquiryId);
44
- if (!inquiry) return null;
45
-
46
- const id = this.store.nextId('QUO');
47
- const record = { id, ...createReply(data) };
48
- this.store.save('replies', record);
49
-
50
- // 更新询价单状态
51
- if (inquiry.status === 'pending') {
52
- inquiry.status = 'quoted';
53
- this.store.save('inquiries', inquiry);
54
- }
55
-
56
- return record;
57
- }
58
-
59
- async listReplies(inquiryId) {
60
- return this.store.list('replies', r => r.inquiryId === inquiryId);
61
- }
62
-
63
- // ─── Compare ─────────────────────────────────────────────
64
-
65
- async compareReplies(inquiryId, sort = 'price') {
66
- const inquiry = this.store.get('inquiries', inquiryId);
67
- if (!inquiry) return null;
68
-
69
- const replies = this.store.list('replies', r => r.inquiryId === inquiryId);
70
- if (replies.length === 0) return { inquiry, sorted: [], lowest: null, fastest: null };
71
-
72
- const sorted = [...replies].sort((a, b) => {
73
- switch (sort) {
74
- case 'price': return a.price - b.price;
75
- case 'delivery': return (a.delivery || 999) - (b.delivery || 999);
76
- case 'brand': return (a.brand || '').localeCompare(b.brand || '');
77
- default: return a.price - b.price;
78
- }
79
- });
80
-
81
- const lowest = sorted[0];
82
- const fastest = [...replies].sort((a, b) => (a.delivery || 999) - (b.delivery || 999))[0];
83
-
84
- return { inquiry, sorted, lowest, fastest };
85
- }
86
-
87
- // ─── Order ───────────────────────────────────────────────
88
-
89
- async confirmOrder(inquiryId, replyId) {
90
- const inquiry = this.store.get('inquiries', inquiryId);
91
- if (!inquiry) return null;
92
-
93
- const reply = this.store.get('replies', replyId);
94
- if (!reply) return null;
95
- if (reply.inquiryId !== inquiryId) return null;
96
-
97
- const id = this.store.nextId('ORD');
98
- const record = { id, ...createOrder({ inquiryId, replyId }) };
99
- this.store.save('orders', record);
100
-
101
- inquiry.status = 'ordered';
102
- this.store.save('inquiries', inquiry);
103
-
104
- return { order: record, inquiry, reply };
105
- }
106
-
107
- async listOrders() {
108
- const orders = this.store.list('orders');
109
- return orders.map(order => ({
110
- ...order,
111
- inquiry: this.store.get('inquiries', order.inquiryId),
112
- reply: this.store.get('replies', order.replyId),
113
- }));
114
- }
115
-
116
- // ─── Config ──────────────────────────────────────────────
117
-
118
- async getConfig() {
119
- return this.store.getConfig();
120
- }
121
-
122
- async setConfig(updates) {
123
- return this.store.setConfig(updates);
124
- }
125
- }
package/src/models.mjs DELETED
@@ -1,43 +0,0 @@
1
- /**
2
- * 数据模型定义与校验
3
- */
4
-
5
- export function createInquiry({ product, oeNumber, vehicle, quantity, note }, createdBy) {
6
- if (!product) throw new Error('product 是必填项');
7
- return {
8
- product,
9
- oeNumber: oeNumber || '',
10
- vehicle: vehicle || '',
11
- quantity: quantity ? Number(quantity) : 1,
12
- note: note || '',
13
- status: 'pending', // pending | quoted | ordered | closed
14
- createdAt: new Date().toISOString(),
15
- createdBy: createdBy || 'anonymous',
16
- };
17
- }
18
-
19
- export function createReply({ inquiryId, supplier, price, currency, brand, delivery, note }) {
20
- if (!inquiryId) throw new Error('inquiryId 是必填项');
21
- if (!price) throw new Error('price 是必填项');
22
- return {
23
- inquiryId,
24
- supplier: supplier || 'unknown',
25
- price: Number(price),
26
- currency: currency || 'CNY',
27
- brand: brand || '',
28
- delivery: delivery ? Number(delivery) : null, // 货期(天)
29
- note: note || '',
30
- createdAt: new Date().toISOString(),
31
- };
32
- }
33
-
34
- export function createOrder({ inquiryId, replyId }) {
35
- if (!inquiryId) throw new Error('inquiryId 是必填项');
36
- if (!replyId) throw new Error('replyId 是必填项');
37
- return {
38
- inquiryId,
39
- replyId,
40
- status: 'confirmed',
41
- confirmedAt: new Date().toISOString(),
42
- };
43
- }