koishi-plugin-ll-mc 3.0.8 → 3.1.0

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.
Files changed (2) hide show
  1. package/lib/index.js +176 -1
  2. package/package.json +2 -2
package/lib/index.js CHANGED
@@ -184,7 +184,11 @@ var Config = import_koishi.Schema.intersect([
184
184
  isTextToImageConversionEnabled: import_koishi.Schema.boolean().default(false).description(
185
185
  "是否将文本排行榜转为 Markdown 图片(依赖 `markdownToImage` 服务)。"
186
186
  ),
187
- isLeaderboardToHorizontalBarChartConversionEnabled: import_koishi.Schema.boolean().default(false).description("是否将排行榜渲染为水平柱状图(依赖 `puppeteer` 服务)。")
187
+ isLeaderboardToHorizontalBarChartConversionEnabled: import_koishi.Schema.boolean().default(false).description("是否将排行榜渲染为水平柱状图(依赖 `puppeteer` 服务)。"),
188
+ renderMode: import_koishi.Schema.union([
189
+ import_koishi.Schema.const("chart").description("原版画布图(默认,保持原有效果)"),
190
+ import_koishi.Schema.const("template").description("自定义 HTML 模板图(按模板设计渲染,需 puppeteer)"),
191
+ ]).default("chart").description("排行图渲染方式"),
188
192
  }).description("图片生成"),
189
193
  // 仅在开启柱状图功能时显示以下详细选项
190
194
  import_koishi.Schema.union([
@@ -384,6 +388,19 @@ async function apply(ctx, config) {
384
388
  fontFile
385
389
  );
386
390
  }
391
+ // 官机补丁:强制覆盖 NotoEmoji 字体(纯 emoji 版),
392
+ // 防止旧版本(带数字字形)缓存在 data/messageCounter/fonts/ 里导致数字被加宽
393
+ try {
394
+ const emojiSrc = import_path.default.join(assetsDir, "fonts", "NotoEmoji.ttf");
395
+ const emojiDest = import_path.default.join(fontsPath, "NotoEmoji.ttf");
396
+ await fs.copyFile(emojiSrc, emojiDest);
397
+ // 删除旧版补丁字体(避免旧版带数字字形的 patched 文件被继续加载、家族名冲突)
398
+ const patched = import_path.default.join(fontsPath, "NotoEmoji-patched.ttf");
399
+ await fs.rm(patched, { force: true });
400
+ logger.info("[ll-mc] 已强制更新 NotoEmoji 字体为纯 emoji 版,并清理旧补丁字体。");
401
+ } catch (error) {
402
+ logger.warn("[ll-mc] 强制更新 NotoEmoji 字体失败:", error?.message);
403
+ }
387
404
  const avatarCache = /* @__PURE__ */ new Map();
388
405
  let iconCache = [];
389
406
  let barBgImgCache = [];
@@ -2492,6 +2509,147 @@ ${targetUserRecord[0].username}
2492
2509
  return number / total * 100;
2493
2510
  }
2494
2511
  __name(calculatePercentage, "calculatePercentage");
