koishi-plugin-chat-analyse 0.3.4 → 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,5 +1,13 @@
1
1
  import { Context } from 'koishi';
2
+ /**
3
+ * 定义了渲染列表中单行数据的格式。它是一个由字符串、数字或 `Date` 对象组成的数组。
4
+ */
2
5
  export type RenderListItem = (string | number | Date)[];
6
+ /**
7
+ * @interface ListRenderData
8
+ * @description 定义了调用 `renderList` 方法所需的数据结构。
9
+ * 它包含了渲染一张完整列表图片所必需的所有信息。
10
+ */
3
11
  export interface ListRenderData {
4
12
  title: string;
5
13
  time: Date;
@@ -8,36 +16,45 @@ export interface ListRenderData {
8
16
  }
9
17
  /**
10
18
  * @class Renderer
11
- * @description 通用列表渲染器,使用 Puppeteer 将结构化数据渲染为精美的、类似卡片的表格图片。
19
+ * @classdesc
20
+ * 负责将结构化的数据(特别是列表)转换为设计精美的PNG图片。
21
+ * 其核心特性是能够动态计算内容尺寸,生成布局紧凑、自适应的图片。
12
22
  */
13
23
  export declare class Renderer {
14
24
  private ctx;
25
+ /**
26
+ * @param {Context} ctx - Koishi 的插件上下文,用于访问核心服务如 `puppeteer` 和 `logger`。
27
+ */
15
28
  constructor(ctx: Context);
16
29
  /**
17
- * @public
18
- * @async
19
- * @method renderList
20
- * @description 将列表数据渲染为图片。
21
- * @param {ListRenderData} data - 待渲染的完整列表数据。
22
- * @param {string[]} [headers] - (可选) 表头文案数组。如果提供,将会在表格顶部渲染表头。
23
- * @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 截图过程中发生错误,将抛出异常。
24
37
  */
25
- renderList(data: ListRenderData, headers?: string[]): Promise<string | Buffer>;
38
+ private htmlToImage;
26
39
  /**
27
40
  * @private
28
41
  * @method formatDate
29
- * @description 将日期格式化为包含两个单位的相对时间字符串(如“21天14时前”),如果超过一年则显示绝对日期。
30
- * @param {Date} date - 待格式化的 Date 对象。
31
- * @returns {string} 格式化后的日期字符串。
42
+ * @description
43
+ * `Date` 对象格式化为人类友好的相对时间字符串。
44
+ * @param {Date} date - 需要格式化的日期对象。
45
+ * @returns {string} - 格式化后的时间字符串。
32
46
  */
33
47
  private formatDate;
34
48
  /**
35
- * @private
36
- * @method generateListHtml
37
- * @description 根据传入的结构化数据和表头,动态生成用于 Puppeteer 渲染的完整 HTML 字符串。
38
- * @param {ListRenderData} data - 列表数据对象。
39
- * @param {string[]} [headers] - (可选) 表头数组。
40
- * @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。如果输入的数据列表为空,则返回一个提示性字符串。
41
58
  */
42
- private generateListHtml;
59
+ renderList(data: ListRenderData, headers?: string[]): Promise<string | Buffer>;
43
60
  }
package/lib/Stat.d.ts CHANGED
@@ -68,13 +68,13 @@ export declare class Stat {
68
68
  /**
69
69
  * @private
70
70
  * @async
71
- * @method getMessageStats
72
- * @description 从数据库中获取并聚合所有消息类型的统计数据。
71
+ * @method getUserMessageStats
72
+ * @description 从数据库中获取并聚合每个用户的消息统计数据。
73
73
  * @param {string} [guildId] - (可选) 若提供,则将范围限制在此群组。
74
74
  * @param {string} [userId] - (可选) 若提供,则将范围限制在此用户。
75
75
  * @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
76
76
  */
77
- private getMessageStats;
77
+ private getUserMessageStats;
78
78
  /**
79
79
  * @private
80
80
  * @async
package/lib/index.js CHANGED
@@ -266,6 +266,9 @@ var import_koishi3 = require("koishi");
266
266
  // src/Renderer.ts
267
267
  var import_koishi2 = require("koishi");
268
268
  var Renderer = class {
269
+ /**
270
+ * @param {Context} ctx - Koishi 的插件上下文,用于访问核心服务如 `puppeteer` 和 `logger`。
271
+ */
269
272
  constructor(ctx) {
270
273
  this.ctx = ctx;
271
274
  }
@@ -273,25 +276,116 @@ var Renderer = class {
273
276
  __name(this, "Renderer");
274
277
  }
275
278
  /**
276
- * @public
277
- * @async
278
- * @method renderList
279
- * @description 将列表数据渲染为图片。
280
- * @param {ListRenderData} data - 待渲染的完整列表数据。
281
- * @param {string[]} [headers] - (可选) 表头文案数组。如果提供,将会在表格顶部渲染表头。
282
- * @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 截图过程中发生错误,将抛出异常。
283
286
  */
284
- async renderList(data, headers) {
285
- const htmlContent = this.generateListHtml(data, headers);
286
- if (!htmlContent) return "暂无数据可供渲染";
287
- 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
+ }
288
381
  }
289
382
  /**
290
383
  * @private
291
384
  * @method formatDate
292
- * @description 将日期格式化为包含两个单位的相对时间字符串(如“21天14时前”),如果超过一年则显示绝对日期。
293
- * @param {Date} date - 待格式化的 Date 对象。
294
- * @returns {string} 格式化后的日期字符串。
385
+ * @description
386
+ * `Date` 对象格式化为人类友好的相对时间字符串。
387
+ * @param {Date} date - 需要格式化的日期对象。
388
+ * @returns {string} - 格式化后的时间字符串。
295
389
  */
296
390
  formatDate(date) {
297
391
  if (!date) return "未知";
@@ -319,55 +413,80 @@ var Renderer = class {
319
413
  return result ? `${result}前` : "刚刚";
320
414
  }
321
415
  /**
322
- * @private
323
- * @method generateListHtml
324
- * @description 根据传入的结构化数据和表头,动态生成用于 Puppeteer 渲染的完整 HTML 字符串。
325
- * @param {ListRenderData} data - 列表数据对象。
326
- * @param {string[]} [headers] - (可选) 表头数组。
327
- * @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。如果输入的数据列表为空,则返回一个提示性字符串。
328
425
  */
329
- generateListHtml(data, headers) {
330
- const { title, time, total, list } = data;
331
- if (!list?.length) return null;
332
- const tableHeadHtml = headers?.length > 0 ? `<thead><tr><th class="rank-cell">排名</th>${headers.map((h2, i) => {
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) => {
333
436
  const firstCell = list[0]?.[i];
334
- let headerClass = "";
335
- if (i === 0) headerClass = "column-main-label";
336
- if (typeof firstCell === "number" || firstCell instanceof Date) {
337
- headerClass += " header-right-align";
338
- }
339
- return `<th class="${headerClass.trim()}">${h2}</th>`;
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>`;
340
440
  }).join("")}</tr></thead>` : "";
341
441
  const tableRowsHtml = list.map((row, index) => {
342
442
  const rank = index + 1;
343
443
  const rankClass = rank === 1 ? "rank-gold" : rank === 2 ? "rank-silver" : rank === 3 ? "rank-bronze" : "";
344
444
  const rankCell = `<td class="rank-cell ${rankClass}">${rank}</td>`;
345
- const dataCells = row.map((cell, cellIndex) => {
346
- let className = "data-cell";
445
+ const dataCells = row.map((cell, i) => {
446
+ let className = "";
347
447
  let content;
348
- if (cell instanceof Date) {
349
- className += " date-cell";
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";
350
455
  content = this.formatDate(cell);
351
456
  } else if (typeof cell === "number") {
352
- className += " count-cell";
457
+ className = "count-cell";
353
458
  content = cell.toLocaleString();
354
459
  } else {
355
- className += " name-cell";
460
+ className = "name-cell";
356
461
  content = String(cell);
357
462
  }
358
- if (cellIndex === 0) className += " column-main-label";
359
463
  return `<td class="${className}">${content}</td>`;
360
464
  }).join("");
361
465
  return `<tr>${rankCell}${dataCells}</tr>`;
362
466
  }).join("");
363
- const metaInfoHtml = `
364
- <div class="meta-group">
365
- ${total !== void 0 ? `<div class="total-count">总计: ${typeof total === "number" ? total.toLocaleString() : total}</div>` : ""}
366
- <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>
367
487
  </div>
