koishi-plugin-chat-analyse 0.5.1 → 0.5.2
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 +49 -0
- package/lib/index.js +53 -20
- package/package.json +1 -1
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Context } from 'koishi';
|
|
2
|
+
/** 定义了渲染列表中单行数据的格式,是一个由字符串、数字或 `Date` 对象构成的数组。 */
|
|
3
|
+
export type RenderListItem = (string | number | Date)[];
|
|
4
|
+
/**
|
|
5
|
+
* @interface ListRenderData
|
|
6
|
+
* @description 定义了调用 `renderList` 方法所需的数据结构,包含了渲染一张完整列表图片所必需的所有信息。
|
|
7
|
+
*/
|
|
8
|
+
export interface ListRenderData {
|
|
9
|
+
title: string;
|
|
10
|
+
time: Date;
|
|
11
|
+
total?: string | number;
|
|
12
|
+
list: RenderListItem[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* @class Renderer
|
|
16
|
+
* @description 负责将结构化的列表数据渲染为设计精美的 PNG 图片。其核心特性是能够动态计算内容尺寸,生成布局紧凑、自适应的图片。
|
|
17
|
+
*/
|
|
18
|
+
export declare class Renderer {
|
|
19
|
+
private ctx;
|
|
20
|
+
/**
|
|
21
|
+
* @param ctx - Koishi 的插件上下文,用于访问 `puppeteer` 等核心服务。
|
|
22
|
+
*/
|
|
23
|
+
constructor(ctx: Context);
|
|
24
|
+
/**
|
|
25
|
+
* @private
|
|
26
|
+
* @method htmlToImage
|
|
27
|
+
* @description 将一个完整的 HTML 文档字符串转换为 PNG 图片 Buffer。
|
|
28
|
+
* @param fullHtmlContent - 要渲染的、包含 `<html>...</html>` 的完整HTML字符串。
|
|
29
|
+
* @returns 返回一个包含 PNG 图片数据的 Buffer,失败则返回 null。
|
|
30
|
+
*/
|
|
31
|
+
private htmlToImage;
|
|
32
|
+
/**
|
|
33
|
+
* @private
|
|
34
|
+
* @method formatDate
|
|
35
|
+
* @description 将 `Date` 对象格式化为易于理解的相对时间或绝对日期字符串。
|
|
36
|
+
* @param date - 需要格式化的日期对象。
|
|
37
|
+
* @returns 格式化后的时间字符串。
|
|
38
|
+
*/
|
|
39
|
+
private formatDate;
|
|
40
|
+
/**
|
|
41
|
+
* @public
|
|
42
|
+
* @method renderList
|
|
43
|
+
* @description 构建并渲染一个包含标题、统计信息和数据表格的 HTML 卡片为图片。如果数据过多,则会分片渲染成多张图片。
|
|
44
|
+
* @param data - 包含渲染所需全部信息的对象。
|
|
45
|
+
* @param headers - (可选) 表格的表头字符串数组。
|
|
46
|
+
* @returns 成功时返回包含 PNG 图片的 Buffer 数组,若列表为空则返回提示字符串。
|
|
47
|
+
*/
|
|
48
|
+
renderList(data: ListRenderData, headers?: string[]): Promise<string | Buffer[]>;
|
|
49
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -222,15 +222,15 @@ var Renderer = class {
|
|
|
222
222
|
/**
|
|
223
223
|
* @private
|
|
224
224
|
* @method htmlToImage
|
|
225
|
-
* @description
|
|
226
|
-
* @param
|
|
227
|
-
* @returns 返回一个包含 PNG 图片数据的 Buffer。
|
|
225
|
+
* @description 将一个完整的 HTML 文档字符串转换为 PNG 图片 Buffer。
|
|
226
|
+
* @param fullHtmlContent - 要渲染的、包含 `<html>...</html>` 的完整HTML字符串。
|
|
227
|
+
* @returns 返回一个包含 PNG 图片数据的 Buffer,失败则返回 null。
|
|
228
228
|
*/
|
|
229
|
-
async htmlToImage(
|
|
229
|
+
async htmlToImage(fullHtmlContent) {
|
|
230
230
|
const page = await this.ctx.puppeteer.page();
|
|
231
231
|
try {
|
|
232
232
|
await page.setViewport({ width: 720, height: 1080, deviceScaleFactor: 2 });
|
|
233
|
-
await page.setContent(
|
|
233
|
+
await page.setContent(fullHtmlContent, { waitUntil: "networkidle0" });
|
|
234
234
|
const dimensions = await page.evaluate(() => ({ width: document.body.scrollWidth, height: document.body.scrollHeight }));
|
|
235
235
|
await page.setViewport({ ...dimensions, deviceScaleFactor: 2 });
|
|
236
236
|
return await page.screenshot({ type: "png", fullPage: true, omitBackground: true });
|
|
@@ -264,17 +264,19 @@ var Renderer = class {
|
|
|
264
264
|
/**
|
|
265
265
|
* @public
|
|
266
266
|
* @method renderList
|
|
267
|
-
* @description 构建并渲染一个包含标题、统计信息和数据表格的 HTML
|
|
267
|
+
* @description 构建并渲染一个包含标题、统计信息和数据表格的 HTML 卡片为图片。如果数据过多,则会分片渲染成多张图片。
|
|
268
268
|
* @param data - 包含渲染所需全部信息的对象。
|
|
269
269
|
* @param headers - (可选) 表格的表头字符串数组。
|
|
270
|
-
* @returns 成功时返回包含 PNG 图片的 Buffer
|
|
270
|
+
* @returns 成功时返回包含 PNG 图片的 Buffer 数组,若列表为空则返回提示字符串。
|
|
271
271
|
*/
|
|
272
272
|
async renderList(data, headers) {
|
|
273
273
|
const { title, time, list } = data;
|
|
274
274
|
if (!list?.length) return "暂无数据可供渲染";
|
|
275
|
+
const CHUNK_SIZE = 100;
|
|
276
|
+
const imageBuffers = [];
|
|
277
|
+
const totalItems = list.length;
|
|
275
278
|
const countHeaderIndex = headers?.findIndex((h4) => ["总计发言", "条数", "次数", "数量"].includes(h4)) ?? -1;
|
|
276
|
-
const
|
|
277
|
-
const totalCount = data.total || totalValue;
|
|
279
|
+
const totalCount = data.total || (countHeaderIndex > -1 ? list.reduce((sum, row) => sum + (Number(row[countHeaderIndex]) || 0), 0) : totalItems);
|
|
278
280
|
const renderCell = /* @__PURE__ */ __name((cell, i) => {
|
|
279
281
|
const headerText = headers?.[i] || "";
|
|
280
282
|
if (headerText.includes("占比")) {
|
|
@@ -285,14 +287,34 @@ var Renderer = class {
|
|
|
285
287
|
if (typeof cell === "number") return `<td class="count-cell">${cell.toLocaleString()}</td>`;
|
|
286
288
|
return `<td class="name-cell">${String(cell)}</td>`;
|
|
287
289
|
}, "renderCell");
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
290
|
+
const totalPages = Math.ceil(totalItems / CHUNK_SIZE);
|
|
291
|
+
for (let i = 0; i < totalItems; i += CHUNK_SIZE) {
|
|
292
|
+
const chunk = list.slice(i, i + CHUNK_SIZE);
|
|
293
|
+
const pageNum = Math.floor(i / CHUNK_SIZE) + 1;
|
|
294
|
+
const pageTitle = totalPages > 1 ? `${title} (第 ${pageNum}/${totalPages} 页)` : title;
|
|
295
|
+
const tableRowsHtml = chunk.map((row, index) => {
|
|
296
|
+
const rank = i + index + 1;
|
|
297
|
+
const rankClass = rank === 1 ? "rank-gold" : rank === 2 ? "rank-silver" : rank === 3 ? "rank-bronze" : "";
|
|
298
|
+
return `<tr><td class="rank-cell ${rankClass}">${rank}</td>${row.map(renderCell).join("")}</tr>`;
|
|
299
|
+
}).join("");
|
|
300
|
+
const tableHeadHtml = headers?.length ? `<thead><tr><th class="rank-cell">#</th>${headers.map((h4) => `<th class="${typeof list[0]?.[headers.indexOf(h4)] === "string" ? "name-header" : "header-right-align"}">${h4}</th>`).join("")}</tr></thead>` : "";
|
|
301
|
+
const cardHtml = `<div class="container"><div class="header"><table class="header-table"><tr><td class="header-table-left"><div class="stat-chip">总计: <span>${typeof totalCount === "number" ? totalCount.toLocaleString() : totalCount}</span></div></td><td class="header-table-center"><h1 class="title-text">${pageTitle}</h1></td><td class="header-table-right"><div class="time-label">${time.toLocaleString("zh-CN", { hour12: false }).replace(/\//g, "-")}</div></td></tr></table></div><div class="table-container"><table class="main-table">${tableHeadHtml}<tbody>${tableRowsHtml}</tbody></table></div></div>`;
|
|
302
|
+
const fullHtml = `<!DOCTYPE html>
|
|
303
|
+
<html>
|
|
304
|
+
<head>
|
|
305
|
+
<meta charset="UTF-8">
|
|
306
|
+
<style>:root{--card-bg:#fff;--text-color:#111827;--header-color:#111827;--sub-text-color:#6b7280;--border-color:#e5e7eb;--accent-color:#4a6ee0;--chip-bg:#f3f4f6;--stripe-bg:#f9fafb;--gold:#f59e0b;--silver:#9ca3af;--bronze:#a16207}body{display:inline-block;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;background:0 0;margin:0;padding:8px;-webkit-font-smoothing:antialiased}.container{display:inline-block;background:var(--card-bg);border-radius:12px;padding:0;overflow:hidden;box-shadow:0 2px 4px rgba(0,0,0,.05)}.header{padding:10px 14px}.header-table{border-collapse:collapse;width:100%}.header-table-left,.header-table-right{width:1%;white-space:nowrap}.header-table-left{text-align:left}.header-table-center{text-align:center}.header-table-right{text-align:right}.title-text{font-size:18px;font-weight:600;color:var(--header-color);margin:0}.stat-chip,.time-label{display:inline-flex;align-items:baseline;padding:4px 8px;border-radius:8px;background:var(--chip-bg);font-size:13px;color:var(--sub-text-color)}.stat-chip span{font-weight:600;color:var(--text-color);margin-left:4px}.table-container{border-top:1px solid var(--border-color)}.main-table{border-collapse:collapse;width:100%}.main-table th,.main-table td{padding:8px 14px;vertical-align:middle}.main-table th{font-size:12px;font-weight:500;color:var(--sub-text-color);text-transform:uppercase;letter-spacing:.05em;background:var(--stripe-bg)}.main-table td{font-size:14px;color:var(--text-color)}.main-table tbody tr:nth-child(even){background-color:var(--stripe-bg)}.main-table .name-cell,.main-table .name-header{text-align:left}.main-table .rank-cell,.main-table .count-cell,.main-table .date-cell,.main-table .percent-cell,.main-table .header-right-align{text-align:right;white-space:nowrap;width:1%;font-variant-numeric:tabular-nums}.name-cell{font-weight:500}.rank-cell{font-weight:500;color:var(--sub-text-color)}.count-cell{font-weight:600;color:var(--accent-color)}.date-cell{color:var(--sub-text-color)}.rank-gold,.rank-silver,.rank-bronze{font-weight:600}.rank-gold{color:var(--gold)!important}.rank-silver{color:var(--silver)!important}.rank-bronze{color:var(--bronze)!important}.percent-cell{position:relative}.percent-bar{position:absolute;top:0;right:0;height:100%;background-color:var(--accent-color);opacity:.15}.percent-text{position:relative;z-index:1}</style>
|
|
307
|
+
</head>
|
|
308
|
+
<body>
|
|
309
|
+
${cardHtml}
|
|
310
|
+
</body>
|
|
311
|
+
</html>`;
|
|
312
|
+
const imageBuffer = await this.htmlToImage(fullHtml);
|
|
313
|
+
if (imageBuffer) {
|
|
314
|
+
imageBuffers.push(imageBuffer);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return imageBuffers.length > 0 ? imageBuffers : "图片渲染失败";
|
|
296
318
|
}
|
|
297
319
|
};
|
|
298
320
|
|
|
@@ -329,7 +351,13 @@ var Stat = class {
|
|
|
329
351
|
if (scope.error) return scope.error;
|
|
330
352
|
try {
|
|
331
353
|
const result = await handler(scope, options);
|
|
332
|
-
|
|
354
|
+
if (typeof result === "string") return result;
|
|
355
|
+
if (Array.isArray(result)) {
|
|
356
|
+
if (result.length === 0) return "图片渲染失败";
|
|
357
|
+
for (const buffer of result) await session.sendQueued(import_koishi3.h.image(buffer, "image/png"));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (Buffer.isBuffer(result)) return import_koishi3.h.image(result, "image/png");
|
|
333
361
|
} catch (error) {
|
|
334
362
|
this.ctx.logger.error("渲染统计图片失败:", error);
|
|
335
363
|
return "渲染统计图片失败";
|
|
@@ -377,7 +405,7 @@ var Stat = class {
|
|
|
377
405
|
const uidsInScope = guildId ? (await this.ctx.database.get("analyse_user", { channelId: guildId }, ["uid"])).map((u) => u.uid) : void 0;
|
|
378
406
|
if (guildId && uidsInScope.length === 0) return "暂无指定时段内发言记录";
|
|
379
407
|
if (uidsInScope) baseQuery.uid = { $in: uidsInScope };
|
|
380
|
-
const rankStats = await this.ctx.database.select("analyse_rank").where(baseQuery).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).orderBy("count", "desc").
|
|
408
|
+
const rankStats = await this.ctx.database.select("analyse_rank").where(baseQuery).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).orderBy("count", "desc").execute();
|
|
381
409
|
if (rankStats.length === 0) return "暂无指定时段内发言记录";
|
|
382
410
|
const uids = rankStats.map((s) => s.uid);
|
|
383
411
|
const users = await this.ctx.database.get("analyse_user", { uid: { $in: uids } }, ["uid", "userName"]);
|
|
@@ -387,7 +415,12 @@ var Stat = class {
|
|
|
387
415
|
const listWithPercentage = list.map((row) => [...row, total > 0 ? `${(row[1] / total * 100).toFixed(2)}%` : "0.00%"]);
|
|
388
416
|
const title = await this.generateTitle({ guildId }, { main: "发言排行", timeRange: hours, subtype: type });
|
|
389
417
|
const result = await this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list: listWithPercentage }, ["用户", "总计发言", "占比"]);
|
|
390
|
-
|
|
418
|
+
if (typeof result === "string") return result;
|
|
419
|
+
if (Array.isArray(result)) {
|
|
420
|
+
if (result.length === 0) return "图片渲染失败";
|
|
421
|
+
for (const buffer of result) await session.sendQueued(import_koishi3.h.image(buffer, "image/png"));
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
391
424
|
} catch (error) {
|
|
392
425
|
this.ctx.logger.error("渲染发言排行图片失败:", error);
|
|
393
426
|
return "渲染发言排行图片失败";
|