2512
+ function escapeHtml(value) {
2513
+ return String(value == null ? "" : value).replace(/[&<>"']/g, (ch) => ({
2514
+ "&": "&amp;",
2515
+ "<": "&lt;",
2516
+ ">": "&gt;",
2517
+ '"': "&quot;",
2518
+ "'": "&#39;"
2519
+ }[ch]));
2520
+ }
2521
+ __name(escapeHtml, "escapeHtml");
2522
+ const LEADERBOARD_ROW_PALETTES = [
2523
+ { bar: "#C8B8AE", fill: "#A26D51", text: "#5D3F2E" },
2524
+ { bar: "#C4C4C4", fill: "#8E908D", text: "#4A4B49" },
2525
+ { bar: "#F8BBD0", fill: "#F06292", text: "#374151" },
2526
+ { bar: "#D1C4E9", fill: "#9575CD", text: "#374151" },
2527
+ { bar: "#FFCDD2", fill: "#E57373", text: "#374151" },
2528
+ { bar: "#CFD8DC", fill: "#90A4AE", text: "#374151" },
2529
+ { bar: "#D7CCC8", fill: "#A1887F", text: "#374151" },
2530
+ { bar: "#FFE0B2", fill: "#FFB74D", text: "#374151" },
2531
+ { bar: "#BDBDBD", fill: "#757575", text: "#374151" },
2532
+ { bar: "#C5E1A5", fill: "#AED581", text: "#374151" },
2533
+ { bar: "#FFECB3", fill: "#FFD54F", text: "#374151" },
2534
+ { bar: "#B2DFDB", fill: "#80CBC4", text: "#374151" },
2535
+ { bar: "#BBDEFB", fill: "#90CAF9", text: "#374151" },
2536
+ { bar: "#E1BEE7", fill: "#CE93D8", text: "#374151" },
2537
+ { bar: "#FFCCBC", fill: "#FFAB91", text: "#374151" },
2538
+ { bar: "#F0F4C3", fill: "#E6EE9C", text: "#374151" },
2539
+ { bar: "#BCAAA4", fill: "#A1887F", text: "#374151" },
2540
+ { bar: "#EEEEEE", fill: "#BDBDBD", text: "#374151" },
2541
+ { bar: "#CFD8DC", fill: "#90A4AE", text: "#374151" }
2542
+ ];
2543
+ function buildLeaderboardRow(item, index) {
2544
+ const pal = LEADERBOARD_ROW_PALETTES[index % LEADERBOARD_ROW_PALETTES.length];
2545
+ const isHero = index === 0;
2546
+ const width = item.count > 0 ? Math.max(item.percentage, 1) : 1;
2547
+ const avatar = escapeHtml(item.avatar || "");
2548
+ const name = escapeHtml(item.name || "");
2549
+ const count = item.count;
2550
+ const barBg = isHero ? "#F3F4F6" : pal.bar;
2551
+ const fill = isHero
2552
+ ? 'linear-gradient(to right, #6366F1, #A855F7, #EC4899)'
2553
+ : pal.fill;
2554
+ const textColor = isHero ? "#FFFFFF" : pal.text;
2555
+ const star = isHero
2556
+ ? `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="row-star"><path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z" clip-rule="evenodd"/></svg>`
2557
+ : "";
2558
+ return `<div class="row">
2559
+ <div class="avatar"><img src="${avatar}" alt="avatar"></div>
2560
+ <div class="bar" style="background:${barBg};">
2561
+ <div class="fill" style="width:${width}%;background:${fill};"></div>
2562
+ <div class="bar-content" style="color:${textColor};">
2563
+ <span class="row-name${isHero ? " is-hero" : ""}">${star}${name}</span>
2564
+ <span class="row-count">${count}</span>
2565
+ </div>
2566
+ </div>
2567
+ </div>`;
2568
+ }
2569
+ __name(buildLeaderboardRow, "buildLeaderboardRow");
2570
+ function buildLeaderboardHtml(title, date, items) {
2571
+ const rows = items.map(buildLeaderboardRow).join("\n");
2572
+ return `<!DOCTYPE html>
2573
+ <html lang="zh-CN">
2574
+ <head>
2575
+ <meta charset="UTF-8">
2576
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
2577
+ <style>
2578
+ * { box-sizing: border-box; margin: 0; padding: 0; }
2579
+ html, body {
2580
+ width: 900px;
2581
+ background-color: #F5F7FA;
2582
+ font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
2583
+ -webkit-font-smoothing: antialiased;
2584
+ }
2585
+ body { padding: 16px; }
2586
+ .card {
2587
+ width: 100%;
2588
+ max-width: 868px;
2589
+ margin: 0 auto;
2590
+ background: #FFFFFF;
2591
+ border-radius: 12px;
2592
+ padding: 24px;
2593
+ box-shadow: 0 4px 6px rgba(0,0,0,0.06);
2594
+ border: 1px solid #F3F4F6;
2595
+ }
2596
+ .card-header { text-align: center; margin-bottom: 22px; }
2597
+ .card-title { font-size: 28px; font-weight: 700; letter-spacing: 0.02em; color: #000000; margin-bottom: 6px; }
2598
+ .card-date { font-size: 14px; font-weight: 500; color: #9CA3AF; }
2599
+ .rows { display: flex; flex-direction: column; gap: 8px; }
2600
+ .row { display: flex; align-items: center; gap: 12px; width: 100%; }
2601
+ .avatar { width: 40px; height: 40px; flex-shrink: 0; }
2602
+ .avatar img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; box-shadow: 0 1px 2px rgba(0,0,0,0.08); background: #fff; padding: 2px; }
2603
+ .bar { position: relative; flex-grow: 1; height: 40px; border-radius: 8px; overflow: hidden; display: flex; align-items: center; justify-content: space-between; padding: 0 12px; }
2604
+ .bar::after { content: ''; position: absolute; inset: 0; background-image: linear-gradient(to right, transparent 99%, rgba(255,255,255,0.2) 100%); background-size: 10% 100%; pointer-events: none; }
2605
+ .fill { position: absolute; left: 0; top: 0; height: 100%; border-radius: 8px 0 0 8px; z-index: 0; }
2606
+ .bar-content { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; width: 100%; }
2607
+ .row-name { font-size: 16px; font-weight: 700; display: flex; align-items: center; gap: 5px; max-width: 78%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2608
+ .row-name.is-hero { color: #FFFFFF; text-shadow: 0 1px 2px rgba(0,0,0,0.2); }
2609
+ .row-star { width: 16px; height: 16px; color: #FCD34D; flex-shrink: 0; }
2610
+ .row-count { font-size: 18px; font-weight: 700; }
2611
+ </style>
2612
+ </head>
2613
+ <body>
2614
+ <div class="card">
2615
+ <div class="card-header">
2616
+ <h1 class="card-title">${escapeHtml(title)}</h1>
2617
+ <p class="card-date">${escapeHtml(date)}</p>
2618
+ </div>
2619
+ <div class="rows">
2620
+ ${rows}
2621
+ </div>
2622
+ </div>
2623
+ </body>
2624
+ </html>`;
2625
+ }
2626
+ __name(buildLeaderboardHtml, "buildLeaderboardHtml");
2627
+ async function renderLeaderboardTemplate(ctx, title, date, items) {
2628
+ if (typeof ctx.puppeteer?.page !== "function") return null;
2629
+ let page;
2630
+ try {
2631
+ const html = buildLeaderboardHtml(title, date, items);
2632
+ page = await ctx.puppeteer.page();
2633
+ await page.setViewport({ width: 900, height: 400, deviceScaleFactor: 2 });
2634
+ try {
2635
+ await page.setContent(html, { waitUntil: "networkidle0", timeout: 20000 });
2636
+ } catch (e) {
2637
+ logger.warn("Leaderboard template wait timed out, screenshotting current content.");
2638
+ }
2639
+ await page.waitForSelector(".card").catch(() => {});
2640
+ const el = await page.$(".card");
2641
+ const buf = el
2642
+ ? await el.screenshot({ type: "png" })
2643
+ : await page.screenshot({ type: "png", fullPage: true });
2644
+ return `data:image/png;base64,${buf.toString("base64")}`;
2645
+ } catch (error) {
2646
+ logger.error("Failed to render leaderboard template:", error);
2647
+ return null;
2648
+ } finally {
2649
+ if (page) await page.close().catch(() => {});
2650
+ }
2651
+ }
2652
+ __name(renderLeaderboardTemplate, "renderLeaderboardTemplate");
2495
2653
  function getCurrentBeijingTime() {
2496
2654
  return (/* @__PURE__ */ new Date()).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" });
2497
2655
  }
