shawnxixi-cli 0.3.0 → 1.1.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/.env.example +8 -2
- package/.github/workflows/ci.yml +9 -22
- package/.github/workflows/release.yml +30 -0
- package/CLAUDE.md +167 -63
- package/README.md +345 -65
- package/commitlint.config.js +10 -0
- package/dist/commands/biz/calendar.d.ts +17 -2
- package/dist/commands/biz/calendar.d.ts.map +1 -1
- package/dist/commands/biz/calendar.js +47 -9
- package/dist/commands/biz/calendar.js.map +1 -1
- package/dist/commands/biz/finance.d.ts +32 -1
- package/dist/commands/biz/finance.d.ts.map +1 -1
- package/dist/commands/biz/finance.js +99 -12
- package/dist/commands/biz/finance.js.map +1 -1
- package/dist/commands/biz/health.d.ts +32 -0
- package/dist/commands/biz/health.d.ts.map +1 -0
- package/dist/commands/biz/health.js +92 -0
- package/dist/commands/biz/health.js.map +1 -0
- package/dist/commands/biz/item.d.ts +41 -0
- package/dist/commands/biz/item.d.ts.map +1 -1
- package/dist/commands/biz/item.js +89 -5
- package/dist/commands/biz/item.js.map +1 -1
- package/dist/commands/biz/record.d.ts +0 -8
- package/dist/commands/biz/record.d.ts.map +1 -1
- package/dist/commands/biz/record.js +29 -8
- package/dist/commands/biz/record.js.map +1 -1
- package/dist/commands/biz/task.d.ts +38 -0
- package/dist/commands/biz/task.d.ts.map +1 -0
- package/dist/commands/biz/task.js +110 -0
- package/dist/commands/biz/task.js.map +1 -0
- package/dist/commands/biz/todo.d.ts +52 -8
- package/dist/commands/biz/todo.d.ts.map +1 -1
- package/dist/commands/biz/todo.js +167 -17
- package/dist/commands/biz/todo.js.map +1 -1
- package/dist/commands/sys/health.d.ts.map +1 -1
- package/dist/commands/sys/health.js +20 -3
- package/dist/commands/sys/health.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +281 -42
- package/dist/index.js.map +1 -1
- package/dist/services/api.d.ts +22 -8
- package/dist/services/api.d.ts.map +1 -1
- package/dist/services/api.js +108 -28
- package/dist/services/api.js.map +1 -1
- package/dist/utils/env.d.ts +16 -0
- package/dist/utils/env.d.ts.map +1 -0
- package/dist/utils/env.js +78 -0
- package/dist/utils/env.js.map +1 -0
- package/dist/utils/finance.model.d.ts +80 -0
- package/dist/utils/finance.model.d.ts.map +1 -0
- package/dist/utils/finance.model.js +150 -0
- package/dist/utils/finance.model.js.map +1 -0
- package/dist/utils/output.d.ts +42 -0
- package/dist/utils/output.d.ts.map +1 -0
- package/dist/utils/output.js +124 -0
- package/dist/utils/output.js.map +1 -0
- package/dist/utils/todo.model.d.ts +55 -0
- package/dist/utils/todo.model.d.ts.map +1 -0
- package/dist/utils/todo.model.js +135 -0
- package/dist/utils/todo.model.js.map +1 -0
- package/package.json +18 -2
- package/src/commands/biz/calendar.ts +61 -10
- package/src/commands/biz/finance.ts +112 -13
- package/src/commands/biz/health.ts +96 -0
- package/src/commands/biz/item.ts +113 -6
- package/src/commands/biz/record.ts +32 -8
- package/src/commands/biz/task.ts +115 -0
- package/src/commands/biz/todo.ts +193 -21
- package/src/commands/sys/health.ts +23 -3
- package/src/index.ts +311 -54
- package/src/services/api.ts +111 -30
- package/src/utils/env.ts +85 -0
- package/src/utils/finance.model.ts +182 -0
- package/src/utils/output.ts +126 -0
- package/src/utils/todo.model.ts +167 -0
- package/tests/commands/finance.test.ts +281 -0
- package/tests/commands/item.test.ts +215 -0
- package/tests/commands/todo.test.ts +292 -9
- package/tests/services/api.test.ts +292 -20
- package/tests/utils/finance.model.test.ts +319 -0
- package/tests/utils/todo.model.test.ts +315 -0
package/src/services/api.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import axios, { AxiosInstance } from 'axios';
|
|
7
|
+
import { envConfig } from '../utils/env';
|
|
7
8
|
|
|
8
|
-
const API_BASE =
|
|
9
|
+
const API_BASE = envConfig.API_URL;
|
|
9
10
|
|
|
10
11
|
/** Normalize date/datetime strings to LocalDateTime format (ISO 8601 without timezone) */
|
|
11
12
|
function toLocalDateTime(value?: string): string | undefined {
|
|
@@ -62,8 +63,7 @@ class ApiService {
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
private async getToken(): Promise<string | null> {
|
|
65
|
-
|
|
66
|
-
return process.env.SHAWNXIXI_API_TOKEN || null;
|
|
66
|
+
return envConfig.API_TOKEN || process.env.SHAWNXIXI_API_TOKEN || null;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
private async refreshToken(): Promise<void> {
|
|
@@ -71,23 +71,30 @@ class ApiService {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
// ============ Todo API ============
|
|
74
|
-
async createTodo(title: string, dueDate?: string, priority?: string) {
|
|
74
|
+
async createTodo(title: string, dueDate?: string, priority?: string, tags?: string, repeatRule?: string, repeatEndDate?: string, parentId?: string) {
|
|
75
75
|
const { data } = await this.client.post('/api/v1/todos', {
|
|
76
76
|
title,
|
|
77
77
|
dueDate: toLocalDateTime(dueDate),
|
|
78
78
|
priority: priority || 'medium',
|
|
79
79
|
status: 'pending',
|
|
80
|
+
tags,
|
|
81
|
+
repeatRule,
|
|
82
|
+
repeatEndDate: toLocalDateTime(repeatEndDate),
|
|
83
|
+
parentId,
|
|
80
84
|
});
|
|
81
85
|
return data;
|
|
82
86
|
}
|
|
83
87
|
|
|
84
|
-
async listTodos(status?: string) {
|
|
85
|
-
const params
|
|
88
|
+
async listTodos(status?: string, priority?: string, tags?: string) {
|
|
89
|
+
const params: Record<string, string> = {};
|
|
90
|
+
if (status) params.status = status;
|
|
91
|
+
if (priority) params.priority = priority;
|
|
92
|
+
if (tags) params.tags = tags;
|
|
86
93
|
const { data } = await this.client.get('/api/v1/todos', { params });
|
|
87
94
|
return data;
|
|
88
95
|
}
|
|
89
96
|
|
|
90
|
-
async updateTodo(id: number, updates: Partial<{title: string; status: string; priority: string; dueDate: string}>) {
|
|
97
|
+
async updateTodo(id: number, updates: Partial<{title: string; status: string; priority: string; dueDate: string; tags: string}>) {
|
|
91
98
|
const { data } = await this.client.patch(`/api/v1/todos/${id}`, updates);
|
|
92
99
|
return data;
|
|
93
100
|
}
|
|
@@ -97,6 +104,18 @@ class ApiService {
|
|
|
97
104
|
return data;
|
|
98
105
|
}
|
|
99
106
|
|
|
107
|
+
async repeatTodo(id: number) {
|
|
108
|
+
const { data } = await this.client.post(`/api/v1/todos/${id}/repeat`);
|
|
109
|
+
return data;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async listSubtasks(parentId: number) {
|
|
113
|
+
const params: Record<string, string> = {};
|
|
114
|
+
if (parentId) params.parentId = String(parentId);
|
|
115
|
+
const { data } = await this.client.get('/api/v1/todos', { params });
|
|
116
|
+
return data;
|
|
117
|
+
}
|
|
118
|
+
|
|
100
119
|
// ============ Record API ============
|
|
101
120
|
async createRecord(title: string, content: string, type: string = 'diary', tags?: string) {
|
|
102
121
|
const { data } = await this.client.post('/api/v1/records', {
|
|
@@ -117,25 +136,58 @@ class ApiService {
|
|
|
117
136
|
}
|
|
118
137
|
|
|
119
138
|
// ============ Finance API ============
|
|
120
|
-
async createFinance(amount: number, type: string, category: string, description?: string) {
|
|
139
|
+
async createFinance(amount: number, type: string, category: string, description?: string, account?: string, transactionDate?: string, tags?: string) {
|
|
121
140
|
const { data } = await this.client.post('/api/v1/finances', {
|
|
122
141
|
amount,
|
|
123
142
|
type,
|
|
124
143
|
category,
|
|
125
144
|
description,
|
|
145
|
+
account,
|
|
146
|
+
transactionDate: toLocalDateTime(transactionDate),
|
|
147
|
+
tags,
|
|
126
148
|
});
|
|
127
149
|
return data;
|
|
128
150
|
}
|
|
129
151
|
|
|
130
|
-
async listFinances(type?: string, category?: string) {
|
|
152
|
+
async listFinances(type?: string, category?: string, month?: string) {
|
|
131
153
|
const params: Record<string, string> = {};
|
|
132
154
|
if (type) params.type = type;
|
|
133
155
|
if (category) params.category = category;
|
|
156
|
+
if (month) params.month = month;
|
|
134
157
|
const { data } = await this.client.get('/api/v1/finances', { params });
|
|
135
158
|
return data;
|
|
136
159
|
}
|
|
137
160
|
|
|
161
|
+
async getFinanceStats(month: string) {
|
|
162
|
+
const { data } = await this.client.get('/api/v1/finances/stats', { params: { month } });
|
|
163
|
+
return data;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async getFinanceReport(month: string) {
|
|
167
|
+
const { data } = await this.client.get('/api/v1/finances/report', { params: { month } });
|
|
168
|
+
return data;
|
|
169
|
+
}
|
|
170
|
+
|
|
138
171
|
// ============ Health API ============
|
|
172
|
+
async logHealthData(dataType: string, value: string, unit?: string, date?: string) {
|
|
173
|
+
const { data } = await this.client.post('/api/v1/health-data', {
|
|
174
|
+
dataType,
|
|
175
|
+
value,
|
|
176
|
+
unit,
|
|
177
|
+
date: toLocalDateTime(date),
|
|
178
|
+
});
|
|
179
|
+
return data;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async listHealthData(dataType?: string, fromDate?: string, toDate?: string) {
|
|
183
|
+
const params: Record<string, string> = {};
|
|
184
|
+
if (dataType) params.dataType = dataType;
|
|
185
|
+
if (fromDate) params.fromDate = fromDate;
|
|
186
|
+
if (toDate) params.toDate = toDate;
|
|
187
|
+
const { data } = await this.client.get('/api/v1/health-data', { params });
|
|
188
|
+
return data;
|
|
189
|
+
}
|
|
190
|
+
|
|
139
191
|
async syncHealthData() {
|
|
140
192
|
const { data } = await this.client.post('/api/v1/health-data/sync');
|
|
141
193
|
return data;
|
|
@@ -151,12 +203,19 @@ class ApiService {
|
|
|
151
203
|
}
|
|
152
204
|
|
|
153
205
|
// ============ Item API ============
|
|
154
|
-
async createItem(name: string, category?: string, location?: string, expireDate?: string) {
|
|
206
|
+
async createItem(name: string, category?: string, location?: string, expireDate?: string, quantity?: number, unit?: string, brand?: string, barcode?: string, price?: number, supplier?: string, purchaseDate?: string) {
|
|
155
207
|
const { data } = await this.client.post('/api/v1/items', {
|
|
156
208
|
name,
|
|
157
209
|
category,
|
|
158
210
|
location,
|
|
159
211
|
expireDate: toLocalDateTime(expireDate),
|
|
212
|
+
quantity,
|
|
213
|
+
unit,
|
|
214
|
+
brand,
|
|
215
|
+
barcode,
|
|
216
|
+
price,
|
|
217
|
+
supplier,
|
|
218
|
+
purchaseDate: toLocalDateTime(purchaseDate),
|
|
160
219
|
});
|
|
161
220
|
return data;
|
|
162
221
|
}
|
|
@@ -169,14 +228,25 @@ class ApiService {
|
|
|
169
228
|
return data;
|
|
170
229
|
}
|
|
171
230
|
|
|
231
|
+
async getExpiringItems(days: number) {
|
|
232
|
+
const { data } = await this.client.get('/api/v1/items/expiring', { params: { days } });
|
|
233
|
+
return data;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async getItemStats() {
|
|
237
|
+
const { data } = await this.client.get('/api/v1/items/stats');
|
|
238
|
+
return data;
|
|
239
|
+
}
|
|
240
|
+
|
|
172
241
|
// ============ Calendar API ============
|
|
173
|
-
async createCalendarEvent(title: string, startTime: string, endTime: string, description?: string, location?: string) {
|
|
242
|
+
async createCalendarEvent(title: string, startTime: string, endTime: string, description?: string, location?: string, color?: string) {
|
|
174
243
|
const { data } = await this.client.post('/api/v1/calendar-events', {
|
|
175
244
|
title,
|
|
176
245
|
startTime: toLocalDateTime(startTime),
|
|
177
246
|
endTime: toLocalDateTime(endTime),
|
|
178
247
|
description,
|
|
179
248
|
location,
|
|
249
|
+
color,
|
|
180
250
|
});
|
|
181
251
|
return data;
|
|
182
252
|
}
|
|
@@ -189,6 +259,36 @@ class ApiService {
|
|
|
189
259
|
return data;
|
|
190
260
|
}
|
|
191
261
|
|
|
262
|
+
// ============ Task API ============
|
|
263
|
+
async createTask(title: string, source?: string, openclawId?: string, priority?: string) {
|
|
264
|
+
const { data } = await this.client.post('/api/v1/tasks', {
|
|
265
|
+
title,
|
|
266
|
+
source: source || 'cli',
|
|
267
|
+
openclawId,
|
|
268
|
+
priority: priority || 'medium',
|
|
269
|
+
status: 'pending',
|
|
270
|
+
});
|
|
271
|
+
return data;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async listTasks(status?: string, source?: string) {
|
|
275
|
+
const params: Record<string, string> = {};
|
|
276
|
+
if (status) params.status = status;
|
|
277
|
+
if (source) params.source = source;
|
|
278
|
+
const { data } = await this.client.get('/api/v1/tasks', { params });
|
|
279
|
+
return data;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async updateTask(id: number, updates: Partial<{status: string; priority: string}>) {
|
|
283
|
+
const { data } = await this.client.patch(`/api/v1/tasks/${id}`, updates);
|
|
284
|
+
return data;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async deleteTask(id: number) {
|
|
288
|
+
const { data } = await this.client.delete(`/api/v1/tasks/${id}`);
|
|
289
|
+
return data;
|
|
290
|
+
}
|
|
291
|
+
|
|
192
292
|
// ============ Reminder API ============
|
|
193
293
|
async createReminder(title: string, triggerTime: string, type: string = 'once') {
|
|
194
294
|
const { data } = await this.client.post('/api/v1/reminders', {
|
|
@@ -217,25 +317,6 @@ class ApiService {
|
|
|
217
317
|
const { data } = await this.client.get('/api/v1/audit-logs', { params });
|
|
218
318
|
return data;
|
|
219
319
|
}
|
|
220
|
-
|
|
221
|
-
// ============ Task API ============
|
|
222
|
-
async createTask(title: string, description?: string, priority?: string, source?: string) {
|
|
223
|
-
const { data } = await this.client.post('/api/v1/tasks', {
|
|
224
|
-
title,
|
|
225
|
-
description,
|
|
226
|
-
priority: priority || 'medium',
|
|
227
|
-
source: source || 'cli',
|
|
228
|
-
status: 'pending',
|
|
229
|
-
});
|
|
230
|
-
return data;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
async listTasks(status?: string) {
|
|
234
|
-
const params: Record<string, string> = {};
|
|
235
|
-
if (status) params.status = status;
|
|
236
|
-
const { data } = await this.client.get('/api/v1/tasks', { params });
|
|
237
|
-
return data;
|
|
238
|
-
}
|
|
239
320
|
}
|
|
240
321
|
|
|
241
322
|
export const apiService = new ApiService();
|
package/src/utils/env.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* .env 配置文件加载器
|
|
3
|
+
* 支持 SHAWNXIXI_API_URL 等环境变量
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
|
|
9
|
+
interface EnvConfig {
|
|
10
|
+
API_URL: string;
|
|
11
|
+
API_TOKEN?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let cachedConfig: EnvConfig | null = null;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 加载 .env 文件并返回配置
|
|
18
|
+
* 优先级:process.env > .env 文件 > 默认值
|
|
19
|
+
*/
|
|
20
|
+
export function loadEnvConfig(): EnvConfig {
|
|
21
|
+
if (cachedConfig) return cachedConfig;
|
|
22
|
+
|
|
23
|
+
const defaultConfig: EnvConfig = {
|
|
24
|
+
API_URL: process.env.SHAWNXIXI_API_URL || 'http://localhost:8080/api/v1',
|
|
25
|
+
API_TOKEN: process.env.SHAWNXIXI_API_TOKEN,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// 尝试从当前目录和项目根目录加载 .env
|
|
29
|
+
const possiblePaths = [
|
|
30
|
+
path.join(process.cwd(), '.env'),
|
|
31
|
+
path.join(__dirname, '../../.env'),
|
|
32
|
+
path.join(__dirname, '../../../.env'),
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
for (const envPath of possiblePaths) {
|
|
36
|
+
try {
|
|
37
|
+
if (fs.existsSync(envPath)) {
|
|
38
|
+
const content = fs.readFileSync(envPath, 'utf-8');
|
|
39
|
+
const envVars = parseEnvFile(content);
|
|
40
|
+
|
|
41
|
+
// 合并配置,process.env 优先级最高
|
|
42
|
+
cachedConfig = {
|
|
43
|
+
API_URL: process.env.SHAWNXIXI_API_URL || envVars.SHAWNXIXI_API_URL || defaultConfig.API_URL,
|
|
44
|
+
API_TOKEN: process.env.SHAWNXIXI_API_TOKEN || envVars.SHAWNXIXI_API_TOKEN || defaultConfig.API_TOKEN,
|
|
45
|
+
};
|
|
46
|
+
return cachedConfig;
|
|
47
|
+
}
|
|
48
|
+
} catch (e) {
|
|
49
|
+
// 忽略读取错误,继续尝试下一个路径
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
cachedConfig = defaultConfig;
|
|
54
|
+
return cachedConfig;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 简单 .env 文件解析器
|
|
59
|
+
*/
|
|
60
|
+
function parseEnvFile(content: string): Record<string, string> {
|
|
61
|
+
const result: Record<string, string> = {};
|
|
62
|
+
const lines = content.split('\n');
|
|
63
|
+
|
|
64
|
+
for (const line of lines) {
|
|
65
|
+
const trimmed = line.trim();
|
|
66
|
+
// 跳过空行和注释
|
|
67
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
68
|
+
|
|
69
|
+
const eqIndex = trimmed.indexOf('=');
|
|
70
|
+
if (eqIndex > 0) {
|
|
71
|
+
const key = trimmed.substring(0, eqIndex).trim();
|
|
72
|
+
let value = trimmed.substring(eqIndex + 1).trim();
|
|
73
|
+
// 去掉引号
|
|
74
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
75
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
76
|
+
value = value.slice(1, -1);
|
|
77
|
+
}
|
|
78
|
+
result[key] = value;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const envConfig = loadEnvConfig();
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finance Model Utilities
|
|
3
|
+
* 包含收支分类、账户余额计算、金额格式化等功能
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type FinanceType = 'income' | 'expense';
|
|
7
|
+
|
|
8
|
+
export interface FinanceItem {
|
|
9
|
+
id: string;
|
|
10
|
+
amount: number;
|
|
11
|
+
type: FinanceType;
|
|
12
|
+
category: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
createdAt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Account {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
balance: number;
|
|
21
|
+
currency: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface FinanceStats {
|
|
25
|
+
totalIncome: number;
|
|
26
|
+
totalExpense: number;
|
|
27
|
+
netBalance: number;
|
|
28
|
+
byCategory: Record<string, { income: number; expense: number }>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 收入分类
|
|
33
|
+
*/
|
|
34
|
+
export const INCOME_CATEGORIES = [
|
|
35
|
+
'salary',
|
|
36
|
+
'bonus',
|
|
37
|
+
'investment',
|
|
38
|
+
'gift',
|
|
39
|
+
'refund',
|
|
40
|
+
'other_income',
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 支出分类
|
|
45
|
+
*/
|
|
46
|
+
export const EXPENSE_CATEGORIES = [
|
|
47
|
+
'food',
|
|
48
|
+
'transport',
|
|
49
|
+
'shopping',
|
|
50
|
+
'entertainment',
|
|
51
|
+
'housing',
|
|
52
|
+
'healthcare',
|
|
53
|
+
'education',
|
|
54
|
+
'travel',
|
|
55
|
+
'other',
|
|
56
|
+
] as const;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 判断账单类型
|
|
60
|
+
*/
|
|
61
|
+
export function categorizeFinance(amount: number, type?: string): FinanceType {
|
|
62
|
+
if (type === 'income' || type === 'expense') {
|
|
63
|
+
return type;
|
|
64
|
+
}
|
|
65
|
+
// 根据金额正负判断
|
|
66
|
+
return amount >= 0 ? 'income' : 'expense';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 获取默认分类
|
|
71
|
+
*/
|
|
72
|
+
export function getDefaultCategory(type: FinanceType): string {
|
|
73
|
+
return type === 'income' ? 'other_income' : 'other';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 验证分类是否有效
|
|
78
|
+
*/
|
|
79
|
+
export function isValidCategory(type: FinanceType, category: string): boolean {
|
|
80
|
+
const categories = type === 'income' ? INCOME_CATEGORIES : EXPENSE_CATEGORIES;
|
|
81
|
+
return (categories as readonly string[]).some(c => c === category);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 计算账户余额
|
|
86
|
+
*/
|
|
87
|
+
export function calculateBalance(items: FinanceItem[]): number {
|
|
88
|
+
return items.reduce((balance, item) => {
|
|
89
|
+
if (item.type === 'income') {
|
|
90
|
+
return balance + item.amount;
|
|
91
|
+
} else {
|
|
92
|
+
return balance - item.amount;
|
|
93
|
+
}
|
|
94
|
+
}, 0);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 计算账单统计
|
|
99
|
+
*/
|
|
100
|
+
export function calculateFinanceStats(items: FinanceItem[]): FinanceStats {
|
|
101
|
+
const stats: FinanceStats = {
|
|
102
|
+
totalIncome: 0,
|
|
103
|
+
totalExpense: 0,
|
|
104
|
+
netBalance: 0,
|
|
105
|
+
byCategory: {},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
for (const item of items) {
|
|
109
|
+
if (!stats.byCategory[item.category]) {
|
|
110
|
+
stats.byCategory[item.category] = { income: 0, expense: 0 };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (item.type === 'income') {
|
|
114
|
+
stats.totalIncome += item.amount;
|
|
115
|
+
stats.byCategory[item.category].income += item.amount;
|
|
116
|
+
} else {
|
|
117
|
+
stats.totalExpense += item.amount;
|
|
118
|
+
stats.byCategory[item.category].expense += item.amount;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
stats.netBalance = stats.totalIncome - stats.totalExpense;
|
|
123
|
+
return stats;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 格式化金额
|
|
128
|
+
* @param amount 金额
|
|
129
|
+
* @param currency 货币符号,默认 ¥
|
|
130
|
+
* @param showSign 是否显示正负号,默认 false
|
|
131
|
+
*/
|
|
132
|
+
export function formatAmount(
|
|
133
|
+
amount: number,
|
|
134
|
+
currency: string = '¥',
|
|
135
|
+
showSign: boolean = false
|
|
136
|
+
): string {
|
|
137
|
+
const formatted = Math.abs(amount).toFixed(2);
|
|
138
|
+
const sign = showSign ? (amount >= 0 ? '+' : '-') : '';
|
|
139
|
+
|
|
140
|
+
// 格式化千分位
|
|
141
|
+
const parts = formatted.split('.');
|
|
142
|
+
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
143
|
+
|
|
144
|
+
return `${sign}${currency}${parts.join('.')}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 解析金额字符串
|
|
149
|
+
*/
|
|
150
|
+
export function parseAmount(amountStr: string): number {
|
|
151
|
+
// 移除货币符号和千分位逗号
|
|
152
|
+
const cleaned = amountStr
|
|
153
|
+
.replace(/[¥$,]/g, '')
|
|
154
|
+
.trim();
|
|
155
|
+
const num = parseFloat(cleaned);
|
|
156
|
+
return isNaN(num) ? 0 : num;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 判断是否为有效金额
|
|
161
|
+
*/
|
|
162
|
+
export function isValidAmount(amount: number): boolean {
|
|
163
|
+
return !isNaN(amount) && isFinite(amount) && amount !== 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 计算日均支出
|
|
168
|
+
*/
|
|
169
|
+
export function calculateDailyAverage(expense: number, days: number): number {
|
|
170
|
+
if (days <= 0) return 0;
|
|
171
|
+
return expense / days;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 计算月度预算剩余
|
|
176
|
+
*/
|
|
177
|
+
export function calculateBudgetRemaining(
|
|
178
|
+
totalBudget: number,
|
|
179
|
+
spent: number
|
|
180
|
+
): number {
|
|
181
|
+
return totalBudget - spent;
|
|
182
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 全局输出格式化工具
|
|
3
|
+
* 支持 --json / --quiet / --dry-run 全局参数
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// 全局标志(由 index.ts 在解析全局参数后设置)
|
|
7
|
+
export let globalJsonFlag = false;
|
|
8
|
+
export let globalQuietFlag = false;
|
|
9
|
+
export let globalDryRunFlag = false;
|
|
10
|
+
|
|
11
|
+
export function setGlobalFlags(args: { json?: boolean; quiet?: boolean; dryRun?: boolean }) {
|
|
12
|
+
if (args.json !== undefined) globalJsonFlag = args.json;
|
|
13
|
+
if (args.quiet !== undefined) globalQuietFlag = args.quiet;
|
|
14
|
+
if (args.dryRun !== undefined) globalDryRunFlag = args.dryRun;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 统一输出函数
|
|
19
|
+
*/
|
|
20
|
+
export function output(data: any, options: { quiet?: boolean } = {}): void {
|
|
21
|
+
if (globalQuietFlag || options.quiet) return;
|
|
22
|
+
|
|
23
|
+
if (globalJsonFlag) {
|
|
24
|
+
console.log(JSON.stringify(data, null, 2));
|
|
25
|
+
} else {
|
|
26
|
+
console.log(typeof data === 'string' ? data : JSON.stringify(data, null, 2));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 错误输出
|
|
32
|
+
*/
|
|
33
|
+
export function outputError(message: string, ...args: any[]): void {
|
|
34
|
+
if (!globalQuietFlag) {
|
|
35
|
+
console.error(`\x1b[31m✖ ${message}\x1b[0m`, ...args);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 成功输出
|
|
41
|
+
*/
|
|
42
|
+
export function outputSuccess(message: string, ...args: any[]): void {
|
|
43
|
+
if (!globalQuietFlag) {
|
|
44
|
+
console.log(`\x1b[32m✔ ${message}\x1b[0m`, ...args);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Simple loading indicator (ora ESM issue workaround)
|
|
49
|
+
const spinners = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
50
|
+
let spinnerInterval: NodeJS.Timeout | null = null;
|
|
51
|
+
|
|
52
|
+
export interface LoadingInstance {
|
|
53
|
+
start: () => void;
|
|
54
|
+
succeed: (text?: string) => void;
|
|
55
|
+
fail: (text?: string) => void;
|
|
56
|
+
stop: () => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 创建 Loading 实例(简单实现,兼容 CJS)
|
|
61
|
+
*/
|
|
62
|
+
export function createLoading(text: string): LoadingInstance {
|
|
63
|
+
let currentText = text;
|
|
64
|
+
let index = 0;
|
|
65
|
+
let started = false;
|
|
66
|
+
|
|
67
|
+
const instance: LoadingInstance = {
|
|
68
|
+
start: () => {
|
|
69
|
+
if (started || globalQuietFlag) return;
|
|
70
|
+
started = true;
|
|
71
|
+
process.stdout.write('\x1b[?25l'); // hide cursor
|
|
72
|
+
spinnerInterval = setInterval(() => {
|
|
73
|
+
process.stdout.write(`\r${spinners[index % spinners.length]} ${currentText}`);
|
|
74
|
+
index++;
|
|
75
|
+
}, 80);
|
|
76
|
+
},
|
|
77
|
+
succeed: (text?: string) => {
|
|
78
|
+
if (spinnerInterval) {
|
|
79
|
+
clearInterval(spinnerInterval);
|
|
80
|
+
spinnerInterval = null;
|
|
81
|
+
}
|
|
82
|
+
process.stdout.write('\x1b[?25h'); // show cursor
|
|
83
|
+
process.stdout.write('\r' + ' '.repeat(currentText.length + 3) + '\r');
|
|
84
|
+
if (!globalQuietFlag) {
|
|
85
|
+
console.log(`\x1b[32m✔ ${text || currentText}\x1b[0m`);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
fail: (text?: string) => {
|
|
89
|
+
if (spinnerInterval) {
|
|
90
|
+
clearInterval(spinnerInterval);
|
|
91
|
+
spinnerInterval = null;
|
|
92
|
+
}
|
|
93
|
+
process.stdout.write('\x1b[?25h'); // show cursor
|
|
94
|
+
process.stdout.write('\r' + ' '.repeat(currentText.length + 3) + '\r');
|
|
95
|
+
if (!globalQuietFlag) {
|
|
96
|
+
console.error(`\x1b[31m✖ ${text || currentText}\x1b[0m`);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
stop: () => {
|
|
100
|
+
if (spinnerInterval) {
|
|
101
|
+
clearInterval(spinnerInterval);
|
|
102
|
+
spinnerInterval = null;
|
|
103
|
+
}
|
|
104
|
+
process.stdout.write('\x1b[?25h'); // show cursor
|
|
105
|
+
process.stdout.write('\r' + ' '.repeat(currentText.length + 3) + '\r');
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return instance;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Dry-run 检查,如果处于预览模式则输出并返回 true
|
|
114
|
+
*/
|
|
115
|
+
export function isDryRun(): boolean {
|
|
116
|
+
return globalDryRunFlag;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function dryRunLog(action: string, details?: any): void {
|
|
120
|
+
if (globalDryRunFlag) {
|
|
121
|
+
console.log(`\x1b[33m[DRY-RUN]\x1b[0m ${action}`);
|
|
122
|
+
if (details && !globalQuietFlag) {
|
|
123
|
+
console.log(JSON.stringify(details, null, 2));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|