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 +36 -19
- package/lib/Stat.d.ts +3 -3
- package/lib/index.js +179 -52
- package/package.json +1 -1
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
|
-
* @
|
|
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
|
-
* @
|
|
18
|
-
* @
|
|
19
|
-
* @
|
|
20
|
-
*
|
|
21
|
-
* @param {
|
|
22
|
-
* @
|
|
23
|
-
* @
|
|
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
|
-
|
|
38
|
+
private htmlToImage;
|
|
26
39
|
/**
|
|
27
40
|
* @private
|
|
28
41
|
* @method formatDate
|
|
29
|
-
* @description
|
|
30
|
-
*
|
|
31
|
-
* @
|
|
42
|
+
* @description
|
|
43
|
+
* 将 `Date` 对象格式化为人类友好的相对时间字符串。
|
|
44
|
+
* @param {Date} date - 需要格式化的日期对象。
|
|
45
|
+
* @returns {string} - 格式化后的时间字符串。
|
|
32
46
|
*/
|
|
33
47
|
private formatDate;
|
|
34
48
|
/**
|
|
35
|
-
* @
|
|
36
|
-
* @method
|
|
37
|
-
* @description
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* @
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
* @
|
|
277
|
-
* @
|
|
278
|
-
* @
|
|
279
|
-
*
|
|
280
|
-
* @param {
|
|
281
|
-
* @
|
|
282
|
-
* @
|
|
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
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
293
|
-
*
|
|
294
|
-
* @
|
|
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
|
-
* @
|
|
323
|
-
* @method
|
|
324
|
-
* @description
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
* @
|
|
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
|
-
|
|
330
|
-
const { title, time,
|
|
331
|
-
if (!list?.length) return
|
|
332
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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,
|
|
346
|
-
let className = "
|
|
445
|
+
const dataCells = row.map((cell, i) => {
|
|
446
|
+
let className = "";
|
|
347
447
|
let content;
|
|
348
|
-
|
|
349
|
-
|
|
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
|
|
457
|
+
className = "count-cell";
|
|
353
458
|
content = cell.toLocaleString();
|
|
354
459
|
} else {
|
|
355
|
-
className
|
|
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
|
|
364
|
-
<div class="
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
559
|
-
const
|
|
560
|
-
if (
|
|
561
|
-
|
|
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.
|
|
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
|
/**
|