@@ -2501,6 +2659,23 @@ ${targetUserRecord[0].username}
2501
2659
  rankTitle,
2502
2660
  rankingData
2503
2661
  }) {
2662
+ if (config.renderMode === "template") {
2663
+ const templateItems = rankingData.map((item, index) => ({
2664
+ ...item,
2665
+ rank: index + 1,
2666
+ name: item.name.replace(/^★/, "")
2667
+ }));
2668
+ const dataUri = await renderLeaderboardTemplate(
2669
+ ctx,
2670
+ rankTitle,
2671
+ rankTimeTitle,
2672
+ templateItems
2673
+ );
2674
+ if (dataUri) {
2675
+ return import_koishi.h.image(dataUri);
2676
+ }
2677
+ logger.warn("Leaderboard template render failed. Falling back to text.");
2678
+ }
2504
2679
  if (config.isLeaderboardToHorizontalBarChartConversionEnabled) {
2505
2680
  if (!ctx.puppeteer) {
2506
2681
  logger.warn("Puppeteer service is not enabled. Falling back to text.");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-ll-mc",
3
3
  "description": "消息数量统计插件(基于 koishi-plugin-message-counter 重做,官方QQ机器人兼容)",
4
- "version": "3.0.8",
4
+ "version": "3.1.0",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
@@ -10,7 +10,7 @@
10
10
  ],
11
11
  "koishi": {
12
12
  "description": {
13
- "zh": "消息数量统计插件:发言排行榜、群排行榜,官方QQ机器人(官机)兼容补丁"
13
+ "zh": "消息数量统计插件:发言排行榜、群排行榜,支持自定义 HTML 模板排行图,官方QQ机器人(官机)兼容补丁"
14
14
  },
15
15
  "service": {
16
16
  "required": [