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 CHANGED
@@ -1,12 +1,12 @@
1
1
  import { Context } from 'koishi';
2
2
  /**
3
- * @typedef {Array<string | number | Date>} RenderListItem
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
- * @description 一个通用的列表渲染器。它使用 Koishi 的 Puppeteer 服务将结构化的 `ListRenderData` 数据
20
- * 渲染为一张包含精美表格的图片。
19
+ * @classdesc
20
+ * 负责将结构化的数据(特别是列表)转换为设计精美的PNG图片。
21
+ * 其核心特性是能够动态计算内容尺寸,生成布局紧凑、自适应的图片。
21
22
  */
22
23
  export declare class Renderer {
23
24
  private ctx;
24
25
  /**
25
- * @constructor
26
- * @param {Context} ctx - Koishi 的插件上下文,用于访问 puppeteer 服务。
26
+ * @param {Context} ctx - Koishi 的插件上下文,用于访问核心服务如 `puppeteer` 和 `logger`。
27
27
  */
28
28
  constructor(ctx: Context);
29
29
  /**
30
- * @public
31
- * @async
32
- * @method renderList
33
- * @description 将列表数据渲染为图片。
34
- * @param {ListRenderData} data - 待渲染的完整列表数据。
35
- * @param {string[]} [headers] - (可选) 表头文案数组。如果提供,将会在表格顶部渲染表头。
36
- * @returns {Promise<string | Buffer>} 渲染成功时返回图片的 Buffer 数据;如果输入数据为空,则返回提示文本。
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
- renderList(data: ListRenderData, headers?: string[]): Promise<string | Buffer>;
38
+ private htmlToImage;
39
39
  /**
40
40
  * @private
41
41
  * @method formatDate
42
- * @description 智能格式化日期。对于近期的时间,提供更人性化的相对时间描述(如“刚刚”,“x 分钟前”);对于较早的时间,则显示标准的“年-月-日”格式。
43
- * @param {Date} date - 待格式化的 Date 对象。
44
- * @returns {string} 格式化后的日期字符串。
42
+ * @description
43
+ * `Date` 对象格式化为人类友好的相对时间字符串。
44
+ * @param {Date} date - 需要格式化的日期对象。
45
+ * @returns {string} - 格式化后的时间字符串。
45
46
  */
46
47
  private formatDate;
47
48
  /**
48
- * @private
49
- * @method generateListHtml
50
- * @description 根据传入的结构化数据和表头,动态生成用于 Puppeteer 渲染的完整 HTML 字符串。
51
- * @param {ListRenderData} data - 列表数据对象。
52
- * @param {string[]} [headers] - (可选) 表头数组。
53
- * @returns {string | null} 返回生成的 HTML 字符串。如果列表数据为空,则返回 `null`。
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
- private generateListHtml;
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 getMessageStats
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 getMessageStats;
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
- * @returns {Promise<{ list: RenderListItem[], total: number } | string>}
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
- * @constructor
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
- * @public
281
- * @async
282
- * @method renderList
283
- * @description 将列表数据渲染为图片。
284
- * @param {ListRenderData} data - 待渲染的完整列表数据。
285
- * @param {string[]} [headers] - (可选) 表头文案数组。如果提供,将会在表格顶部渲染表头。
286
- * @returns {Promise<string | Buffer>} 渲染成功时返回图片的 Buffer 数据;如果输入数据为空,则返回提示文本。
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 renderList(data, headers) {
289
- const htmlContent = this.generateListHtml(data, headers);
290
- if (!htmlContent) return "暂无数据可供渲染";
291
- return this.ctx.puppeteer.render(htmlContent);
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 智能格式化日期。对于近期的时间,提供更人性化的相对时间描述(如“刚刚”,“x 分钟前”);对于较早的时间,则显示标准的“年-月-日”格式。
297
- * @param {Date} date - 待格式化的 Date 对象。
298
- * @returns {string} 格式化后的日期字符串。
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 < import_koishi2.Time.hour) return `${Math.floor(diff / import_koishi2.Time.minute)} 分钟前`;
305
- if (diff < import_koishi2.Time.day) return `${Math.floor(diff / import_koishi2.Time.hour)} 小时前`;
306
- return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
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
- * @private
310
- * @method generateListHtml
311
- * @description 根据传入的结构化数据和表头,动态生成用于 Puppeteer 渲染的完整 HTML 字符串。
312
- * @param {ListRenderData} data - 列表数据对象。
313
- * @param {string[]} [headers] - (可选) 表头数组。
314
- * @returns {string | null} 返回生成的 HTML 字符串。如果列表数据为空,则返回 `null`。
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
- generateListHtml(data, headers) {
317
- const { title, time, total, list } = data;
318
- if (!list?.length) return null;
319
- const tableHeadHtml = headers?.length > 0 ? `<thead><tr><th class="rank-cell">排名</th>${headers.map((h2) => `<th>${h2}</th>`).join("")}</tr></thead>` : "";
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"><span class="rank-badge ${rankClass}">${rank}</span></td>`;
324
- const dataCells = row.map((cell) => {
325
- if (cell instanceof Date) return `<td class="data-cell date-cell">${this.formatDate(cell)}</td>`;
326
- if (typeof cell === "number") return `<td class="data-cell count-cell">${cell}</td>`;
327
- return `<td class="data-cell name-cell">${String(cell)}</td>`;
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 metaInfoHtml = `
332
- <div class="meta-group">
333
- ${total !== void 0 ? `<div class="total-count">总计: ${total}</div>` : ""}
334
- <div class="time-label">生成于 ${time.toLocaleString("zh-CN", { hour12: false })}</div>
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
- const styles = `
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] 指定群组").usage("查询用户或群组的命令使用统计,默认展示全局统计。").action(async ({ session, options }) => {
403
- const userId = options.user ? import_koishi3.h.select(options.user, "user")[0]?.attrs.id : void 0;
404
- let guildId = options.guild;
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
- title,
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> 指定类型").usage("查询用户或群组的消息发送统计,默认展示全局统计。").action(async ({ session, options }) => {
431
- const userId = options.user ? import_koishi3.h.select(options.user, "user")[0]?.attrs.id : void 0;
432
- let guildId = options.guild;
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, void 0, { main: "消息", subtype: options.type });
443
- const renderData = {
444
- title,
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.getMessageStats(guildId, userId);
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
- title,
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 }).usage("查询用户或群组的用户发言排行,默认展示全局统计。").action(async ({ session, options }) => {
474
- let guildId = options.guild;
475
- if (!guildId && session.guildId) guildId = session.guildId;
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(guildId, options.hours);
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
- const count = row[1];
482
- const percentage = stats.total > 0 ? `${(count / stats.total * 100).toFixed(2)}%` : "0.00%";
483
- return [...row, percentage];
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
- title,
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
- const userName = user[0]?.userName || userId;
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
- const userName = user[0]?.userName || userId;
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 userQuery = {};
556
- if (guildId) userQuery.channelId = guildId;
557
- if (userId) userQuery.userId = userId;
558
- const users = await this.ctx.database.get("analyse_user", userQuery, ["uid"]);
559
- if (users.length === 0) return "暂无目标用户统计数据";
560
- const uids = users.map((u) => u.uid);
561
- const aggregatedStats = await this.ctx.database.select("analyse_cmd").where({ uid: { $in: uids } }).groupBy(["command"], {
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 getMessageStats
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 getMessageStats(guildId, userId) {
580
- const userQuery = {};
581
- if (guildId) userQuery.channelId = guildId;
582
- if (userId) userQuery.userId = userId;
583
- const users = await this.ctx.database.get("analyse_user", userQuery, ["uid"]);
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 aggregatedStats = await this.ctx.database.select("analyse_msg").where({ uid: { $in: uids } }).groupBy(["type"], {
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 (aggregatedStats.length === 0) return "暂无统计数据";
591
- const totalCount = aggregatedStats.reduce((sum, record) => sum + record.count, 0);
592
- const list = aggregatedStats.map((item) => [item.type, item.count, item.lastUsed]);
593
- return { list, total: totalCount };
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 userQuery = {};
607
- if (guildId) userQuery.channelId = guildId;
608
- if (userId) userQuery.userId = userId;
609
- const users = await this.ctx.database.get("analyse_user", userQuery, ["uid", "userName"]);
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 aggregatedStats = await this.ctx.database.select("analyse_msg").where({
614
- uid: { $in: uids },
615
- type
616
- }).groupBy(["uid"], {
617
- count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"),
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
- * @returns {Promise<{ list: RenderListItem[], total: number } | string>}
724
+ * @param {string} [guildId] - (可选) 要查询的群组 ID。若不提供,则进行全局排行。
725
+ * @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
637
726
  */
638
- async getActiveUserStats(guildId, hours) {
727
+ async getActiveUserStats(hours, guildId) {
639
728
  const since = new Date(Date.now() - hours * 3600 * 1e3);
640
- const usersInGuild = await this.ctx.database.get("analyse_user", { channelId: guildId }, ["uid", "userId", "userName"]);
641
- if (usersInGuild.length === 0) return "暂无用户统计数据";
642
- const uids = usersInGuild.map((u) => u.uid);
643
- const userNameMap = new Map(usersInGuild.map((u) => [u.uid, u.userName]));
644
- const aggregatedStats = await this.ctx.database.select("analyse_msg").where({
645
- uid: { $in: uids },
646
- hour: { $gte: since }
647
- }).groupBy(["uid"], {
648
- count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count")
649
- }).orderBy("count", "desc").limit(100).execute();
650
- if (aggregatedStats.length === 0) return "暂无统计数据";
651
- const totalCount = aggregatedStats.reduce((sum, record) => sum + record.count, 0);
652
- const list = aggregatedStats.map((item) => [
653
- userNameMap.get(item.uid) || `UID ${item.uid}`,
654
- item.count
655
- ]);
656
- return { list, total: totalCount };
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
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-chat-analyse",
3
3
  "description": "聊天记录分析",
4
- "version": "0.3.3",
4
+ "version": "0.3.5",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],