koishi-plugin-chat-analyse 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/Renderer.d.ts +29 -25
- package/lib/Stat.d.ts +28 -8
- package/lib/index.js +330 -229
- package/package.json +1 -1
package/lib/Renderer.d.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { Context } from 'koishi';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
* @description 定义了统计列表中单行数据的格式,它是一个由字符串、数字或日期组成的元组。
|
|
3
|
+
* 定义了渲染列表中单行数据的格式。它是一个由字符串、数字或 `Date` 对象组成的数组。
|
|
5
4
|
*/
|
|
6
5
|
export type RenderListItem = (string | number | Date)[];
|
|
7
6
|
/**
|
|
8
7
|
* @interface ListRenderData
|
|
9
|
-
* @description
|
|
8
|
+
* @description 定义了调用 `renderList` 方法所需的数据结构。
|
|
9
|
+
* 它包含了渲染一张完整列表图片所必需的所有信息。
|
|
10
10
|
*/
|
|
11
11
|
export interface ListRenderData {
|
|
12
12
|
title: string;
|
|
@@ -16,41 +16,45 @@ export interface ListRenderData {
|
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
18
|
* @class Renderer
|
|
19
|
-
* @
|
|
20
|
-
*
|
|
19
|
+
* @classdesc
|
|
20
|
+
* 负责将结构化的数据(特别是列表)转换为设计精美的PNG图片。
|
|
21
|
+
* 其核心特性是能够动态计算内容尺寸,生成布局紧凑、自适应的图片。
|
|
21
22
|
*/
|
|
22
23
|
export declare class Renderer {
|
|
23
24
|
private ctx;
|
|
24
25
|
/**
|
|
25
|
-
* @
|
|
26
|
-
* @param {Context} ctx - Koishi 的插件上下文,用于访问 puppeteer 服务。
|
|
26
|
+
* @param {Context} ctx - Koishi 的插件上下文,用于访问核心服务如 `puppeteer` 和 `logger`。
|
|
27
27
|
*/
|
|
28
28
|
constructor(ctx: Context);
|
|
29
29
|
/**
|
|
30
|
-
* @
|
|
31
|
-
* @
|
|
32
|
-
* @
|
|
33
|
-
*
|
|
34
|
-
* @param {
|
|
35
|
-
* @
|
|
36
|
-
* @
|
|
30
|
+
* @private
|
|
31
|
+
* @method htmlToImage
|
|
32
|
+
* @description
|
|
33
|
+
* 负责将任意HTML字符串转换为PNG图片Buffer。
|
|
34
|
+
* @param {string} html - 要渲染的HTML主体内容(不包含 `<html>` 和 `<body>` 标签)。
|
|
35
|
+
* @returns {Promise<Buffer>} 返回一个包含PNG图片数据的 Buffer 对象。
|
|
36
|
+
* @throws {Error} 如果 Puppeteer 截图过程中发生错误,将抛出异常。
|
|
37
37
|
*/
|
|
38
|
-
|
|
38
|
+
private htmlToImage;
|
|
39
39
|
/**
|
|
40
40
|
* @private
|
|
41
41
|
* @method formatDate
|
|
42
|
-
* @description
|
|
43
|
-
*
|
|
44
|
-
* @
|
|
42
|
+
* @description
|
|
43
|
+
* 将 `Date` 对象格式化为人类友好的相对时间字符串。
|
|
44
|
+
* @param {Date} date - 需要格式化的日期对象。
|
|
45
|
+
* @returns {string} - 格式化后的时间字符串。
|
|
45
46
|
*/
|
|
46
47
|
private formatDate;
|
|
47
48
|
/**
|
|
48
|
-
* @
|
|
49
|
-
* @method
|
|
50
|
-
* @description
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* @
|
|
49
|
+
* @public
|
|
50
|
+
* @method renderList
|
|
51
|
+
* @description
|
|
52
|
+
* 接收一个标准化的 `ListRenderData` 对象和可选的表头数组,
|
|
53
|
+
* 然后构建一个包含标题、统计信息和数据表格的完整HTML卡片。
|
|
54
|
+
* @param {ListRenderData} data - 包含渲染所需全部信息的对象。
|
|
55
|
+
* @param {string[]} [headers] - (可选) 表格的表头字符串数组。如果提供,将渲染表头。
|
|
56
|
+
* @returns {Promise<string | Buffer>}
|
|
57
|
+
* 如果成功,返回包含PNG图片的 Buffer。如果输入的数据列表为空,则返回一个提示性字符串。
|
|
54
58
|
*/
|
|
55
|
-
|
|
59
|
+
renderList(data: ListRenderData, headers?: string[]): Promise<string | Buffer>;
|
|
56
60
|
}
|
package/lib/Stat.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { Renderer } from './Renderer';
|
|
|
3
3
|
import { Config } from './index';
|
|
4
4
|
/**
|
|
5
5
|
* @class Stat
|
|
6
|
-
* @description
|
|
6
|
+
* @description 提供统一的统计查询服务。负责注册查询命令,从数据库获取数据,并调用渲染器生成图表。
|
|
7
7
|
*/
|
|
8
8
|
export declare class Stat {
|
|
9
9
|
private ctx;
|
|
@@ -16,11 +16,31 @@ export declare class Stat {
|
|
|
16
16
|
*/
|
|
17
17
|
constructor(ctx: Context, config: Config);
|
|
18
18
|
/**
|
|
19
|
+
* @public
|
|
19
20
|
* @method registerCommands
|
|
20
21
|
* @description 根据插件配置,动态地将 `.cmd`, `.msg`, `.rank` 子命令注册到主 `analyse` 命令下。
|
|
21
22
|
* @param {Command} analyse - 主 `analyse` 命令实例。
|
|
22
23
|
*/
|
|
23
24
|
registerCommands(analyse: Command): void;
|
|
25
|
+
/**
|
|
26
|
+
* @private
|
|
27
|
+
* @method parseQueryScope
|
|
28
|
+
* @description 解析命令的选项,将其转换为统一的查询范围对象(userId 和 guildId)。
|
|
29
|
+
* @param {Session} session - 当前会话对象。
|
|
30
|
+
* @param {QueryScopeOptions} options - 命令传入的选项。
|
|
31
|
+
* @returns {QueryScopeResult} 包含 userId、guildId 或 error 信息的查询范围对象。
|
|
32
|
+
*/
|
|
33
|
+
private parseQueryScope;
|
|
34
|
+
/**
|
|
35
|
+
* @private
|
|
36
|
+
* @async
|
|
37
|
+
* @method getUidsInScope
|
|
38
|
+
* @description 根据查询范围(guildId, userId)获取匹配用户的 UID 列表。
|
|
39
|
+
* @param {string} [guildId] - (可选) 群组 ID。
|
|
40
|
+
* @param {string} [userId] - (可选) 用户 ID。
|
|
41
|
+
* @returns {Promise<{ uids?: number[], error?: string }>} 包含 UID 数组或错误信息的对象。
|
|
42
|
+
*/
|
|
43
|
+
private getUidsInScope;
|
|
24
44
|
/**
|
|
25
45
|
* @private
|
|
26
46
|
* @async
|
|
@@ -48,13 +68,13 @@ export declare class Stat {
|
|
|
48
68
|
/**
|
|
49
69
|
* @private
|
|
50
70
|
* @async
|
|
51
|
-
* @method
|
|
52
|
-
* @description
|
|
71
|
+
* @method getUserMessageStats
|
|
72
|
+
* @description 从数据库中获取并聚合每个用户的消息统计数据。
|
|
53
73
|
* @param {string} [guildId] - (可选) 若提供,则将范围限制在此群组。
|
|
54
74
|
* @param {string} [userId] - (可选) 若提供,则将范围限制在此用户。
|
|
55
75
|
* @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
|
|
56
76
|
*/
|
|
57
|
-
private
|
|
77
|
+
private getUserMessageStats;
|
|
58
78
|
/**
|
|
59
79
|
* @private
|
|
60
80
|
* @async
|
|
@@ -63,17 +83,17 @@ export declare class Stat {
|
|
|
63
83
|
* @param {string} type - 要查询的消息类型。
|
|
64
84
|
* @param {string} [guildId] - (可选) 若提供,则将范围限制在此群组。
|
|
65
85
|
* @param {string} [userId] - (可选) 若提供,则将范围限制在此用户。
|
|
66
|
-
* @returns {Promise<{ list: RenderListItem[], total: number } | string>}
|
|
86
|
+
* @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
|
|
67
87
|
*/
|
|
68
88
|
private getMessageStatsByType;
|
|
69
89
|
/**
|
|
70
90
|
* @private
|
|
71
91
|
* @async
|
|
72
92
|
* @method getActiveUserStats
|
|
73
|
-
* @description
|
|
74
|
-
* @param {string} guildId - 要查询的群组 ID。
|
|
93
|
+
* @description 从数据库中获取并聚合指定时间范围内的活跃用户排行数据。
|
|
75
94
|
* @param {number} hours - 查询过去的小时数。
|
|
76
|
-
* @
|
|
95
|
+
* @param {string} [guildId] - (可选) 要查询的群组 ID。若不提供,则进行全局排行。
|
|
96
|
+
* @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
|
|
77
97
|
*/
|
|
78
98
|
private getActiveUserStats;
|
|
79
99
|
}
|
package/lib/index.js
CHANGED
|
@@ -267,8 +267,7 @@ var import_koishi3 = require("koishi");
|
|
|
267
267
|
var import_koishi2 = require("koishi");
|
|
268
268
|
var Renderer = class {
|
|
269
269
|
/**
|
|
270
|
-
* @
|
|
271
|
-
* @param {Context} ctx - Koishi 的插件上下文,用于访问 puppeteer 服务。
|
|
270
|
+
* @param {Context} ctx - Koishi 的插件上下文,用于访问核心服务如 `puppeteer` 和 `logger`。
|
|
272
271
|
*/
|
|
273
272
|
constructor(ctx) {
|
|
274
273
|
this.ctx = ctx;
|
|
@@ -277,102 +276,217 @@ var Renderer = class {
|
|
|
277
276
|
__name(this, "Renderer");
|
|
278
277
|
}
|
|
279
278
|
/**
|
|
280
|
-
* @
|
|
281
|
-
* @
|
|
282
|
-
* @
|
|
283
|
-
*
|
|
284
|
-
* @param {
|
|
285
|
-
* @
|
|
286
|
-
* @
|
|
279
|
+
* @private
|
|
280
|
+
* @method htmlToImage
|
|
281
|
+
* @description
|
|
282
|
+
* 负责将任意HTML字符串转换为PNG图片Buffer。
|
|
283
|
+
* @param {string} html - 要渲染的HTML主体内容(不包含 `<html>` 和 `<body>` 标签)。
|
|
284
|
+
* @returns {Promise<Buffer>} 返回一个包含PNG图片数据的 Buffer 对象。
|
|
285
|
+
* @throws {Error} 如果 Puppeteer 截图过程中发生错误,将抛出异常。
|
|
287
286
|
*/
|
|
288
|
-
async
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
287
|
+
async htmlToImage(html) {
|
|
288
|
+
let page = null;
|
|
289
|
+
try {
|
|
290
|
+
page = await this.ctx.puppeteer.page();
|
|
291
|
+
await page.setViewport({ width: 720, height: 1080, deviceScaleFactor: 2 });
|
|
292
|
+
await page.setContent(`
|
|
293
|
+
<!DOCTYPE html>
|
|
294
|
+
<html>
|
|
295
|
+
<head>
|
|
296
|
+
<meta charset="UTF-8">
|
|
297
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
298
|
+
<style>
|
|
299
|
+
:root {
|
|
300
|
+
--card-bg: #ffffff; --text-color: #111827; --header-color: #111827;
|
|
301
|
+
--sub-text-color: #6b7280; --border-color: #e5e7eb; --accent-color: #3b82f6;
|
|
302
|
+
--chip-bg: #f3f4f6; --stripe-bg: #f9fafb; --gold: #f59e0b; --silver: #9ca3af; --bronze: #a16207;
|
|
303
|
+
}
|
|
304
|
+
body {
|
|
305
|
+
display: inline-block; /* Crucial for shrink-wrapping */
|
|
306
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
307
|
+
background: transparent; margin: 0; padding: 10px;
|
|
308
|
+
-webkit-font-smoothing: antialiased;
|
|
309
|
+
}
|
|
310
|
+
.container {
|
|
311
|
+
display: inline-block; background: var(--card-bg);
|
|
312
|
+
border-radius: 12px; padding: 0; overflow: hidden;
|
|
313
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
|
314
|
+
}
|
|
315
|
+
.header { padding: 12px 16px; }
|
|
316
|
+
.header-table { border-collapse: collapse; table-layout: auto; width: 100%; }
|
|
317
|
+
.header-table-left, .header-table-right { width: 1%; white-space: nowrap; }
|
|
318
|
+
.header-table-left { text-align: left; }
|
|
319
|
+
.header-table-center { text-align: center; }
|
|
320
|
+
.header-table-right { text-align: right; }
|
|
321
|
+
.title-text { font-size: 18px; font-weight: 600; color: var(--header-color); margin: 0; }
|
|
322
|
+
.stat-chip, .time-label {
|
|
323
|
+
display: inline-flex; align-items: baseline; padding: 5px 10px; border-radius: 8px;
|
|
324
|
+
background: var(--chip-bg); font-size: 13px; color: var(--sub-text-color);
|
|
325
|
+
}
|
|
326
|
+
.stat-chip span { font-weight: 600; color: var(--text-color); margin-left: 4px; }
|
|
327
|
+
.table-container { border-top: 1px solid var(--border-color); }
|
|
328
|
+
.main-table { border-collapse: collapse; table-layout: auto; width: 100%; }
|
|
329
|
+
.main-table th, .main-table td {
|
|
330
|
+
padding: 10px 16px;
|
|
331
|
+
vertical-align: middle;
|
|
332
|
+
}
|
|
333
|
+
.main-table th {
|
|
334
|
+
font-size: 12px; font-weight: 500; color: var(--sub-text-color);
|
|
335
|
+
text-transform: uppercase; letter-spacing: 0.05em; background: var(--stripe-bg);
|
|
336
|
+
}
|
|
337
|
+
.main-table td { font-size: 14px; color: var(--text-color); }
|
|
338
|
+
.main-table tbody tr:nth-child(even) { background-color: var(--stripe-bg); }
|
|
339
|
+
.main-table .name-cell, .main-table .name-header {
|
|
340
|
+
text-align: left;
|
|
341
|
+
white-space: normal;
|
|
342
|
+
}
|
|
343
|
+
.main-table .rank-cell, .main-table .count-cell, .main-table .date-cell, .main-table .percent-cell, .main-table .header-right-align {
|
|
344
|
+
text-align: right;
|
|
345
|
+
white-space: nowrap;
|
|
346
|
+
width: 1%;
|
|
347
|
+
font-variant-numeric: tabular-nums;
|
|
348
|
+
}
|
|
349
|
+
.name-cell { font-weight: 500; }
|
|
350
|
+
.rank-cell { font-weight: 500; color: var(--sub-text-color); }
|
|
351
|
+
.count-cell { font-weight: 600; color: var(--accent-color); }
|
|
352
|
+
.date-cell { color: var(--sub-text-color); }
|
|
353
|
+
.rank-gold, .rank-silver, .rank-bronze { font-weight: 600; }
|
|
354
|
+
.rank-gold { color: var(--gold) !important; }
|
|
355
|
+
.rank-silver { color: var(--silver) !important; }
|
|
356
|
+
.rank-bronze { color: var(--bronze) !important; }
|
|
357
|
+
.percent-cell { position: relative; }
|
|
358
|
+
.percent-bar { position: absolute; top: 0; left: 0; height: 100%; background-color: var(--accent-color); opacity: 0.1; }
|
|
359
|
+
.percent-text { position: relative; z-index: 1; }
|
|
360
|
+
</style>
|
|
361
|
+
</head>
|
|
362
|
+
<body>${html}</body>
|
|
363
|
+
</html>
|
|
364
|
+
`, { waitUntil: "networkidle0" });
|
|
365
|
+
const dimensions = await page.evaluate(() => {
|
|
366
|
+
const el = document.body;
|
|
367
|
+
return {
|
|
368
|
+
width: el.scrollWidth,
|
|
369
|
+
height: el.scrollHeight
|
|
370
|
+
};
|
|
371
|
+
});
|
|
372
|
+
await page.setViewport({ ...dimensions, deviceScaleFactor: 2 });
|
|
373
|
+
return await page.screenshot({ type: "png", fullPage: true, omitBackground: true });
|
|
374
|
+
} catch (error) {
|
|
375
|
+
this.ctx.logger.error("图片渲染出错:", error);
|
|
376
|
+
throw new Error(`图片渲染出错: ${error.message || "未知错误"}`);
|
|
377
|
+
} finally {
|
|
378
|
+
if (page) await page.close().catch(() => {
|
|
379
|
+
});
|
|
380
|
+
}
|
|
292
381
|
}
|
|
293
382
|
/**
|
|
294
383
|
* @private
|
|
295
384
|
* @method formatDate
|
|
296
|
-
* @description
|
|
297
|
-
*
|
|
298
|
-
* @
|
|
385
|
+
* @description
|
|
386
|
+
* 将 `Date` 对象格式化为人类友好的相对时间字符串。
|
|
387
|
+
* @param {Date} date - 需要格式化的日期对象。
|
|
388
|
+
* @returns {string} - 格式化后的时间字符串。
|
|
299
389
|
*/
|
|
300
390
|
formatDate(date) {
|
|
301
391
|
if (!date) return "未知";
|
|
302
392
|
const diff = Date.now() - date.getTime();
|
|
303
393
|
if (diff < import_koishi2.Time.minute) return "刚刚";
|
|
304
|
-
if (diff
|
|
305
|
-
|
|
306
|
-
|
|
394
|
+
if (diff > 365 * import_koishi2.Time.day) {
|
|
395
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
396
|
+
}
|
|
397
|
+
const timeUnits = [
|
|
398
|
+
{ unit: "月", ms: 30 * import_koishi2.Time.day },
|
|
399
|
+
{ unit: "天", ms: import_koishi2.Time.day },
|
|
400
|
+
{ unit: "时", ms: import_koishi2.Time.hour },
|
|
401
|
+
{ unit: "分", ms: import_koishi2.Time.minute }
|
|
402
|
+
];
|
|
403
|
+
let remainingDiff = diff;
|
|
404
|
+
const parts = [];
|
|
405
|
+
for (const { unit, ms } of timeUnits) {
|
|
406
|
+
if (remainingDiff >= ms) {
|
|
407
|
+
const value = Math.floor(remainingDiff / ms);
|
|
408
|
+
parts.push(`${value}${unit}`);
|
|
409
|
+
remainingDiff %= ms;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const result = parts.slice(0, 2).join("");
|
|
413
|
+
return result ? `${result}前` : "刚刚";
|
|
307
414
|
}
|
|
308
415
|
/**
|
|
309
|
-
* @
|
|
310
|
-
* @method
|
|
311
|
-
* @description
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
* @
|
|
416
|
+
* @public
|
|
417
|
+
* @method renderList
|
|
418
|
+
* @description
|
|
419
|
+
* 接收一个标准化的 `ListRenderData` 对象和可选的表头数组,
|
|
420
|
+
* 然后构建一个包含标题、统计信息和数据表格的完整HTML卡片。
|
|
421
|
+
* @param {ListRenderData} data - 包含渲染所需全部信息的对象。
|
|
422
|
+
* @param {string[]} [headers] - (可选) 表格的表头字符串数组。如果提供,将渲染表头。
|
|
423
|
+
* @returns {Promise<string | Buffer>}
|
|
424
|
+
* 如果成功,返回包含PNG图片的 Buffer。如果输入的数据列表为空,则返回一个提示性字符串。
|
|
315
425
|
*/
|
|
316
|
-
|
|
317
|
-
const { title, time,
|
|
318
|
-
if (!list?.length) return
|
|
319
|
-
|
|
426
|
+
async renderList(data, headers) {
|
|
427
|
+
const { title, time, list } = data;
|
|
428
|
+
if (!list?.length) return "暂无数据可供渲染";
|
|
429
|
+
let totalValueForPercent = 0;
|
|
430
|
+
const countHeaderIndex = headers?.findIndex((h2) => ["总计发言", "条数", "次数", "数量"].includes(h2));
|
|
431
|
+
if (countHeaderIndex > -1) {
|
|
432
|
+
totalValueForPercent = list.reduce((sum, row) => sum + (Number(row[countHeaderIndex]) || 0), 0);
|
|
433
|
+
}
|
|
434
|
+
const totalCount = data.total || totalValueForPercent;
|
|
435
|
+
const tableHeadHtml = headers?.length > 0 ? `<thead><tr><th class="rank-cell">#</th>${headers.map((h2, i) => {
|
|
436
|
+
const firstCell = list[0]?.[i];
|
|
437
|
+
const isRightAlign = typeof firstCell === "number" || firstCell instanceof Date || h2.includes("占比");
|
|
438
|
+
const alignClass = isRightAlign ? "header-right-align" : "name-header";
|
|
439
|
+
return `<th class="${alignClass}">${h2}</th>`;
|
|
440
|
+
}).join("")}</tr></thead>` : "";
|
|
320
441
|
const tableRowsHtml = list.map((row, index) => {
|
|
321
442
|
const rank = index + 1;
|
|
322
443
|
const rankClass = rank === 1 ? "rank-gold" : rank === 2 ? "rank-silver" : rank === 3 ? "rank-bronze" : "";
|
|
323
|
-
const rankCell = `<td class="rank-cell
|
|
324
|
-
const dataCells = row.map((cell) => {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
444
|
+
const rankCell = `<td class="rank-cell ${rankClass}">${rank}</td>`;
|
|
445
|
+
const dataCells = row.map((cell, i) => {
|
|
446
|
+
let className = "";
|
|
447
|
+
let content;
|
|
448
|
+
const headerText = headers?.[i] || "";
|
|
449
|
+
if (headerText.includes("占比")) {
|
|
450
|
+
className = "percent-cell";
|
|
451
|
+
const percentValue = parseFloat(String(cell).replace("%", ""));
|
|
452
|
+
content = `<div class="percent-bar" style="width: ${percentValue}%;"></div><span class="percent-text">${cell}</span>`;
|
|
453
|
+
} else if (cell instanceof Date) {
|
|
454
|
+
className = "date-cell";
|
|
455
|
+
content = this.formatDate(cell);
|
|
456
|
+
} else if (typeof cell === "number") {
|
|
457
|
+
className = "count-cell";
|
|
458
|
+
content = cell.toLocaleString();
|
|
459
|
+
} else {
|
|
460
|
+
className = "name-cell";
|
|
461
|
+
content = String(cell);
|
|
462
|
+
}
|
|
463
|
+
return `<td class="${className}">${content}</td>`;
|
|
328
464
|
}).join("");
|
|
329
465
|
return `<tr>${rankCell}${dataCells}</tr>`;
|
|
330
466
|
}).join("");
|
|
331
|
-
const
|
|
332
|
-
<div class="
|
|
333
|
-
|
|
334
|
-
|
|
467
|
+
const cardHtml = `
|
|
468
|
+
<div class="container">
|
|
469
|
+
<div class="header">
|
|
470
|
+
<table class="header-table">
|
|
471
|
+
<tr>
|
|
472
|
+
<td class="header-table-left">
|
|
473
|
+
<div class="stat-chip">总计: <span>${typeof totalCount === "number" ? totalCount.toLocaleString() : totalCount}</span></div>
|
|
474
|
+
</td>
|
|
475
|
+
<td class="header-table-center">
|
|
476
|
+
<h1 class="title-text">${title}</h1>
|
|
477
|
+
</td>
|
|
478
|
+
<td class="header-table-right">
|
|
479
|
+
<div class="time-label">${time.toLocaleString("zh-CN", { hour12: false }).replace(/\//g, "-")}</div>
|
|
480
|
+
</td>
|
|
481
|
+
</tr>
|
|
482
|
+
</table>
|
|
483
|
+
</div>
|
|
484
|
+
<div class="table-container">
|
|
485
|
+
<table class="main-table">${tableHeadHtml}<tbody>${tableRowsHtml}</tbody></table>
|
|
486
|
+
</div>
|
|
335
487
|
</div>
|
|
336
488
|
`;
|
|
337
|
-
|
|
338
|
-
:root {
|
|
339
|
-
--bg-color: #f7f8fa; --card-bg: #ffffff; --text-color: #333; --header-color: #1f2329;
|
|
340
|
-
--sub-text-color: #646a73; --border-color: #e4e6eb; --accent-color: #4a6ee0;
|
|
341
|
-
--gold: #ffc327; --silver: #a8b5c1; --bronze: #d69864;
|
|
342
|
-
}
|
|
343
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: var(--bg-color); margin: 0; padding: 20px; width: 700px; box-sizing: border-box; -webkit-font-smoothing: antialiased; }
|
|
344
|
-
.container { background: var(--card-bg); border-radius: 12px; box-shadow: 0 6px 16px rgba(0,0,0,0.08); padding: 24px; }
|
|
345
|
-
.header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 1px solid var(--border-color); padding-bottom: 16px; margin-bottom: 16px; }
|
|
346
|
-
.title-group h1 { font-size: 24px; font-weight: 700; color: var(--header-color); margin: 0; }
|
|
347
|
-
.meta-group { text-align: right; }
|
|
348
|
-
.meta-group .total-count { font-size: 22px; font-weight: 700; color: var(--accent-color); }
|
|
349
|
-
.meta-group .time-label { font-size: 13px; color: var(--sub-text-color); margin-top: 4px; }
|
|
350
|
-
table { width: 100%; border-collapse: collapse; color: var(--text-color); }
|
|
351
|
-
th, td { padding: 12px 8px; text-align: left; border-bottom: 1px solid var(--border-color); vertical-align: middle; }
|
|
352
|
-
th { font-size: 13px; font-weight: 600; color: var(--sub-text-color); }
|
|
353
|
-
td { font-size: 15px; }
|
|
354
|
-
tr:last-child td { border-bottom: none; }
|
|
355
|
-
.rank-cell { width: 50px; text-align: center; }
|
|
356
|
-
.rank-badge { display: inline-block; width: 24px; height: 24px; line-height: 24px; border-radius: 50%; font-weight: 600; font-size: 14px; color: var(--header-color); background-color: #eef0f3; }
|
|
357
|
-
.rank-gold, .rank-silver, .rank-bronze { color: #fff; }
|
|
358
|
-
.rank-gold { background-color: var(--gold); } .rank-silver { background-color: var(--silver); } .rank-bronze { background-color: var(--bronze); }
|
|
359
|
-
.data-cell { word-break: break-all; }
|
|
360
|
-
.name-cell { font-weight: 600; color: var(--header-color); }
|
|
361
|
-
.count-cell { text-align: right; font-weight: 600; color: var(--accent-color); }
|
|
362
|
-
.date-cell { text-align: right; font-size: 13px; color: var(--sub-text-color); }
|
|
363
|
-
`;
|
|
364
|
-
return `
|
|
365
|
-
<!DOCTYPE html><html lang="zh-CN">
|
|
366
|
-
<head><meta charset="UTF-8"><title>${title}</title><style>${styles}</style></head>
|
|
367
|
-
<body>
|
|
368
|
-
<div class="container">
|
|
369
|
-
<div class="header">
|
|
370
|
-
<div class="title-group"><h1>${title}</h1></div>
|
|
371
|
-
${metaInfoHtml}
|
|
372
|
-
</div>
|
|
373
|
-
<table>${tableHeadHtml}<tbody>${tableRowsHtml}</tbody></table>
|
|
374
|
-
</div>
|
|
375
|
-
</body></html>`;
|
|
489
|
+
return this.htmlToImage(cardHtml);
|
|
376
490
|
}
|
|
377
491
|
};
|
|
378
492
|
|
|
@@ -393,32 +507,22 @@ var Stat = class {
|
|
|
393
507
|
}
|
|
394
508
|
renderer;
|
|
395
509
|
/**
|
|
510
|
+
* @public
|
|
396
511
|
* @method registerCommands
|
|
397
512
|
* @description 根据插件配置,动态地将 `.cmd`, `.msg`, `.rank` 子命令注册到主 `analyse` 命令下。
|
|
398
513
|
* @param {Command} analyse - 主 `analyse` 命令实例。
|
|
399
514
|
*/
|
|
400
515
|
registerCommands(analyse) {
|
|
401
516
|
if (this.config.enableCmdStat) {
|
|
402
|
-
analyse.subcommand(".cmd", "命令使用统计").option("user", "-u [user:user] 指定用户").option("guild", "-g [guildId:string] 指定群组").
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
if (!userId && !guildId && session.guildId) {
|
|
406
|
-
guildId = session.guildId;
|
|
407
|
-
} else if (!userId && !guildId && !session.guildId) {
|
|
408
|
-
return "请指定查询范围";
|
|
409
|
-
}
|
|
517
|
+
analyse.subcommand(".cmd", "命令使用统计").option("user", "-u [user:user] 指定用户").option("guild", "-g [guildId:string] 指定群组").option("all", "-a 展示全局统计").action(async ({ session, options }) => {
|
|
518
|
+
const scope = this.parseQueryScope(session, options);
|
|
519
|
+
if (scope.error) return scope.error;
|
|
410
520
|
try {
|
|
411
|
-
const stats = await this.getCommandStats(guildId, userId);
|
|
521
|
+
const stats = await this.getCommandStats(scope.guildId, scope.userId);
|
|
412
522
|
if (typeof stats === "string") return stats;
|
|
413
|
-
const title = await this.generateTitle(guildId, userId, { main: "命令" });
|
|
414
|
-
const renderData = {
|
|
415
|
-
|
|
416
|
-
time: /* @__PURE__ */ new Date(),
|
|
417
|
-
total: stats.total,
|
|
418
|
-
list: stats.list
|
|
419
|
-
};
|
|
420
|
-
const headers = ["命令", "次数", "最后使用"];
|
|
421
|
-
const result = await this.renderer.renderList(renderData, headers);
|
|
523
|
+
const title = await this.generateTitle(scope.guildId, scope.userId, { main: "命令" });
|
|
524
|
+
const renderData = { title, time: /* @__PURE__ */ new Date(), total: stats.total, list: stats.list };
|
|
525
|
+
const result = await this.renderer.renderList(renderData, ["命令", "次数", "最后使用"]);
|
|
422
526
|
return Buffer.isBuffer(result) ? import_koishi3.Element.image(result, "image/png") : result;
|
|
423
527
|
} catch (error) {
|
|
424
528
|
this.ctx.logger.error("渲染命令统计图片失败:", error);
|
|
@@ -427,40 +531,23 @@ var Stat = class {
|
|
|
427
531
|
});
|
|
428
532
|
}
|
|
429
533
|
if (this.config.enableMsgStat) {
|
|
430
|
-
analyse.subcommand(".msg", "消息发送统计").option("user", "-u [user:user] 指定用户").option("guild", "-g [guildId:string] 指定群组").option("type", "-t <type:string> 指定类型").
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
if (!userId && !guildId && !options.type && session.guildId) {
|
|
434
|
-
guildId = session.guildId;
|
|
435
|
-
} else if (!userId && !guildId && !session.guildId) {
|
|
436
|
-
return "请指定查询范围";
|
|
437
|
-
}
|
|
534
|
+
analyse.subcommand(".msg", "消息发送统计").option("user", "-u [user:user] 指定用户").option("guild", "-g [guildId:string] 指定群组").option("type", "-t <type:string> 指定类型").option("all", "-a 展示全局统计").action(async ({ session, options }) => {
|
|
535
|
+
const scope = this.parseQueryScope(session, options);
|
|
536
|
+
if (scope.error) return scope.error;
|
|
438
537
|
try {
|
|
439
538
|
if (options.type) {
|
|
440
|
-
const stats = await this.getMessageStatsByType(options.type, guildId, userId);
|
|
539
|
+
const stats = await this.getMessageStatsByType(options.type, scope.guildId, scope.userId);
|
|
441
540
|
if (typeof stats === "string") return stats;
|
|
442
|
-
const title = await this.generateTitle(guildId,
|
|
443
|
-
const renderData = {
|
|
444
|
-
|
|
445
|
-
time: /* @__PURE__ */ new Date(),
|
|
446
|
-
total: stats.total,
|
|
447
|
-
list: stats.list
|
|
448
|
-
};
|
|
449
|
-
const headers = ["用户", "条数", "最后发言"];
|
|
450
|
-
const result = await this.renderer.renderList(renderData, headers);
|
|
541
|
+
const title = await this.generateTitle(scope.guildId, scope.userId, { main: "消息", subtype: options.type });
|
|
542
|
+
const renderData = { title, time: /* @__PURE__ */ new Date(), total: stats.total, list: stats.list };
|
|
543
|
+
const result = await this.renderer.renderList(renderData, ["用户", "条数", "最后发言"]);
|
|
451
544
|
return Buffer.isBuffer(result) ? import_koishi3.Element.image(result, "image/png") : result;
|
|
452
545
|
} else {
|
|
453
|
-
const stats = await this.
|
|
546
|
+
const stats = await this.getUserMessageStats(scope.guildId, scope.userId);
|
|
454
547
|
if (typeof stats === "string") return stats;
|
|
455
|
-
const title = await this.generateTitle(guildId, userId, { main: "消息" });
|
|
456
|
-
const renderData = {
|
|
457
|
-
|
|
458
|
-
time: /* @__PURE__ */ new Date(),
|
|
459
|
-
total: stats.total,
|
|
460
|
-
list: stats.list
|
|
461
|
-
};
|
|
462
|
-
const headers = ["类型", "条数", "最后发言"];
|
|
463
|
-
const result = await this.renderer.renderList(renderData, headers);
|
|
548
|
+
const title = await this.generateTitle(scope.guildId, scope.userId, { main: "消息" });
|
|
549
|
+
const renderData = { title, time: /* @__PURE__ */ new Date(), total: stats.total, list: stats.list };
|
|
550
|
+
const result = await this.renderer.renderList(renderData, ["用户", "总计发言", "最后发言"]);
|
|
464
551
|
return Buffer.isBuffer(result) ? import_koishi3.Element.image(result, "image/png") : result;
|
|
465
552
|
}
|
|
466
553
|
} catch (error) {
|
|
@@ -470,27 +557,20 @@ var Stat = class {
|
|
|
470
557
|
});
|
|
471
558
|
}
|
|
472
559
|
if (this.config.enableRankStat) {
|
|
473
|
-
analyse.subcommand(".rank", "用户发言排行").option("guild", "-g [guildId:string] 指定群组").option("hours", "-h <hours:number> 指定时长", { fallback: 24 }).
|
|
474
|
-
let guildId = options.guild;
|
|
475
|
-
if (!
|
|
476
|
-
if (!guildId) return "
|
|
560
|
+
analyse.subcommand(".rank", "用户发言排行").option("guild", "-g [guildId:string] 指定群组").option("all", "-a 展示全局统计").option("hours", "-h <hours:number> 指定时长", { fallback: 24 }).action(async ({ session, options }) => {
|
|
561
|
+
let guildId = options.all ? void 0 : typeof options.guild === "string" ? options.guild : session.guildId;
|
|
562
|
+
if (!session.guildId) return "请指定群组 ID";
|
|
563
|
+
if (!guildId && !options.all) return "请提供查询范围";
|
|
477
564
|
try {
|
|
478
|
-
const stats = await this.getActiveUserStats(
|
|
565
|
+
const stats = await this.getActiveUserStats(options.hours, guildId);
|
|
479
566
|
if (typeof stats === "string") return stats;
|
|
480
|
-
const listWithPercentage = stats.list.map((row) =>
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
});
|
|
567
|
+
const listWithPercentage = stats.list.map((row) => [
|
|
568
|
+
...row,
|
|
569
|
+
stats.total > 0 ? `${(row[1] / stats.total * 100).toFixed(2)}%` : "0.00%"
|
|
570
|
+
]);
|
|
485
571
|
const title = await this.generateTitle(guildId, void 0, { main: "排行", timeRange: options.hours });
|
|
486
|
-
const renderData = {
|
|
487
|
-
|
|
488
|
-
time: /* @__PURE__ */ new Date(),
|
|
489
|
-
total: stats.total,
|
|
490
|
-
list: listWithPercentage
|
|
491
|
-
};
|
|
492
|
-
const headers = ["用户", "总计发言", "占比"];
|
|
493
|
-
const result = await this.renderer.renderList(renderData, headers);
|
|
572
|
+
const renderData = { title, time: /* @__PURE__ */ new Date(), total: stats.total, list: listWithPercentage };
|
|
573
|
+
const result = await this.renderer.renderList(renderData, ["用户", "总计发言", "占比"]);
|
|
494
574
|
return Buffer.isBuffer(result) ? import_koishi3.Element.image(result, "image/png") : result;
|
|
495
575
|
} catch (error) {
|
|
496
576
|
this.ctx.logger.error("渲染发言排行图片失败:", error);
|
|
@@ -499,6 +579,44 @@ var Stat = class {
|
|
|
499
579
|
});
|
|
500
580
|
}
|
|
501
581
|
}
|
|
582
|
+
/**
|
|
583
|
+
* @private
|
|
584
|
+
* @method parseQueryScope
|
|
585
|
+
* @description 解析命令的选项,将其转换为统一的查询范围对象(userId 和 guildId)。
|
|
586
|
+
* @param {Session} session - 当前会话对象。
|
|
587
|
+
* @param {QueryScopeOptions} options - 命令传入的选项。
|
|
588
|
+
* @returns {QueryScopeResult} 包含 userId、guildId 或 error 信息的查询范围对象。
|
|
589
|
+
*/
|
|
590
|
+
parseQueryScope(session, options) {
|
|
591
|
+
let userId, guildId;
|
|
592
|
+
if (typeof options.user === "string") userId = import_koishi3.h.select(options.user, "user")[0]?.attrs.id;
|
|
593
|
+
else if (options.user) userId = session.userId;
|
|
594
|
+
if (typeof options.guild === "string") guildId = options.guild;
|
|
595
|
+
else if (options.guild) {
|
|
596
|
+
if (!session.guildId) return { error: "请指定群组 ID" };
|
|
597
|
+
guildId = session.guildId;
|
|
598
|
+
}
|
|
599
|
+
if (options.all) return { userId, guildId: void 0 };
|
|
600
|
+
if (!guildId && !userId) return session.guildId ? { guildId: session.guildId } : { error: "请提供查询范围" };
|
|
601
|
+
return { userId, guildId };
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* @private
|
|
605
|
+
* @async
|
|
606
|
+
* @method getUidsInScope
|
|
607
|
+
* @description 根据查询范围(guildId, userId)获取匹配用户的 UID 列表。
|
|
608
|
+
* @param {string} [guildId] - (可选) 群组 ID。
|
|
609
|
+
* @param {string} [userId] - (可选) 用户 ID。
|
|
610
|
+
* @returns {Promise<{ uids?: number[], error?: string }>} 包含 UID 数组或错误信息的对象。
|
|
611
|
+
*/
|
|
612
|
+
async getUidsInScope(guildId, userId) {
|
|
613
|
+
const query = {};
|
|
614
|
+
if (guildId) query.channelId = guildId;
|
|
615
|
+
if (userId) query.userId = userId;
|
|
616
|
+
const users = await this.ctx.database.get("analyse_user", query, ["uid"]);
|
|
617
|
+
if (users.length === 0) return { error: "暂无统计数据" };
|
|
618
|
+
return { uids: users.map((u) => u.uid) };
|
|
619
|
+
}
|
|
502
620
|
/**
|
|
503
621
|
* @private
|
|
504
622
|
* @async
|
|
@@ -513,34 +631,21 @@ var Stat = class {
|
|
|
513
631
|
* @returns {Promise<string>} 生成的标题字符串。
|
|
514
632
|
*/
|
|
515
633
|
async generateTitle(guildId, userId, options) {
|
|
516
|
-
let scopeText;
|
|
634
|
+
let scopeText = "全局";
|
|
517
635
|
if (userId && guildId) {
|
|
518
636
|
const user = await this.ctx.database.get("analyse_user", { channelId: guildId, userId }, ["userName"]);
|
|
519
637
|
const guild = await this.ctx.database.get("analyse_user", { channelId: guildId }, ["channelName"]);
|
|
520
|
-
|
|
521
|
-
const guildName = guild[0]?.channelName || guildId;
|
|
522
|
-
scopeText = `${userName} 在 ${guildName}`;
|
|
638
|
+
scopeText = `${user[0]?.userName || userId} 在 ${guild[0]?.channelName || guildId}`;
|
|
523
639
|
} else if (userId) {
|
|
524
640
|
const user = await this.ctx.database.get("analyse_user", { userId }, ["userName"]);
|
|
525
|
-
|
|
526
|
-
scopeText = `${userName}的全局`;
|
|
641
|
+
scopeText = `${user[0]?.userName || userId}的全局`;
|
|
527
642
|
} else if (guildId) {
|
|
528
643
|
const guild = await this.ctx.database.get("analyse_user", { channelId: guildId }, ["channelName"]);
|
|
529
644
|
scopeText = guild[0]?.channelName || guildId;
|
|
530
|
-
} else {
|
|
531
|
-
scopeText = "全局";
|
|
532
|
-
}
|
|
533
|
-
switch (options.main) {
|
|
534
|
-
case "命令":
|
|
535
|
-
return `${scopeText}的命令统计`;
|
|
536
|
-
case "消息":
|
|
537
|
-
if (options.subtype) return `${scopeText}的"${options.subtype}"消息统计`;
|
|
538
|
-
return `${scopeText}的消息统计`;
|
|
539
|
-
case "排行":
|
|
540
|
-
return `${scopeText}的${options.timeRange}小时消息排行`;
|
|
541
|
-
default:
|
|
542
|
-
return scopeText;
|
|
543
645
|
}
|
|
646
|
+
if (options.main === "排行") return `${scopeText}的${options.timeRange}小时消息排行`;
|
|
647
|
+
if (options.main === "消息" && options.subtype) return `${scopeText}的"${options.subtype}"消息统计`;
|
|
648
|
+
return `${scopeText}的${options.main}统计`;
|
|
544
649
|
}
|
|
545
650
|
/**
|
|
546
651
|
* @private
|
|
@@ -552,45 +657,39 @@ var Stat = class {
|
|
|
552
657
|
* @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
|
|
553
658
|
*/
|
|
554
659
|
async getCommandStats(guildId, userId) {
|
|
555
|
-
const
|
|
556
|
-
if (
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"),
|
|
563
|
-
lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed")
|
|
564
|
-
}).orderBy("count", "desc").execute();
|
|
565
|
-
if (aggregatedStats.length === 0) return "暂无统计数据";
|
|
566
|
-
const totalCount = aggregatedStats.reduce((sum, record) => sum + record.count, 0);
|
|
567
|
-
const list = aggregatedStats.map((item) => [item.command, item.count, item.lastUsed]);
|
|
568
|
-
return { list, total: totalCount };
|
|
660
|
+
const { uids, error } = await this.getUidsInScope(guildId, userId);
|
|
661
|
+
if (error) return error;
|
|
662
|
+
const stats = await this.ctx.database.select("analyse_cmd").where({ uid: { $in: uids } }).groupBy("command", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
|
|
663
|
+
if (stats.length === 0) return "暂无统计数据";
|
|
664
|
+
const total = stats.reduce((sum, record) => sum + record.count, 0);
|
|
665
|
+
const list = stats.map((item) => [item.command, item.count, item.lastUsed]);
|
|
666
|
+
return { list, total };
|
|
569
667
|
}
|
|
570
668
|
/**
|
|
571
669
|
* @private
|
|
572
670
|
* @async
|
|
573
|
-
* @method
|
|
574
|
-
* @description
|
|
671
|
+
* @method getUserMessageStats
|
|
672
|
+
* @description 从数据库中获取并聚合每个用户的消息统计数据。
|
|
575
673
|
* @param {string} [guildId] - (可选) 若提供,则将范围限制在此群组。
|
|
576
674
|
* @param {string} [userId] - (可选) 若提供,则将范围限制在此用户。
|
|
577
675
|
* @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
|
|
578
676
|
*/
|
|
579
|
-
async
|
|
580
|
-
const
|
|
581
|
-
if (guildId)
|
|
582
|
-
if (userId)
|
|
583
|
-
const users = await this.ctx.database.get("analyse_user",
|
|
584
|
-
if (users.length === 0) return "
|
|
677
|
+
async getUserMessageStats(guildId, userId) {
|
|
678
|
+
const query = {};
|
|
679
|
+
if (guildId) query.channelId = guildId;
|
|
680
|
+
if (userId) query.userId = userId;
|
|
681
|
+
const users = await this.ctx.database.get("analyse_user", query, ["uid", "userName"]);
|
|
682
|
+
if (users.length === 0) return "暂无统计数据";
|
|
585
683
|
const uids = users.map((u) => u.uid);
|
|
586
|
-
const
|
|
684
|
+
const userNameMap = new Map(users.map((u) => [u.uid, u.userName]));
|
|
685
|
+
const stats = await this.ctx.database.select("analyse_msg").where({ uid: { $in: uids } }).groupBy("uid", {
|
|
587
686
|
count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"),
|
|
588
687
|
lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed")
|
|
589
688
|
}).orderBy("count", "desc").execute();
|
|
590
|
-
if (
|
|
591
|
-
const
|
|
592
|
-
const list =
|
|
593
|
-
return { list, total
|
|
689
|
+
if (stats.length === 0) return "暂无统计数据";
|
|
690
|
+
const total = stats.reduce((sum, record) => sum + record.count, 0);
|
|
691
|
+
const list = stats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count, item.lastUsed]);
|
|
692
|
+
return { list, total };
|
|
594
693
|
}
|
|
595
694
|
/**
|
|
596
695
|
* @private
|
|
@@ -600,60 +699,62 @@ var Stat = class {
|
|
|
600
699
|
* @param {string} type - 要查询的消息类型。
|
|
601
700
|
* @param {string} [guildId] - (可选) 若提供,则将范围限制在此群组。
|
|
602
701
|
* @param {string} [userId] - (可选) 若提供,则将范围限制在此用户。
|
|
603
|
-
* @returns {Promise<{ list: RenderListItem[], total: number } | string>}
|
|
702
|
+
* @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
|
|
604
703
|
*/
|
|
605
704
|
async getMessageStatsByType(type, guildId, userId) {
|
|
606
|
-
const
|
|
607
|
-
if (guildId)
|
|
608
|
-
if (userId)
|
|
609
|
-
const users = await this.ctx.database.get("analyse_user",
|
|
610
|
-
if (users.length === 0) return "
|
|
705
|
+
const query = {};
|
|
706
|
+
if (guildId) query.channelId = guildId;
|
|
707
|
+
if (userId) query.userId = userId;
|
|
708
|
+
const users = await this.ctx.database.get("analyse_user", query, ["uid", "userName"]);
|
|
709
|
+
if (users.length === 0) return "暂无统计数据";
|
|
611
710
|
const uids = users.map((u) => u.uid);
|
|
612
711
|
const userNameMap = new Map(users.map((u) => [u.uid, u.userName]));
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed")
|
|
619
|
-
}).orderBy("count", "desc").execute();
|
|
620
|
-
if (aggregatedStats.length === 0) return `暂无统计数据`;
|
|
621
|
-
const totalCount = aggregatedStats.reduce((sum, record) => sum + record.count, 0);
|
|
622
|
-
const list = aggregatedStats.map((item) => [
|
|
623
|
-
userNameMap.get(item.uid) || `UID ${item.uid}`,
|
|
624
|
-
item.count,
|
|
625
|
-
item.lastUsed
|
|
626
|
-
]);
|
|
627
|
-
return { list, total: totalCount };
|
|
712
|
+
const stats = await this.ctx.database.select("analyse_msg").where({ uid: { $in: uids }, type }).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
|
|
713
|
+
if (stats.length === 0) return `暂无统计数据`;
|
|
714
|
+
const total = stats.reduce((sum, record) => sum + record.count, 0);
|
|
715
|
+
const list = stats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count, item.lastUsed]);
|
|
716
|
+
return { list, total };
|
|
628
717
|
}
|
|
629
718
|
/**
|
|
630
719
|
* @private
|
|
631
720
|
* @async
|
|
632
721
|
* @method getActiveUserStats
|
|
633
|
-
* @description
|
|
634
|
-
* @param {string} guildId - 要查询的群组 ID。
|
|
722
|
+
* @description 从数据库中获取并聚合指定时间范围内的活跃用户排行数据。
|
|
635
723
|
* @param {number} hours - 查询过去的小时数。
|
|
636
|
-
* @
|
|
724
|
+
* @param {string} [guildId] - (可选) 要查询的群组 ID。若不提供,则进行全局排行。
|
|
725
|
+
* @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
|
|
637
726
|
*/
|
|
638
|
-
async getActiveUserStats(
|
|
727
|
+
async getActiveUserStats(hours, guildId) {
|
|
639
728
|
const since = new Date(Date.now() - hours * 3600 * 1e3);
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
uid: { $in: uids },
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
729
|
+
if (guildId) {
|
|
730
|
+
const usersInGuild = await this.ctx.database.get("analyse_user", { channelId: guildId }, ["uid", "userName"]);
|
|
731
|
+
if (usersInGuild.length === 0) return "暂无统计数据";
|
|
732
|
+
const uids = usersInGuild.map((u) => u.uid);
|
|
733
|
+
const userNameMap = new Map(usersInGuild.map((u) => [u.uid, u.userName]));
|
|
734
|
+
const stats = await this.ctx.database.select("analyse_msg").where({ uid: { $in: uids }, hour: { $gte: since } }).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).orderBy("count", "desc").limit(100).execute();
|
|
735
|
+
if (stats.length === 0) return "暂无统计数据";
|
|
736
|
+
const total = stats.reduce((sum, record) => sum + record.count, 0);
|
|
737
|
+
const list = stats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count]);
|
|
738
|
+
return { list, total };
|
|
739
|
+
} else {
|
|
740
|
+
const msgStats = await this.ctx.database.select("analyse_msg").where({ hour: { $gte: since } }).project(["uid", "count"]).execute();
|
|
741
|
+
if (msgStats.length === 0) return "暂无统计数据";
|
|
742
|
+
const allUsers = await this.ctx.database.get("analyse_user", {}, ["uid", "userId", "userName"]);
|
|
743
|
+
const uidToUserMap = new Map(allUsers.map((u) => [u.uid, { userId: u.userId, userName: u.userName }]));
|
|
744
|
+
const userCounts = /* @__PURE__ */ new Map();
|
|
745
|
+
for (const msg of msgStats) {
|
|
746
|
+
const userInfo = uidToUserMap.get(msg.uid);
|
|
747
|
+
if (userInfo) {
|
|
748
|
+
const existing = userCounts.get(userInfo.userId);
|
|
749
|
+
userCounts.set(userInfo.userId, { count: (existing?.count || 0) + msg.count, name: userInfo.userName });
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
if (userCounts.size === 0) return "暂无统计数据";
|
|
753
|
+
const grandTotal = Array.from(userCounts.values()).reduce((sum, data) => sum + data.count, 0);
|
|
754
|
+
const sortedUsers = Array.from(userCounts.entries()).sort(([, a], [, b]) => b.count - a.count).slice(0, 100);
|
|
755
|
+
const list = sortedUsers.map(([userId, data]) => [data.name || userId, data.count]);
|
|
756
|
+
return { list, total: grandTotal };
|
|
757
|
+
}
|
|
657
758
|
}
|
|
658
759
|
};
|
|
659
760
|
|