koishi-plugin-chat-analyse 0.2.3 → 0.2.4

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/CmdStat.d.ts CHANGED
@@ -13,22 +13,26 @@ declare module 'koishi' {
13
13
  }
14
14
  /**
15
15
  * @class CmdStat
16
- * @description 负责提供命令执行所需的服务,并管理插件的核心逻辑。
16
+ * @description 提供命令统计服务,处理用户查询并渲染结果。
17
17
  */
18
18
  export declare class CmdStat {
19
+ private ctx;
19
20
  renderer: Renderer;
20
- ctx: Context;
21
- constructor(context: Context);
21
+ constructor(ctx: Context);
22
22
  /**
23
- * @method registerCommands
24
- * @description 注册命令,并通过选项支持不同维度的查询。
23
+ * 注册所有相关的子命令到主 `analyse` 命令下。
24
+ * @param analyse {Command} 主 `analyse` 命令实例。
25
25
  */
26
26
  registerCommands(analyse: Command): void;
27
27
  /**
28
- * @private
29
- * @method getCommandStats
30
- * @description 从数据库获取并处理命令统计数据,兼容全局、群组和个人查询。
31
- * @returns 返回一个包含二维数组列表和总数的对象,或错误字符串。
28
+ * 根据查询参数动态生成图片标题。
29
+ */
30
+ private generateTitle;
31
+ /**
32
+ * 从数据库获取并聚合命令统计数据。
33
+ * @param guildId {string} (可选) 群组ID。
34
+ * @param userId {string} (可选) 用户ID。
35
+ * @returns {Promise<{ list: RenderListItem[], total: number } | string>} 包含结果列表和总数的对象,或错误/提示信息。
32
36
  */
33
37
  private getCommandStats;
34
38
  }
@@ -19,46 +19,53 @@ declare module 'koishi' {
19
19
  }
20
20
  /**
21
21
  * @class Collector
22
- * @description
23
- * 负责初始化数据库表、监听消息,并将处理后的数据高效存入数据库。
22
+ * @description 负责收集、缓冲并持久化消息数据,同时高效缓存用户与群组的名称信息。
24
23
  */
25
24
  export declare class Collector {
26
25
  private ctx;
27
26
  private static readonly FLUSH_INTERVAL;
28
27
  private static readonly BUFFER_THRESHOLD;
29
28
  private msgBuffer;
30
- private flushInterval;
31
29
  private nameCache;
32
30
  private pendingNameRequests;
31
+ private flushInterval;
33
32
  /**
34
33
  * @constructor
35
- * @param ctx {Context} Koishi 的上下文对象
34
+ * @param ctx {Context} Koishi 上下文,用于访问框架核心功能。
36
35
  */
37
36
  constructor(ctx: Context);
38
37
  /**
39
- * 核心消息处理函数。
40
- * @param session {Session} 消息会话对象
38
+ * 核心消息处理器,对消息进行格式化并存入缓冲区。
39
+ * @param session {Session} 消息会话对象。
41
40
  */
42
41
  private handleMessage;
43
42
  /**
44
- * 从消息元素中提取并汇总类型。
43
+ * 汇总消息元素的类型,生成紧凑的类型字符串。
44
+ * @param elements {Element[]} 消息元素数组。
45
+ * @returns {string} 类型汇总字符串,如 `[text][img]`。
45
46
  */
46
47
  private summarizeElementTypes;
47
48
  /**
48
- * 从消息元素中提取文本化、安全的内容。
49
+ * 清理并格式化消息内容,提取关键信息。
50
+ * @param elements {Element[]} 消息元素数组。
51
+ * @returns {string} 处理后的内容字符串。
49
52
  */
50
53
  private sanitizeContent;
51
54
  /**
52
- * 将内存缓冲区的消息数据批量写入数据库。
55
+ * 将内存缓冲区的消息批量写入数据库,并处理写入失败的情况。
53
56
  */
54
57
  private flushBuffer;
55
58
  /**
56
- * 检查并更新用户和群组的名称信息。
59
+ * 检查用户和群组名称是否需要更新,利用缓存和请求锁机制避免重复调用。
60
+ * @param session {Session} 消息会话对象。
61
+ * @param effectiveId {string} 有效的频道/群组ID。
57
62
  */
58
63
  private updateNameIfNeeded;
59
64
  /**
60
- * @private
61
- * 封装了实际获取和更新名称的异步逻辑。
65
+ * 异步获取用户和群组的最新名称,并更新到数据库和内存缓存。
66
+ * @param session {Session} 消息会话对象。
67
+ * @param effectiveId {string} 频道/群组ID。
68
+ * @param cacheKey {string} 用于缓存的键。
62
69
  */
63
70
  private fetchAndUpdateNames;
64
71
  }