368
488
  `;
369
- const styles = `:root{--bg-color:#f7f8fa;--card-bg:#fff;--text-color:#333;--header-color:#1f2329;--sub-text-color:#646a73;--border-color:#f0f0f0;--accent-color:#4a6ee0;--gold:#ffac33;--silver:#a8b5c1;--bronze:#d69864}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;background:var(--bg-color);margin:0;padding:20px;width:800px;box-sizing:border-box;-webkit-font-smoothing:antialiased}.container{background:var(--card-bg);border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.08);padding:20px}.header{display:flex;justify-content:space-between;align-items:flex-start;padding-bottom:12px;margin-bottom:8px}.title-group h1{font-size:22px;font-weight:600;color:var(--header-color);margin:0}.meta-group{text-align:right;white-space:nowrap}.meta-group .total-count{font-size:18px;font-weight:600;color:var(--accent-color)}.meta-group .time-label{font-size:13px;color:var(--sub-text-color);margin-top:4px}table{width:100%;border-collapse:collapse;color:var(--text-color)}th,td{padding:9px 12px;text-align:left;border-bottom:1px solid var(--border-color);vertical-align:middle}thead tr:first-child th{border-top:1px solid var(--border-color)}th{font-size:13px;font-weight:500;color:var(--sub-text-color)}td{font-size:15px}tr:last-child td{border-bottom:none}.header-right-align{text-align:right}.rank-cell{width:45px;text-align:center;font-weight:600;color:var(--sub-text-color);padding-left:0;padding-right:0}.rank-gold{color:var(--gold) !important}.rank-silver{color:var(--silver) !important}.rank-bronze{color:var(--bronze) !important}.column-main-label{width:45%}.data-cell{word-break:break-all}.name-cell{font-weight:500;color:var(--header-color)}.count-cell,.date-cell{text-align:right}.count-cell{font-weight:600;color:var(--accent-color)}.date-cell{font-size:14px;color:var(--sub-text-color)}`;
370
- return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>${title}</title><style>${styles}</style></head><body><div class="container"><div class="header"><div class="title-group"><h1>${title}</h1></div>${metaInfoHtml}</div><table>${tableHeadHtml}<tbody>${tableRowsHtml}</tbody></table></div></body></html>`;
489
+ return this.htmlToImage(cardHtml);
371
490
  }
372
491
  };
373
492
 
@@ -424,11 +543,11 @@ var Stat = class {
424
543
  const result = await this.renderer.renderList(renderData, ["用户", "条数", "最后发言"]);
425
544
  return Buffer.isBuffer(result) ? import_koishi3.Element.image(result, "image/png") : result;
426
545
  } else {
427
- const stats = await this.getMessageStats(scope.guildId, scope.userId);
546
+ const stats = await this.getUserMessageStats(scope.guildId, scope.userId);
428
547
  if (typeof stats === "string") return stats;
429
548
  const title = await this.generateTitle(scope.guildId, scope.userId, { main: "消息" });
430
549
  const renderData = { title, time: /* @__PURE__ */ new Date(), total: stats.total, list: stats.list };
431
- const result = await this.renderer.renderList(renderData, ["类型", "条数", "最后发言"]);
550
+ const result = await this.renderer.renderList(renderData, ["用户", "总计发言", "最后发言"]);
432
551
  return Buffer.isBuffer(result) ? import_koishi3.Element.image(result, "image/png") : result;
433
552
  }
434
553
  } catch (error) {
@@ -549,19 +668,27 @@ var Stat = class {
549
668
  /**
550
669
  * @private
551
670
  * @async
552
- * @method getMessageStats
553
- * @description 从数据库中获取并聚合所有消息类型的统计数据。
671
+ * @method getUserMessageStats
672
+ * @description 从数据库中获取并聚合每个用户的消息统计数据。
554
673
  * @param {string} [guildId] - (可选) 若提供,则将范围限制在此群组。
555
674
  * @param {string} [userId] - (可选) 若提供,则将范围限制在此用户。
556
675
  * @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
557
676
  */
558
- async getMessageStats(guildId, userId) {
559
- const { uids, error } = await this.getUidsInScope(guildId, userId);
560
- if (error) return error;
561
- const stats = await this.ctx.database.select("analyse_msg").where({ uid: { $in: uids } }).groupBy("type", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
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 "暂无统计数据";
683
+ const uids = users.map((u) => u.uid);
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", {
686
+ count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"),
687
+ lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed")
688
+ }).orderBy("count", "desc").execute();
562
689
  if (stats.length === 0) return "暂无统计数据";
563
690
  const total = stats.reduce((sum, record) => sum + record.count, 0);
564
- const list = stats.map((item) => [item.type, item.count, item.lastUsed]);
691
+ const list = stats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count, item.lastUsed]);
565
692
  return { list, total };
566
693
  }
567
694
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-chat-analyse",
3
3
  "description": "聊天记录分析",
4
- "version": "0.3.4",
4
+ "version": "0.3.5",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],