@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 +1 -1
- package/src/adapter/api.mjs +119 -131
- package/src/adapter/index.mjs +9 -13
- package/src/auth.mjs +16 -15
- package/src/commands/config.mjs +24 -19
- package/src/commands/inquiry.mjs +80 -22
- package/src/commands/login.mjs +93 -53
- package/src/store.mjs +27 -67
- package/src/adapter/local.mjs +0 -125
- package/src/models.mjs +0 -43
package/package.json
CHANGED
package/src/adapter/api.mjs
CHANGED
|
@@ -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('
|
|
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
|
-
*
|
|
90
|
-
*
|
|
86
|
+
* isSimpleInquiryAllowed=true 走 simple_inquiry(自由文本)
|
|
87
|
+
* 否则走 POST /inquiries(标准流程)
|
|
91
88
|
*/
|
|
92
89
|
async createInquiry(data) {
|
|
93
|
-
const config = this.
|
|
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
|
-
|
|
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
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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(
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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) {
|
package/src/adapter/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
261
|
+
* 登出(仅清除认证 token,保留账号信息用于下次登录预填)
|
|
262
262
|
*/
|
|
263
263
|
export function logout() {
|
|
264
264
|
getStore().setConfig({
|
|
265
|
-
accessToken:
|
|
266
|
-
refreshToken:
|
|
267
|
-
tokenType:
|
|
268
|
-
tokenExpiresAt:
|
|
269
|
-
userLoginId
|
|
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:
|
|
289
|
-
garageCompanyId:
|
|
290
|
-
cellphone:
|
|
291
|
-
provinceGeoId:
|
|
292
|
-
cityGeoId:
|
|
293
|
-
countyGeoId:
|
|
294
|
-
provinceGeoName:
|
|
295
|
-
cityGeoName:
|
|
296
|
-
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
|
// 拉取失败不影响登录流程
|
package/src/commands/config.mjs
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
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('-
|
|
14
|
-
.
|
|
15
|
-
|
|
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)
|
|
20
|
-
if (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)
|
|
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
|
-
|
|
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(
|
|
41
|
-
const
|
|
42
|
-
const cfg =
|
|
38
|
+
.action(() => {
|
|
39
|
+
const store = new Store();
|
|
40
|
+
const cfg = store.getConfig();
|
|
43
41
|
if (Object.keys(cfg).length === 0) {
|
|
44
|
-
console.log('
|
|
42
|
+
console.log('暂无配置,执行 quote login 登录后自动初始化');
|
|
45
43
|
return;
|
|
46
44
|
}
|
|
47
|
-
|
|
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
|
}
|
package/src/commands/inquiry.mjs
CHANGED
|
@@ -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
|
|
128
|
-
const interval
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
let
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
|
package/src/commands/login.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
117
|
-
|
|
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.
|
|
137
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
182
|
+
if (!isNonInteractive) {
|
|
183
|
+
process.stdout.write('正在发送验证码...');
|
|
184
|
+
await sendRegisterCode(apiBase, cellphone);
|
|
185
|
+
console.log(' 已发送');
|
|
186
|
+
}
|
|
147
187
|
|
|
148
|
-
const verifyCode
|
|
149
|
-
const username
|
|
150
|
-
const password
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
189
|
-
cityId:
|
|
190
|
-
countyId:
|
|
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
|
|
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
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* 本地配置存储
|
|
7
|
+
* 配置文件位于 ~/.quote/config.json,全局唯一,不受 cwd 影响
|
|
8
8
|
*/
|
|
9
9
|
export class Store {
|
|
10
10
|
constructor() {
|
|
11
|
-
|
|
12
|
-
this.
|
|
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
|
-
|
|
22
|
-
|
|
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.
|
|
23
|
+
this._ensureDir();
|
|
63
24
|
if (!existsSync(this.configPath)) return {};
|
|
64
|
-
|
|
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.
|
|
69
|
-
const config = { ...this.getConfig()
|
|
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
|
}
|
package/src/adapter/local.mjs
DELETED
|
@@ -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
|
-
}
|