@dalehkx/quote-cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dalehkx/quote-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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
  }
@@ -123,36 +123,94 @@ export function registerInquiryCommands(program) {
123
123
  .description('监听询价单报价状态(有新报价时提醒)')
124
124
  .argument('<id>', '询价单 ID')
125
125
  .option('-i, --interval <seconds>', '轮询间隔(秒)', '30')
126
+ .option('--timeout <seconds>', '最长等待时间(秒),超时后退出,0=不限制', '0')
127
+ .option('--exit-on-quotes <n>', '收到 n 条报价后自动退出', '0')
128
+ .option('--json', '以 JSON 格式输出结果(适合脚本/AI 调用)')
126
129
  .action(async (id, opts) => {
127
- const adapter = getAdapter();
128
- const interval = Math.max(10, parseInt(opts.interval, 10)) * 1000;
129
-
130
- console.log(`监听询价单 ${id},每 ${interval / 1000} 秒检查一次,Ctrl+C 退出\n`);
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
+
136
+ if (!opts.json) {
137
+ console.log(`监听询价单 ${id},每 ${interval / 1000} 秒检查一次,Ctrl+C 退出\n`);
138
+ }
131
139
 
132
- let lastQuoteCount = 0;
133
- 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
+ };
134
164
 
165
+ /**
166
+ * 阶段一:轻量轮询,只查状态
167
+ */
135
168
  const check = async () => {
136
169
  try {
137
- const record = await adapter.getInquiry(id);
138
- const replies = await adapter.listReplies(id);
139
- const count = replies.length;
140
- const time = new Date().toLocaleTimeString();
141
-
142
- if (count > lastQuoteCount) {
143
- console.log(`\n[${time}] 🔔 新报价!共 ${count} 条`);
144
- replies.slice(lastQuoteCount).forEach(r => {
145
- console.log(` ${r.supplier} | ¥${r.price} | ${r.brand || '-'} | ${r.delivery ? r.delivery + '天' : '货期未知'}`);
146
- });
147
- lastQuoteCount = count;
148
- } else if (record.status !== lastStatus) {
149
- console.log(`[${time}] 状态变更: ${lastStatus || '-'} → ${record.status} 报价: ${count} 条`);
150
- lastStatus = record.status;
170
+ // 超时退出
171
+ if (timeout > 0 && Date.now() - startTime >= timeout) {
172
+ if (!opts.json) console.log(`\n等待超时,共 ${lastQuoteCount} 条报价`);
173
+ else console.log(JSON.stringify({ inquiryId: id, timedOut: true, quoteCount: lastQuoteCount }));
174
+ process.exit(0);
175
+ }
176
+
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 里打印了新报价,这里只更新计数
194
+ }
195
+
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);
203
+ }
151
204
  } else {
152
- 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
+ }
153
211
  }
154
212
  } catch (e) {
155
- console.error(`\n[错误] ${e.message}`);
213
+ if (!opts.json) console.error(`\n[错误] ${e.message}`);
156
214
  }
157
215
  };
158
216
 
@@ -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));
@@ -28,9 +30,15 @@ export function registerLoginCommands(program) {
28
30
  try {
29
31
  let tokenData;
30
32
 
31
- // --test 强制走短信登录,手机号固定为测试账号
32
33
  if (opts.test || opts.sms) {
33
- 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); }
34
42
 
35
43
  if (opts.test) {
36
44
  console.log(`[测试模式] 使用测试账号: ${TEST_PHONE}`);
@@ -47,19 +55,38 @@ export function registerLoginCommands(program) {
47
55
  }
48
56
  console.log(' 已确认');
49
57
 
50
- process.stdout.write('正在发送验证码...');
51
- await sendLoginCode(apiBase, cellphone);
52
- 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(' 已发送');
53
76
 
54
- const verifyCode = await ask('验证码: ');
77
+ verifyCode = await ask('验证码: ');
78
+ }
55
79
  rl.close();
56
80
 
57
81
  tokenData = await loginWithCellphone(apiBase, cellphone, verifyCode);
58
82
  } else {
59
83
  // 账号密码登录
60
- const username = opts.username || await ask('账号: ');
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); }
61
89
 
62
- // 检查账号是否存在
63
90
  process.stdout.write('正在检查账号...');
64
91
  const exists = await checkAccountExists(apiBase, username);