package/lib/Renderer.d.ts CHANGED
@@ -1,12 +1,11 @@
1
1
  import { Context } from 'koishi';
2
2
  /**
3
- * 渲染列表中的一行数据。每个元素代表一列。
4
- * 这是一个灵活的元组类型,可以包含字符串、数字或日期。
3
+ * 定义渲染列表中的单行数据格式。
5
4
  * @example ['ping', 150, new Date()]
6
5
  */
7
6
  export type RenderListItem = (string | number | Date)[];
8
7
  /**
9
- * 渲染列表图片所需的数据结构。
8
+ * 定义渲染图片所需的数据结构。
10
9
  */
11
10
  export interface ListRenderData {
12
11
  title: string;
@@ -16,34 +15,33 @@ export interface ListRenderData {
16
15
  }
17
16
  /**
18
17
  * @class Renderer
19
- * @description 使用 Puppeteer 服务将格式化的数据渲染成图片。
20
- * 这是一个通用的列表渲染器,能够处理任意列数的数据,并根据数据类型智能应用样式。
18
+ * @description 通用列表渲染器,通过 Puppeteer 将数据渲染为包含精美表格的图片。
21
19
  */
22
20
  export declare class Renderer {
23
21
  private ctx;
24
22
  /**
25
23
  * @constructor
26
- * @param ctx {Context} Koishi 的上下文对象
24
+ * @param ctx {Context} Koishi 上下文,用于访问 puppeteer 服务。
27
25
  */
28
26
  constructor(ctx: Context);
29
27
  /**
30
28
  * 将列表数据渲染为图片。
31
29
  * @param data {ListRenderData} 待渲染的列表数据。
32
- * @param headers {string[]} (可选) 表头数组。如果不提供或为空,则不渲染表头部分。
33
- * @returns {Promise<string | Buffer>} 成功时返回图片 Buffer,失败或无数据时返回提示文本。
30
+ * @param headers {string[]} (可选) 表头文案数组,若不提供则不渲染表头。
31
+ * @returns {Promise<string | Buffer>} 成功时返回图片 Buffer,无数据时返回提示文本。
34
32
  */
35
33
  renderList(data: ListRenderData, headers?: string[]): Promise<string | Buffer>;
36
34
  /**
37
- * 格式化日期,提供相对时间和绝对时间显示。
38
- * @param date {Date} 日期对象
39
- * @returns {string} 格式化后的日期字符串
35
+ * 智能格式化日期,提供相对时间(如“刚刚”,“x分钟前”)和绝对日期。
36
+ * @param date {Date} 待格式化的日期对象。
37
+ * @returns {string} 格式化后的日期字符串。
40
38
  */
41
39
  private formatDate;
42
40
  /**
43
- * 根据列表数据动态生成 HTML 字符串。
44
- * @param data {ListRenderData} 列表数据
45
- * @param headers {string[]} (可选) 表头数组
46
- * @returns {string | null} 生成的 HTML 字符串,如果无数据则返回 null。
41
+ * 根据数据动态生成渲染图片所需的完整 HTML 字符串。
42
+ * @param data {ListRenderData} 列表数据。
43
+ * @param headers {string[]} (可选) 表头数组。
44
+ * @returns {string | null} 生成的 HTML 字符串,若无数据则返回 null。
47
45
  */
48
46
  private generateListHtml;
49
47
  }
package/lib/index.d.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import { Context, Schema } from 'koishi';
2
2
  export declare const usage = "\n<div style=\"border-radius: 10px; border: 1px solid #ddd; padding: 16px; margin-bottom: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.1);\">\n <h2 style=\"margin-top: 0; color: #4a6ee0;\">\uD83D\uDCCC \u63D2\u4EF6\u8BF4\u660E</h2>\n <p>\uD83D\uDCD6 <strong>\u4F7F\u7528\u6587\u6863</strong>\uFF1A\u8BF7\u70B9\u51FB\u5DE6\u4E0A\u89D2\u7684 <strong>\u63D2\u4EF6\u4E3B\u9875</strong> \u67E5\u770B\u63D2\u4EF6\u4F7F\u7528\u6587\u6863</p>\n <p>\uD83D\uDD0D <strong>\u66F4\u591A\u63D2\u4EF6</strong>\uFF1A\u53EF\u8BBF\u95EE <a href=\"https://github.com/YisRime\" style=\"color:#4a6ee0;text-decoration:none;\">\u82E1\u6DDE\u7684 GitHub</a> \u67E5\u770B\u672C\u4EBA\u7684\u6240\u6709\u63D2\u4EF6</p>\n</div>\n<div style=\"border-radius: 10px; border: 1px solid #ddd; padding: 16px; margin-bottom: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.1);\">\n <h2 style=\"margin-top: 0; color: #e0574a;\">\u2764\uFE0F \u652F\u6301\u4E0E\u53CD\u9988</h2>\n <p>\uD83C\uDF1F \u559C\u6B22\u8FD9\u4E2A\u63D2\u4EF6\uFF1F\u8BF7\u5728 <a href=\"https://github.com/YisRime\" style=\"color:#e0574a;text-decoration:none;\">GitHub</a> \u4E0A\u7ED9\u6211\u4E00\u4E2A Star\uFF01</p>\n <p>\uD83D\uDC1B \u9047\u5230\u95EE\u9898\uFF1F\u8BF7\u901A\u8FC7 <strong>Issues</strong> \u63D0\u4EA4\u53CD\u9988\uFF0C\u6216\u52A0\u5165 QQ \u7FA4 <a href=\"https://qm.qq.com/q/PdLMx9Jowq\" style=\"color:#e0574a;text-decoration:none;\"><strong>855571375</strong></a> \u8FDB\u884C\u4EA4\u6D41</p>\n</div>\n";
3
3
  export declare const name = "chat-analyse";
4
+ export declare const using: string[];
4
5
  export interface Config {
5
6
  }
6
7
  export declare const Config: Schema<Config>;
7
- export declare const using: string[];
8
8
  /**
9
- * Koishi 插件的入口函数。
10
- * @param ctx {Context} Koishi 的上下文对象,用于访问框架核心功能。
9
+ * Koishi 插件主入口函数。
10
+ * @param ctx {Context} Koishi 上下文,用于访问和扩展框架功能。
11
11
  */
12
12
  export declare function apply(ctx: Context): void;
package/lib/index.js CHANGED
@@ -33,33 +33,25 @@ var import_koishi3 = require("koishi");
33
33
  var Collector = class _Collector {
34
34
  /**
35
35
  * @constructor
36
- * @param ctx {Context} Koishi 的上下文对象
36
+ * @param ctx {Context} Koishi 上下文,用于访问框架核心功能。
37
37
  */
38
38
  constructor(ctx) {
39
39
  this.ctx = ctx;
40
- this.ctx.model.extend("analyse_msg", {
40
+ ctx.model.extend("analyse_msg", {
41
41
  id: "unsigned",
42
42
  channelId: "string",
43
43
  userId: "string",
44
44
  type: "string",
45
45
  content: "text",
46
46
  timestamp: "timestamp"
47
- }, {
48
- primary: "id",
49
- autoInc: true,
50
- indexes: ["timestamp", "channelId", "userId", "type"]
51
- });
52
- this.ctx.model.extend("analyse_name", {
47
+ }, { primary: "id", autoInc: true, indexes: ["timestamp", "channelId", "userId", "type"] });
48
+ ctx.model.extend("analyse_name", {
53
49
  channelId: "string",
54
50
  channelName: "string",
55
51
  userId: "string",
56
52
  userName: "string"
57
- }, {
58
- primary: ["channelId", "userId"]
59
- });
60
- ctx.on("message", (session) => {
61
- this.handleMessage(session);
62
- });
53
+ }, { primary: ["channelId", "userId"] });
54
+ ctx.on("message", (session) => this.handleMessage(session));
63
55
  this.flushInterval = setInterval(() => this.flushBuffer(), _Collector.FLUSH_INTERVAL);
64
56
  ctx.on("dispose", () => {
65
57
  clearInterval(this.flushInterval);
@@ -69,25 +61,28 @@ var Collector = class _Collector {
69
61
  static {
70
62
  __name(this, "Collector");
71
63
  }
64
+ // 数据刷新配置
72
65
  static FLUSH_INTERVAL = 60 * 1e3;
66
+ // 每分钟刷新一次
73
67
  static BUFFER_THRESHOLD = 100;
68
+ // 缓冲区达到100条消息时刷新
69
+ // 消息和名称缓存
74
70
  msgBuffer = [];
75
- flushInterval;
76
71
  nameCache = /* @__PURE__ */ new Map();
77
72
  pendingNameRequests = /* @__PURE__ */ new Map();
73
+ flushInterval;
78
74
  /**
79
- * 核心消息处理函数。
80
- * @param session {Session} 消息会话对象
75
+ * 核心消息处理器,对消息进行格式化并存入缓冲区。
76
+ * @param session {Session} 消息会话对象。
81
77
  */
82
78
  async handleMessage(session) {
83
79
  const { userId, channelId, guildId, content, timestamp, argv, elements } = session;
84
80
  const effectiveId = channelId || guildId;
85
- if (!effectiveId || !userId || !timestamp) return;
86
- await this.updateNameIfNeeded(session, effectiveId);
81
+ if (!effectiveId || !userId || !timestamp || !content?.trim()) return;
82
+ this.updateNameIfNeeded(session, effectiveId);
87
83
  const isCommand = !!argv?.command;
88
84
  const type = isCommand ? argv.command.name : this.summarizeElementTypes(elements);
89
85
  const finalContent = isCommand ? content : this.sanitizeContent(elements);
90
- if (!finalContent?.trim()) return;
91
86
  this.msgBuffer.push({
92
87
  channelId: effectiveId,
93
88
  userId,
@@ -95,35 +90,38 @@ var Collector = class _Collector {
95
90
  content: finalContent,
96
91
  timestamp: new Date(timestamp)
97
92
  });
98
- if (this.msgBuffer.length >= _Collector.BUFFER_THRESHOLD) {
99
- this.flushBuffer();
100
- }
93
+ if (this.msgBuffer.length >= _Collector.BUFFER_THRESHOLD) await this.flushBuffer();
101
94
  }
102
95
  /**
103
- * 从消息元素中提取并汇总类型。
96
+ * 汇总消息元素的类型,生成紧凑的类型字符串。
97
+ * @param elements {Element[]} 消息元素数组。
98
+ * @returns {string} 类型汇总字符串,如 `[text][img]`。
104
99
  */
105
100
  summarizeElementTypes(elements) {
106
- return [...new Set(elements.map((e) => `[${e.type}]`))].join("");
101
+ const types = new Set(elements.map((e) => `[${e.type}]`));
102
+ return Array.from(types).join("");
107
103
  }
108
104
  /**
109
- * 从消息元素中提取文本化、安全的内容。
105
+ * 清理并格式化消息内容,提取关键信息。
106
+ * @param elements {Element[]} 消息元素数组。
107
+ * @returns {string} 处理后的内容字符串。
110
108
  */
111
109
  sanitizeContent(elements) {
112
- return elements.map((element) => {
113
- switch (element.type) {
110
+ return elements.map((e) => {
111
+ switch (e.type) {
114
112
  case "text":
115
- return element.attrs.content;
113
+ return e.attrs.content;
116
114
  case "img":
117
- return element.attrs.summary === "[动画表情]" ? "[gif]" : `[img]`;
115
+ return e.attrs.summary === "[动画表情]" ? "[gif]" : "[img]";
118
116
  case "at":
119
- return `[at:${element.attrs.id}]`;
117
+ return `[at:${e.attrs.id}]`;
120
118
  default:
121
- return `[${element.type}]`;
119
+ return `[${e.type}]`;
122
120
  }
123
121
  }).join("");
124
122
  }
125
123
  /**
126
- * 将内存缓冲区的消息数据批量写入数据库。
124
+ * 将内存缓冲区的消息批量写入数据库,并处理写入失败的情况。
127
125
  */
128
126
  async flushBuffer() {
129
127
  if (this.msgBuffer.length === 0) return;
@@ -137,27 +135,27 @@ var Collector = class _Collector {
137
135
  }
138
136
  }
139
137
  /**
140
- * 检查并更新用户和群组的名称信息。
138
+ * 检查用户和群组名称是否需要更新,利用缓存和请求锁机制避免重复调用。
139
+ * @param session {Session} 消息会话对象。
140
+ * @param effectiveId {string} 有效的频道/群组ID。
141
141
  */
142
142
  async updateNameIfNeeded(session, effectiveId) {
143
143
  const { userId } = session;
144
144
  if (!userId) return;
145
145
  const cacheKey = `${effectiveId}:${userId}`;
146
+ const CACHE_EXPIRATION = 24 * 60 * 60 * 1e3;
146
147
  if (this.pendingNameRequests.has(cacheKey)) return this.pendingNameRequests.get(cacheKey);
147
148
  const cached = this.nameCache.get(cacheKey);
148
- const CACHE_EXPIRATION = 24 * 60 * 60 * 1e3;
149
149
  if (cached && Date.now() - cached.timestamp < CACHE_EXPIRATION) return;
150
150
  const promise = this.fetchAndUpdateNames(session, effectiveId, cacheKey);
151
151
  this.pendingNameRequests.set(cacheKey, promise);
152
- try {
153
- await promise;
154
- } finally {
155
- this.pendingNameRequests.delete(cacheKey);
156
- }
152
+ promise.finally(() => this.pendingNameRequests.delete(cacheKey));
157
153
  }
158
154
  /**
159
- * @private
160
- * 封装了实际获取和更新名称的异步逻辑。
155
+ * 异步获取用户和群组的最新名称,并更新到数据库和内存缓存。
156
+ * @param session {Session} 消息会话对象。
157
+ * @param effectiveId {string} 频道/群组ID。
158
+ * @param cacheKey {string} 用于缓存的键。
161
159
  */
162
160
  async fetchAndUpdateNames(session, effectiveId, cacheKey) {
163
161
  try {
@@ -193,7 +191,7 @@ var import_koishi = require("koishi");
193
191
  var Renderer = class {
194
192
  /**
195
193
  * @constructor
196
- * @param ctx {Context} Koishi 的上下文对象
194
+ * @param ctx {Context} Koishi 上下文,用于访问 puppeteer 服务。
197
195
  */
198
196
  constructor(ctx) {
199
197
  this.ctx = ctx;
@@ -204,87 +202,61 @@ var Renderer = class {
204
202
  /**
205
203
  * 将列表数据渲染为图片。
206
204
  * @param data {ListRenderData} 待渲染的列表数据。
207
- * @param headers {string[]} (可选) 表头数组。如果不提供或为空,则不渲染表头部分。
208
- * @returns {Promise<string | Buffer>} 成功时返回图片 Buffer,失败或无数据时返回提示文本。
205
+ * @param headers {string[]} (可选) 表头文案数组,若不提供则不渲染表头。
206
+ * @returns {Promise<string | Buffer>} 成功时返回图片 Buffer,无数据时返回提示文本。
209
207
  */
210
208
  async renderList(data, headers) {
211
209
  const htmlContent = this.generateListHtml(data, headers);
210
+ if (!htmlContent) return "暂无数据可供渲染";
212
211
  return this.ctx.puppeteer.render(htmlContent);
213
212
  }
214
213
  /**
215
- * 格式化日期,提供相对时间和绝对时间显示。
216
- * @param date {Date} 日期对象
217
- * @returns {string} 格式化后的日期字符串
214
+ * 智能格式化日期,提供相对时间(如“刚刚”,“x分钟前”)和绝对日期。
215
+ * @param date {Date} 待格式化的日期对象。
216
+ * @returns {string} 格式化后的日期字符串。
218
217
  */
219
218
  formatDate(date) {
220
219
  if (!date) return "未知";
221
- const now = Date.now();
222
- const diff = now - date.getTime();
220
+ const diff = Date.now() - date.getTime();
223
221
  if (diff < import_koishi.Time.minute) return "刚刚";
224
222
  if (diff < import_koishi.Time.hour) return `${Math.floor(diff / import_koishi.Time.minute)} 分钟前`;
225
223
  if (diff < import_koishi.Time.day) return `${Math.floor(diff / import_koishi.Time.hour)} 小时前`;
226
- const pad = /* @__PURE__ */ __name((n) => n.toString().padStart(2, "0"), "pad");
227
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
224
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
228
225
  }
229
226
  /**
230
- * 根据列表数据动态生成 HTML 字符串。
231
- * @param data {ListRenderData} 列表数据
232
- * @param headers {string[]} (可选) 表头数组
233
- * @returns {string | null} 生成的 HTML 字符串,如果无数据则返回 null。
227
+ * 根据数据动态生成渲染图片所需的完整 HTML 字符串。
228
+ * @param data {ListRenderData} 列表数据。
229
+ * @param headers {string[]} (可选) 表头数组。
230
+ * @returns {string | null} 生成的 HTML 字符串,若无数据则返回 null。
234
231
  */
235
232
  generateListHtml(data, headers) {
236
233
  const { title, time, total, list } = data;
237
- if (!list || list.length === 0) return null;
238
- let tableHeadHtml = "";
239
- if (headers && headers.length > 0) {
240
- const numDataColumns = list[0].length;
241
- const headerCells = [];
242
- for (let i = 0; i < numDataColumns; i++) {
243
- const headerText = headers[i] || "";
244
- headerCells.push(`<th>${headerText}</th>`);
245
- }
246
- const allHeaders = `<th class="rank-cell">排名</th>${headerCells.join("")}`;
247
- tableHeadHtml = `<thead><tr>${allHeaders}</tr></thead>`;
248
- }
249
- const tableRows = list.map((rowItems, index) => {
234
+ if (!list?.length) return null;
235
+ const tableHeadHtml = headers?.length > 0 ? `<thead><tr><th class="rank-cell">排名</th>${headers.map((h2) => `<th>${h2}</th>`).join("")}</tr></thead>` : "";
236
+ const tableRowsHtml = list.map((row, index) => {
250
237
  const rank = index + 1;
251
- let rankClass = "";
252
- if (rank === 1) rankClass = "rank-gold";
253
- if (rank === 2) rankClass = "rank-silver";
254
- if (rank === 3) rankClass = "rank-bronze";
238
+ const rankClass = rank === 1 ? "rank-gold" : rank === 2 ? "rank-silver" : rank === 3 ? "rank-bronze" : "";
255
239
  const rankCell = `<td class="rank-cell"><span class="rank-badge ${rankClass}">${rank}</span></td>`;
256
- const dataCells = rowItems.map((cellData) => {
257
- let cellClass = "data-cell";
258
- let content;
259
- if (cellData instanceof Date) {
260
- cellClass += " date-cell";
261
- content = this.formatDate(cellData);
262
- } else if (typeof cellData === "number") {
263
- cellClass += " count-cell";
264
- content = cellData;
265
- } else {
266
- cellClass += " name-cell";
267
- content = String(cellData);
268
- }
269
- return `<td class="${cellClass}">${content}</td>`;
240
+ const dataCells = row.map((cell) => {
241
+ if (cell instanceof Date) return `<td class="data-cell date-cell">${this.formatDate(cell)}</td>`;
242
+ if (typeof cell === "number") return `<td class="data-cell count-cell">${cell}</td>`;
243
+ return `<td class="data-cell name-cell">${String(cell)}</td>`;
270
244
  }).join("");
271
245
  return `<tr>${rankCell}${dataCells}</tr>`;
272
246
  }).join("");
273
- const metaInfo = total !== void 0 ? `<div class="total-count">总计: ${total}</div>` : "";
274
- const timeLabel = `生成于 ${time.getFullYear()}-${String(time.getMonth() + 1).padStart(2, "0")}-${String(time.getDate()).padStart(2, "0")} ${String(time.getHours()).padStart(2, "0")}:${String(time.getMinutes()).padStart(2, "0")}`;
247
+ const metaInfoHtml = `
248
+ <div class="meta-group">
249
+ ${total !== void 0 ? `<div class="total-count">总计: ${total}</div>` : ""}
250
+ <div class="time-label">生成于 ${time.toLocaleString("zh-CN", { hour12: false })}</div>
251
+ </div>
252
+ `;
275
253
  const styles = `
276
254
  :root {
277
- --bg-color: #f7f8fa; --card-bg: #ffffff; --text-color: #333;
278
- --header-color: #1f2329; --sub-text-color: #646a73;
279
- --border-color: #e4e6eb; --accent-color: #4a6ee0;
255
+ --bg-color: #f7f8fa; --card-bg: #ffffff; --text-color: #333; --header-color: #1f2329;
256
+ --sub-text-color: #646a73; --border-color: #e4e6eb; --accent-color: #4a6ee0;
280
257
  --gold: #ffc327; --silver: #a8b5c1; --bronze: #d69864;
281
258
  }
282
- body {
283
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
284
- background: var(--bg-color); margin: 0; padding: 20px;
285
- width: 700px; box-sizing: border-box;
286
- -webkit-font-smoothing: antialiased;
287
- }
259
+ 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; }
288
260
  .container { background: var(--card-bg); border-radius: 12px; box-shadow: 0 6px 16px rgba(0,0,0,0.08); padding: 24px; }
289
261
  .header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 1px solid var(--border-color); padding-bottom: 16px; margin-bottom: 16px; }
290
262
  .title-group h1 { font-size: 24px; font-weight: 700; color: var(--header-color); margin: 0; }
@@ -292,15 +264,14 @@ var Renderer = class {
292
264
  .meta-group .total-count { font-size: 22px; font-weight: 700; color: var(--accent-color); }
293
265
  .meta-group .time-label { font-size: 13px; color: var(--sub-text-color); margin-top: 4px; }
294
266
  table { width: 100%; border-collapse: collapse; color: var(--text-color); }
295
- th, td { padding: 12px 8px; text-align: left; border-bottom: 1px solid var(--border-color); }
267
+ th, td { padding: 12px 8px; text-align: left; border-bottom: 1px solid var(--border-color); vertical-align: middle; }
296
268
  th { font-size: 13px; font-weight: 600; color: var(--sub-text-color); }
297
- td { font-size: 15px; vertical-align: middle; }
269
+ td { font-size: 15px; }
298
270
  tr:last-child td { border-bottom: none; }
299
271
  .rank-cell { width: 50px; text-align: center; }
300
272
  .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; }
301
- .rank-gold { background-color: var(--gold); color: #fff; }
302
- .rank-silver { background-color: var(--silver); color: #fff; }
303
- .rank-bronze { background-color: var(--bronze); color: #fff; }
273
+ .rank-gold, .rank-silver, .rank-bronze { color: #fff; }
274
+ .rank-gold { background-color: var(--gold); } .rank-silver { background-color: var(--silver); } .rank-bronze { background-color: var(--bronze); }
304
275
  .data-cell { word-break: break-all; }
305
276
  .name-cell { font-weight: 600; color: var(--header-color); }
306
277
  .count-cell { text-align: right; font-weight: 600; color: var(--accent-color); }
@@ -308,33 +279,24 @@ var Renderer = class {
308
279
  `;
309
280
  return `
310
281
  <!DOCTYPE html><html lang="zh-CN">
311
- <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>${title}</title><style>${styles}</style></head>
282
+ <head><meta charset="UTF-8"><title>${title}</title><style>${styles}</style></head>
312
283
  <body>
313
284
  <div class="container">
314
285
  <div class="header">
315
286
  <div class="title-group"><h1>${title}</h1></div>
316
- <div class="meta-group">${metaInfo}<div class="time-label">${timeLabel}</div></div>
287
+ ${metaInfoHtml}
317
288
  </div>
318
- <table>
319
- ${tableHeadHtml}
320
- <tbody>${tableRows}</tbody>
321
- </table>
289
+ <table>${tableHeadHtml}<tbody>${tableRowsHtml}</tbody></table>
322
290
  </div>
323
- </body></html>
324
- `;
291
+ </body></html>`;
325
292
  }
326
293
  };
327
294
 
328
295
  // src/CmdStat.ts
329
296
  var CmdStat = class {
330
- static {
331
- __name(this, "CmdStat");
332
- }
333
- renderer;
334
- ctx;
335
- constructor(context) {
336
- this.ctx = context;
337
- this.renderer = new Renderer(this.ctx);
297
+ constructor(ctx) {
298
+ this.ctx = ctx;
299
+ this.renderer = new Renderer(ctx);
338
300
  this.ctx.model.extend("analyse_cmd", {
339
301
  channelId: "string",
340
302
  userId: "string",
@@ -345,8 +307,7 @@ var CmdStat = class {
345
307
  this.ctx.on("command/before-execute", async ({ command, session }) => {
346
308
  const { userId, guildId } = session;
347
309
  if (!guildId || !userId) return;
348
- const commandName = command.name;
349
- const query = { channelId: guildId, userId, command: commandName };
310
+ const query = { channelId: guildId, userId, command: command.name };
350
311
  await this.ctx.database.upsert("analyse_cmd", (row) => [{
351
312
  ...query,
352
313
  count: import_koishi2.$.add(import_koishi2.$.ifNull(row.count, 0), 1),
@@ -354,38 +315,26 @@ var CmdStat = class {
354
315
  }]);
355
316
  });
356
317
  }
318
+ static {
319
+ __name(this, "CmdStat");
320
+ }
321
+ renderer;
357
322
  /**
358
- * @method registerCommands
359
- * @description 注册命令,并通过选项支持不同维度的查询。
323
+ * 注册所有相关的子命令到主 `analyse` 命令下。
324
+ * @param analyse {Command} 主 `analyse` 命令实例。
360
325
  */
361
326
  registerCommands(analyse) {
362
- analyse.subcommand(".command", "命令使用统计").option("user", "-u [user:user] 指定用户").option("guild", "-g [guildId:string] 指定群组").usage(`查询命令使用统计。默认查询全局统计,可通过选项指定用户和群组。`).action(async ({ session, options }) => {
327
+ analyse.subcommand(".command", "命令使用统计").option("user", "-u [user:user] 查看指定用户的统计").option("guild", "-g [guildId:string] 查看指定群组的统计 (默认当前群)").usage("查询命令使用统计。支持按用户、按群组或组合查询。").action(async ({ session, options }) => {
363
328
  const userId = options.user ? import_koishi2.h.select(options.user, "user")[0]?.attrs.id : void 0;
364
329
  let guildId = options.guild;
365
330
  if (options.guild === "" && !options.user) {
366
- if (!session.guildId) return "请指定群组 ID";
331
+ if (!session.guildId) return "私聊中请使用 -g <群组ID> 指定群组。";
367
332
  guildId = session.guildId;
368
333
  }
369
334
  try {
370
335
  const stats = await this.getCommandStats(guildId, userId);
371
336
  if (typeof stats === "string") return stats;
372
- let title;
373
- const titleParts = [];
374
- if (userId) {
375
- const user = await session.bot.getUser(userId).catch(() => null);
376
- titleParts.push(`用户 ${user?.name || userId}`);
377
- }
378
- if (guildId) {
379
- const guild = await session.bot.getGuild(guildId).catch(() => null);
380
- titleParts.push(`群组 ${guild?.name || guildId}`);
381
- }
382
- if (userId && !guildId) {
383
- title = `${titleParts[0]}的全局命令统计`;
384
- } else if (titleParts.length > 0) {
385
- title = `${titleParts.join("、")}的命令统计`;
386
- } else {
387
- title = "全局命令统计";
388
- }
337
+ const title = await this.generateTitle(session, guildId, userId);
389
338
  const renderData = {
390
339
  title,
391
340
  time: /* @__PURE__ */ new Date(),
@@ -402,34 +351,41 @@ var CmdStat = class {
402
351
  });
403
352
  }
404
353
  /**
405
- * @private
406
- * @method getCommandStats
407
- * @description 从数据库获取并处理命令统计数据,兼容全局、群组和个人查询。
408
- * @returns 返回一个包含二维数组列表和总数的对象,或错误字符串。
354
+ * 根据查询参数动态生成图片标题。
355
+ */
356
+ async generateTitle(session, guildId, userId) {
357
+ if (userId && guildId) {
358
+ const userName = (await session.bot.getUser(userId).catch(() => null))?.name || userId;
359
+ const guildName = (await session.bot.getGuild(guildId).catch(() => null))?.name || guildId;
360
+ return `${userName} 在 ${guildName} 的命令统计`;
361
+ }
362
+ if (userId) {
363
+ const userName = (await session.bot.getUser(userId).catch(() => null))?.name || userId;
364
+ return `${userName} 的全局命令统计`;
365
+ }
366
+ if (guildId) {
367
+ const guildName = (await session.bot.getGuild(guildId).catch(() => null))?.name || guildId;
368
+ return `${guildName} 的命令统计`;
369
+ }
370
+ return "全局命令统计";
371
+ }
372
+ /**
373
+ * 从数据库获取并聚合命令统计数据。
374
+ * @param guildId {string} (可选) 群组ID。
375
+ * @param userId {string} (可选) 用户ID。
376
+ * @returns {Promise<{ list: RenderListItem[], total: number } | string>} 包含结果列表和总数的对象,或错误/提示信息。
409
377
  */
410
378
  async getCommandStats(guildId, userId) {
411
379
  const query = {};
412
380
  if (guildId) query.channelId = guildId;
413
381
  if (userId) query.userId = userId;
414
- if (userId) {
415
- const records = await this.ctx.database.get("analyse_cmd", query);
416
- if (records.length === 0) return "暂无统计数据";
417
- const totalCount2 = records.reduce((sum, record) => sum + record.count, 0);
418
- const sortedList2 = records.map((r) => ({ name: r.command, count: r.count, lastUsed: r.timestamp })).sort((a, b) => b.count - a.count);
419
- const list2 = sortedList2.map((item) => [item.name, item.count, item.lastUsed]);
420
- return { list: list2, total: totalCount2 };
421
- }
422
- const aggregatedStats = await this.ctx.database.select("analyse_cmd", query).groupBy(
423
- ["command"],
424
- {
425
- count: /* @__PURE__ */ __name((row) => import_koishi2.$.sum(row.count), "count"),
426
- lastUsed: /* @__PURE__ */ __name((row) => import_koishi2.$.max(row.timestamp), "lastUsed")
427
- }
428
- ).execute();
382
+ const aggregatedStats = await this.ctx.database.select("analyse_cmd", query).groupBy(["command"], {
383
+ count: /* @__PURE__ */ __name((row) => import_koishi2.$.sum(row.count), "count"),
384
+ lastUsed: /* @__PURE__ */ __name((row) => import_koishi2.$.max(row.timestamp), "lastUsed")
385
+ }).orderBy("count", "desc").execute();
429
386
  if (aggregatedStats.length === 0) return "暂无统计数据";
430
387
  const totalCount = aggregatedStats.reduce((sum, record) => sum + record.count, 0);
431
- const sortedList = aggregatedStats.sort((a, b) => b.count - a.count);
432
- const list = sortedList.map((item) => [item.command, item.count, item.lastUsed]);
388
+ const list = aggregatedStats.map((item) => [item.command, item.count, item.lastUsed]);
433
389
  return { list, total: totalCount };
434
390
  }
435
391
  };
@@ -448,8 +404,8 @@ var usage = `
448
404
  </div>
449
405
  `;
450
406
  var name = "chat-analyse";
451
- var Config = import_koishi3.Schema.object({});
452
407
  var using = ["database", "puppeteer"];
408
+ var Config = import_koishi3.Schema.object({});
453
409
  function apply(ctx) {
454
410
  new Collector(ctx);
455
411
  const cmd = new CmdStat(ctx);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-chat-analyse",
3
3
  "description": "聊天记录分析",
4
- "version": "0.2.3",
4
+ "version": "0.2.4",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],