65
92
  if (!exists) {
@@ -93,12 +120,32 @@ export function registerLoginCommands(program) {
93
120
  .option('-u, --username <name>', '用户名')
94
121
  .option('--phone <phone>', '手机号')
95
122
  .option('-p, --password <pwd>', '密码')
123
+ .option('--code <code>', '验证码(非交互:先用 --send-code 发送)')
124
+ .option('--send-code', '仅发送注册验证码,不完成注册')
125
+ .option('--company <name>', '公司/门店名称')
126
+ .option('--province-id <id>', '省级 geoId(如 CN-11)')
127
+ .option('--province-name <name>', '省名称(如 北京市)')
128
+ .option('--city-id <id>', '市级 geoId(如 1351)')
129
+ .option('--city-name <name>', '市名称')
130
+ .option('--county-id <id>', '区县 geoId(如 9629)')
131
+ .option('--county-name <name>', '区县名称')
96
132
  .option('--api-base <url>', 'API 地址', API_BASE_DEFAULT)
97
133
  .action(async (opts) => {
98
134
  const store = new Store();
99
135
  const config = store.getConfig();
100
136
  const apiBase = opts.apiBase || config.apiBase || API_BASE_DEFAULT;
101
137
 
138
+ // 仅发送验证码模式
139
+ if (opts.sendCode) {
140
+ if (!opts.phone) { console.error('✗ 请提供 --phone <手机号>'); process.exit(1); }
141
+ process.stdout.write('正在发送验证码...');
142
+ await sendRegisterCode(apiBase, opts.phone);
143
+ console.log(' 已发送,请查收短信后带 --code 完成注册');
144
+ return;
145
+ }
146
+
147
+ const isNonInteractive = opts.phone && opts.password && opts.username && opts.code;
148
+
102
149
  const rl = createInterface({ input: process.stdin, output: process.stdout });
103
150
  const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
104
151
  const askHidden = (q) => new Promise((resolve) => {
@@ -112,14 +159,9 @@ export function registerLoginCommands(program) {
112
159
  process.stdin.removeListener('data', onData);
113
160
  process.stdout.write('\n');
114
161
  resolve(input);
115
- } else if (ch === '') {
116
- process.exit();
117
- } else if (ch === '') {
118
- input = input.slice(0, -1);
119
- } else {
120
- input += ch;
121
- process.stdout.write('*');
122
- }
162
+ } else if (ch === '') { process.exit(); }
163
+ else if (ch === '') { input = input.slice(0, -1); }
164
+ else { input += ch; process.stdout.write('*'); }
123
165
  };
124
166
  process.stdin.resume();
125
167
  process.stdin.setEncoding('utf8');
@@ -129,68 +171,69 @@ export function registerLoginCommands(program) {
129
171
  try {
130
172
  const cellphone = opts.phone || await ask('手机号: ');
131
173
 
132
- // 检查是否已注册
133
174
  process.stdout.write('正在检查账号...');
134
175
  const exists = await checkAccountExists(apiBase, cellphone);
176
+ console.log(exists ? ' 已注册' : ' 未注册');
135
177
  if (exists) {
136
- console.log('');
137
- console.error('✗ 该手机号已注册,请直接登录: quote login --sms');
138
- rl.close();
139
- process.exit(1);
178
+ console.error('✗ 该手机号已注册,请直接登录: quote login');
179
+ rl.close(); process.exit(1);
140
180
  }
141
- console.log(' 未注册');
142
181
 
143
- // 发送注册验证码
144
- process.stdout.write('正在发送验证码...');
145
- await sendRegisterCode(apiBase, cellphone);
146
- console.log(' 已发送');
182
+ if (!isNonInteractive) {
183
+ process.stdout.write('正在发送验证码...');
184
+ await sendRegisterCode(apiBase, cellphone);
185
+ console.log(' 已发送');
186
+ }
147
187
 
148
- const verifyCode = await ask('验证码: ');
149
- const username = opts.username || await ask('用户名: ');
150
- const password = opts.password || await askHidden('密码: ');
188
+ const verifyCode = opts.code || await ask('验证码: ');
189
+ const username = opts.username || await ask('用户名: ');
190
+ const password = opts.password || await askHidden('密码: ');
151
191
 
152
- rl.close();
192
+ if (!isNonInteractive) rl.close();
153
193
 
154
194
  await registerUser(apiBase, { cellphone, password, username, verificationCode: verifyCode });
155
-
156
195
  console.log('✓ 注册成功,正在自动登录...');
157
196
 
158
- store.setConfig({ apiBase, mode: 'api' });
197
+ store.setConfig({ apiBase });
159
198
  const tokenData = await loginWithPassword(apiBase, cellphone, password);
160
199
  console.log(`✓ 登录成功 用户: ${tokenData.userLoginId || username}`);
161
200
 
162
- // 引导完善公司信息
201
+ // 公司信息 —— 有 flag 则非交互
202
+ if (opts.company && opts.provinceId && opts.cityId && opts.countyId) {
203
+ await saveCompanyInfo(apiBase, {
204
+ companyName: opts.company,
205
+ provinceId: opts.provinceId, provinceName: opts.provinceName || '',
206
+ cityId: opts.cityId, cityName: opts.cityName || '',
207
+ countyId: opts.countyId, countyName: opts.countyName || '',
208
+ });
209
+ console.log('✓ 公司信息已保存,使用 quote inquiry create 发布询价');
210
+ rl.close();
211
+ return;
212
+ }
213
+
163
214
  console.log('\n完善公司/门店信息(发布询价必须):');
164
215
  const companyName = await ask('公司/门店名称: ');
165
216
 
166
- // 选省
167
217
  const provinces = await fetchAreas(apiBase, 'CHN');
168
218
  provinces.forEach((p, i) => process.stdout.write(` ${String(i + 1).padStart(2)}. ${p.geoName}\n`));
169
- const pIdx = parseInt(await ask(`选择省份 (1-${provinces.length}): `), 10) - 1;
170
- const province = provinces[pIdx];
219
+ const province = provinces[parseInt(await ask(`选择省份 (1-${provinces.length}): `), 10) - 1];
171
220
 
172
- // 选市
173
221
  const cities = await fetchAreas(apiBase, province.geoId);
174
222
  cities.forEach((c, i) => process.stdout.write(` ${String(i + 1).padStart(2)}. ${c.geoName}\n`));
175
- const cIdx = parseInt(await ask(`选择城市 (1-${cities.length}): `), 10) - 1;
176
- const city = cities[cIdx];
223
+ const city = cities[parseInt(await ask(`选择城市 (1-${cities.length}): `), 10) - 1];
177
224
 
178
- // 选区县
179
225
  const counties = await fetchAreas(apiBase, city.geoId);
180
226
  counties.forEach((d, i) => process.stdout.write(` ${String(i + 1).padStart(2)}. ${d.geoName}\n`));
181
- const dIdx = parseInt(await ask(`选择区县 (1-${counties.length}): `), 10) - 1;
182
- const county = counties[dIdx];
227
+ const county = counties[parseInt(await ask(`选择区县 (1-${counties.length}): `), 10) - 1];
183
228
 
184
229
  rl.close();
185
-
186
230
  await saveCompanyInfo(apiBase, {
187
231
  companyName,
188
- provinceId: province.geoId, provinceName: province.geoName,
189
- cityId: city.geoId, cityName: city.geoName,
190
- countyId: county.geoId, countyName: county.geoName,
232
+ provinceId: province.geoId, provinceName: province.geoName,
233
+ cityId: city.geoId, cityName: city.geoName,
234
+ countyId: county.geoId, countyName: county.geoName,
191
235
  });
192
- console.log('✓ 公司信息已保存');
193
- console.log('账号已准备就绪,使用 quote inquiry create 发布询价');
236
+ console.log('✓ 公司信息已保存,使用 quote inquiry create 发布询价');
194
237
  } catch (err) {
195
238
  rl.close();
196
239
  console.error(`✗ 注册失败: ${err.message}`);
@@ -202,10 +245,7 @@ export function registerLoginCommands(program) {
202
245
  .command('logout')
203
246
  .description('登出(清除本地令牌)')
204
247
  .action(() => {
205
- if (!isLoggedIn()) {
206
- console.log('当前未登录');
207
- return;
208
- }
248
+ if (!isLoggedIn()) { console.log('当前未登录'); return; }
209
249
  logout();
210
250
  console.log('✓ 已登出,令牌已清除');
211
251
  });
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
- }