koishi-plugin-chat-analyse 0.2.2 → 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,44 +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;
30
+ private pendingNameRequests;
31
+ private flushInterval;
32
32
  /**
33
33
  * @constructor
34
- * @param ctx {Context} Koishi 的上下文对象
34
+ * @param ctx {Context} Koishi 上下文,用于访问框架核心功能。
35
35
  */
36
36
  constructor(ctx: Context);
37
37
  /**
38
- * 核心消息处理函数。
39
- * @param session {Session} 消息会话对象
38
+ * 核心消息处理器,对消息进行格式化并存入缓冲区。
39
+ * @param session {Session} 消息会话对象。
40
40
  */
41
41
  private handleMessage;
42
42
  /**
43
- * 从消息元素中提取并汇总类型。
44
- * @example
45
- * // returns "[text][img]"
46
- * summarizeElementTypes([{type: 'text'}, {type: 'img'}])
43
+ * 汇总消息元素的类型,生成紧凑的类型字符串。
44
+ * @param elements {Element[]} 消息元素数组。
45
+ * @returns {string} 类型汇总字符串,如 `[text][img]`。
47
46
  */
48
47
  private summarizeElementTypes;
49
48
  /**
50
- * 从消息元素中提取文本化、安全的内容。
49
+ * 清理并格式化消息内容,提取关键信息。
50
+ * @param elements {Element[]} 消息元素数组。
51
+ * @returns {string} 处理后的内容字符串。
51
52
  */
52
53
  private sanitizeContent;
53
54
  /**
54
- * 将内存缓冲区的消息数据批量写入数据库。
55
+ * 将内存缓冲区的消息批量写入数据库,并处理写入失败的情况。
55
56
  */
56
57
  private flushBuffer;
57
58
  /**
58
- * 检查并更新用户和群组的名称信息。
59
- * 如果名称不在缓存或缓存已过期 (超过24小时),则从 API 获取并更新到数据库。
59
+ * 检查用户和群组名称是否需要更新,利用缓存和请求锁机制避免重复调用。
60
+ * @param session {Session} 消息会话对象。
61
+ * @param effectiveId {string} 有效的频道/群组ID。
60
62
  */
61
63
  private updateNameIfNeeded;
64
+ /**
65
+ * 异步获取用户和群组的最新名称,并更新到数据库和内存缓存。
66
+ * @param session {Session} 消息会话对象。
67
+ * @param effectiveId {string} 频道/群组ID。
68
+ * @param cacheKey {string} 用于缓存的键。
69
+ */
70
+ private fetchAndUpdateNames;
62
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,24 +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();
72
+ pendingNameRequests = /* @__PURE__ */ new Map();
73
+ flushInterval;
77
74
  /**
78
- * 核心消息处理函数。
79
- * @param session {Session} 消息会话对象
75
+ * 核心消息处理器,对消息进行格式化并存入缓冲区。
76
+ * @param session {Session} 消息会话对象。
80
77
  */
81
78
  async handleMessage(session) {
82
79
  const { userId, channelId, guildId, content, timestamp, argv, elements } = session;
83
80
  const effectiveId = channelId || guildId;
84
- if (!effectiveId || !userId || !timestamp) return;
81
+ if (!effectiveId || !userId || !timestamp || !content?.trim()) return;
85
82
  this.updateNameIfNeeded(session, effectiveId);
86
83
  const isCommand = !!argv?.command;
87
84
  const type = isCommand ? argv.command.name : this.summarizeElementTypes(elements);
88
85
  const finalContent = isCommand ? content : this.sanitizeContent(elements);
89
- if (!finalContent?.trim()) return;
90
86
  this.msgBuffer.push({
91
87
  channelId: effectiveId,
92
88
  userId,
@@ -94,38 +90,38 @@ var Collector = class _Collector {
94
90
  content: finalContent,
95
91
  timestamp: new Date(timestamp)
96
92
  });
97
- if (this.msgBuffer.length >= _Collector.BUFFER_THRESHOLD) {
98
- this.flushBuffer();
99
- }
93
+ if (this.msgBuffer.length >= _Collector.BUFFER_THRESHOLD) await this.flushBuffer();
100
94
  }
101
95
  /**
102
- * 从消息元素中提取并汇总类型。
103
- * @example
104
- * // returns "[text][img]"
105
- * summarizeElementTypes([{type: 'text'}, {type: 'img'}])
96
+ * 汇总消息元素的类型,生成紧凑的类型字符串。
97
+ * @param elements {Element[]} 消息元素数组。
98
+ * @returns {string} 类型汇总字符串,如 `[text][img]`。
106
99
  */
107
100
  summarizeElementTypes(elements) {
108
- 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("");
109
103
  }
110
104
  /**
111
- * 从消息元素中提取文本化、安全的内容。
105
+ * 清理并格式化消息内容,提取关键信息。
106
+ * @param elements {Element[]} 消息元素数组。
107
+ * @returns {string} 处理后的内容字符串。
112
108
  */
113
109
  sanitizeContent(elements) {
114
- return elements.map((element) => {
115
- switch (element.type) {
110
+ return elements.map((e) => {
111
+ switch (e.type) {
116
112
  case "text":
117
- return element.attrs.content;
113
+ return e.attrs.content;
118
114
  case "img":
119
- return element.attrs.summary === "[动画表情]" ? "[gif]" : `[img]`;
115
+ return e.attrs.summary === "[动画表情]" ? "[gif]" : "[img]";
120
116
  case "at":
121
- return `[at:${element.attrs.id}]`;
117
+ return `[at:${e.attrs.id}]`;
122
118
  default:
123
- return `[${element.type}]`;
119
+ return `[${e.type}]`;
124
120
  }
125
121
  }).join("");
126
122
  }
127
123
  /**
128
- * 将内存缓冲区的消息数据批量写入数据库。
124
+ * 将内存缓冲区的消息批量写入数据库,并处理写入失败的情况。
129
125
  */
130
126
  async flushBuffer() {
131
127
  if (this.msgBuffer.length === 0) return;
@@ -139,32 +135,50 @@ var Collector = class _Collector {
139
135
  }
140
136
  }
141
137
  /**
142
- * 检查并更新用户和群组的名称信息。
143
- * 如果名称不在缓存或缓存已过期 (超过24小时),则从 API 获取并更新到数据库。
138
+ * 检查用户和群组名称是否需要更新,利用缓存和请求锁机制避免重复调用。
139
+ * @param session {Session} 消息会话对象。
140
+ * @param effectiveId {string} 有效的频道/群组ID。
144
141
  */
145
142
  async updateNameIfNeeded(session, effectiveId) {
146
- const { userId, guildId, bot } = session;
143
+ const { userId } = session;
144
+ if (!userId) return;
147
145
  const cacheKey = `${effectiveId}:${userId}`;
148
- const cached = this.nameCache.get(cacheKey);
149
146
  const CACHE_EXPIRATION = 24 * 60 * 60 * 1e3;
147
+ if (this.pendingNameRequests.has(cacheKey)) return this.pendingNameRequests.get(cacheKey);
148
+ const cached = this.nameCache.get(cacheKey);
150
149
  if (cached && Date.now() - cached.timestamp < CACHE_EXPIRATION) return;
150
+ const promise = this.fetchAndUpdateNames(session, effectiveId, cacheKey);
151
+ this.pendingNameRequests.set(cacheKey, promise);
152
+ promise.finally(() => this.pendingNameRequests.delete(cacheKey));
153
+ }
154
+ /**
155
+ * 异步获取用户和群组的最新名称,并更新到数据库和内存缓存。
156
+ * @param session {Session} 消息会话对象。
157
+ * @param effectiveId {string} 频道/群组ID。
158
+ * @param cacheKey {string} 用于缓存的键。
159
+ */
160
+ async fetchAndUpdateNames(session, effectiveId, cacheKey) {
151
161
  try {
162
+ const { userId, guildId, bot } = session;
152
163
  const [guild, member] = await Promise.all([
153
- bot.getGuild(guildId),
154
- bot.getGuildMember(guildId, userId)
164
+ guildId ? bot.getGuild(guildId).catch(() => null) : Promise.resolve(null),
165
+ guildId && userId ? bot.getGuildMember(guildId, userId).catch(() => null) : Promise.resolve(null)
155
166
  ]);
156
167
  const channelName = guild?.name;
157
168
  const userName = member?.nick || member?.name;
158
- if (!channelName || !userName) return;
159
- await this.ctx.database.upsert("analyse_name", () => [{
169
+ if (!channelName || !userName) {
170
+ this.nameCache.set(cacheKey, { name: null, timestamp: Date.now() });
171
+ return;
172
+ }
173
+ await this.ctx.database.upsert("analyse_name", [{
160
174
  channelId: effectiveId,
161
- channelName,
162
175
  userId,
176
+ channelName,
163
177
  userName
164
178
  }]);
165
179
  this.nameCache.set(cacheKey, { name: userName, timestamp: Date.now() });
166
180
  } catch (error) {
167
- this.ctx.logger.warn("更新用户/群组名称失败:", error);
181
+ this.nameCache.set(cacheKey, { name: null, timestamp: Date.now() });
168
182
  }
169
183
  }
170
184
  };
@@ -177,7 +191,7 @@ var import_koishi = require("koishi");
177
191
  var Renderer = class {
178
192
  /**
179
193
  * @constructor
180
- * @param ctx {Context} Koishi 的上下文对象
194
+ * @param ctx {Context} Koishi 上下文,用于访问 puppeteer 服务。
181
195
  */
182
196
  constructor(ctx) {
183
197
  this.ctx = ctx;
@@ -188,87 +202,61 @@ var Renderer = class {
188
202
  /**
189
203
  * 将列表数据渲染为图片。
190
204
  * @param data {ListRenderData} 待渲染的列表数据。
191
- * @param headers {string[]} (可选) 表头数组。如果不提供或为空,则不渲染表头部分。
192
- * @returns {Promise<string | Buffer>} 成功时返回图片 Buffer,失败或无数据时返回提示文本。
205
+ * @param headers {string[]} (可选) 表头文案数组,若不提供则不渲染表头。
206
+ * @returns {Promise<string | Buffer>} 成功时返回图片 Buffer,无数据时返回提示文本。
193
207
  */
194
208
  async renderList(data, headers) {
195
209
  const htmlContent = this.generateListHtml(data, headers);
210
+ if (!htmlContent) return "暂无数据可供渲染";
196
211
  return this.ctx.puppeteer.render(htmlContent);
197
212
  }
198
213
  /**
199
- * 格式化日期,提供相对时间和绝对时间显示。
200
- * @param date {Date} 日期对象
201
- * @returns {string} 格式化后的日期字符串
214
+ * 智能格式化日期,提供相对时间(如“刚刚”,“x分钟前”)和绝对日期。
215
+ * @param date {Date} 待格式化的日期对象。
216
+ * @returns {string} 格式化后的日期字符串。
202
217
  */
203
218
  formatDate(date) {
204
219
  if (!date) return "未知";
205
- const now = Date.now();
206
- const diff = now - date.getTime();
220
+ const diff = Date.now() - date.getTime();
207
221
  if (diff < import_koishi.Time.minute) return "刚刚";
208
222
  if (diff < import_koishi.Time.hour) return `${Math.floor(diff / import_koishi.Time.minute)} 分钟前`;
209
223
  if (diff < import_koishi.Time.day) return `${Math.floor(diff / import_koishi.Time.hour)} 小时前`;
210
- const pad = /* @__PURE__ */ __name((n) => n.toString().padStart(2, "0"), "pad");
211
- 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")}`;
212
225
  }
213
226
  /**
214
- * 根据列表数据动态生成 HTML 字符串。
215
- * @param data {ListRenderData} 列表数据
216
- * @param headers {string[]} (可选) 表头数组
217
- * @returns {string | null} 生成的 HTML 字符串,如果无数据则返回 null。
227
+ * 根据数据动态生成渲染图片所需的完整 HTML 字符串。
228
+ * @param data {ListRenderData} 列表数据。
229
+ * @param headers {string[]} (可选) 表头数组。
230
+ * @returns {string | null} 生成的 HTML 字符串,若无数据则返回 null。
218
231
  */
219
232
  generateListHtml(data, headers) {
220
233
  const { title, time, total, list } = data;
221
- if (!list || list.length === 0) return null;
222
- let tableHeadHtml = "";
223
- if (headers && headers.length > 0) {
224
- const numDataColumns = list[0].length;
225
- const headerCells = [];
226
- for (let i = 0; i < numDataColumns; i++) {
227
- const headerText = headers[i] || "";
228
- headerCells.push(`<th>${headerText}</th>`);
229
- }
230
- const allHeaders = `<th class="rank-cell">排名</th>${headerCells.join("")}`;
231
- tableHeadHtml = `<thead><tr>${allHeaders}</tr></thead>`;
232
- }
233
- 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) => {
234
237
  const rank = index + 1;
235
- let rankClass = "";
236
- if (rank === 1) rankClass = "rank-gold";
237
- if (rank === 2) rankClass = "rank-silver";
238
- if (rank === 3) rankClass = "rank-bronze";
238
+ const rankClass = rank === 1 ? "rank-gold" : rank === 2 ? "rank-silver" : rank === 3 ? "rank-bronze" : "";
239
239
  const rankCell = `<td class="rank-cell"><span class="rank-badge ${rankClass}">${rank}</span></td>`;
240
- const dataCells = rowItems.map((cellData) => {
241
- let cellClass = "data-cell";
242
- let content;
243
- if (cellData instanceof Date) {
244
- cellClass += " date-cell";
245
- content = this.formatDate(cellData);
246
- } else if (typeof cellData === "number") {
247
- cellClass += " count-cell";
248
- content = cellData;
249
- } else {
250
- cellClass += " name-cell";
251
- content = String(cellData);
252
- }
253
- 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>`;
254
244
  }).join("");
255
245
  return `<tr>${rankCell}${dataCells}</tr>`;
256
246
  }).join("");
257
- const metaInfo = total !== void 0 ? `<div class="total-count">总计: ${total}</div>` : "";
258
- 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
+ `;
259
253
  const styles = `
260
254
  :root {
261
- --bg-color: #f7f8fa; --card-bg: #ffffff; --text-color: #333;
262
- --header-color: #1f2329; --sub-text-color: #646a73;
263
- --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;
264
257
  --gold: #ffc327; --silver: #a8b5c1; --bronze: #d69864;
265
258
  }
266
- body {
267
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
268
- background: var(--bg-color); margin: 0; padding: 20px;
269
- width: 700px; box-sizing: border-box;
270
- -webkit-font-smoothing: antialiased;
271
- }
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; }
272
260
  .container { background: var(--card-bg); border-radius: 12px; box-shadow: 0 6px 16px rgba(0,0,0,0.08); padding: 24px; }
273
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; }
274
262
  .title-group h1 { font-size: 24px; font-weight: 700; color: var(--header-color); margin: 0; }
@@ -276,15 +264,14 @@ var Renderer = class {
276
264
  .meta-group .total-count { font-size: 22px; font-weight: 700; color: var(--accent-color); }
277
265
  .meta-group .time-label { font-size: 13px; color: var(--sub-text-color); margin-top: 4px; }
278
266
  table { width: 100%; border-collapse: collapse; color: var(--text-color); }
279
- 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; }
280
268
  th { font-size: 13px; font-weight: 600; color: var(--sub-text-color); }
281
- td { font-size: 15px; vertical-align: middle; }
269
+ td { font-size: 15px; }
282
270
  tr:last-child td { border-bottom: none; }
283
271
  .rank-cell { width: 50px; text-align: center; }
284
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; }
285
- .rank-gold { background-color: var(--gold); color: #fff; }
286
- .rank-silver { background-color: var(--silver); color: #fff; }
287
- .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); }
288
275
  .data-cell { word-break: break-all; }
289
276
  .name-cell { font-weight: 600; color: var(--header-color); }
290
277
  .count-cell { text-align: right; font-weight: 600; color: var(--accent-color); }
@@ -292,33 +279,24 @@ var Renderer = class {
292
279
  `;
293
280
  return `
294
281
  <!DOCTYPE html><html lang="zh-CN">
295
- <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>
296
283
  <body>
297
284
  <div class="container">
298
285
  <div class="header">
299
286
  <div class="title-group"><h1>${title}</h1></div>
300
- <div class="meta-group">${metaInfo}<div class="time-label">${timeLabel}</div></div>
287
+ ${metaInfoHtml}
301
288
  </div>
302
- <table>
303
- ${tableHeadHtml}
304
- <tbody>${tableRows}</tbody>
305
- </table>
289
+ <table>${tableHeadHtml}<tbody>${tableRowsHtml}</tbody></table>
306
290
  </div>
307
- </body></html>
308
- `;
291
+ </body></html>`;
309
292
  }
310
293
  };
311
294
 
312
295
  // src/CmdStat.ts
313
296
  var CmdStat = class {
314
- static {
315
- __name(this, "CmdStat");
316
- }
317
- renderer;
318
- ctx;
319
- constructor(context) {
320
- this.ctx = context;
321
- this.renderer = new Renderer(this.ctx);
297
+ constructor(ctx) {
298
+ this.ctx = ctx;
299
+ this.renderer = new Renderer(ctx);
322
300
  this.ctx.model.extend("analyse_cmd", {
323
301
  channelId: "string",
324
302
  userId: "string",
@@ -328,9 +306,8 @@ var CmdStat = class {
328
306
  }, { primary: ["channelId", "userId", "command"] });
329
307
  this.ctx.on("command/before-execute", async ({ command, session }) => {
330
308
  const { userId, guildId } = session;
331
- if (!guildId || !userId || command.name === "analyse" || command.parent?.name === "analyse") return;
332
- const commandName = command.name;
333
- const query = { channelId: guildId, userId, command: commandName };
309
+ if (!guildId || !userId) return;
310
+ const query = { channelId: guildId, userId, command: command.name };
334
311
  await this.ctx.database.upsert("analyse_cmd", (row) => [{
335
312
  ...query,
336
313
  count: import_koishi2.$.add(import_koishi2.$.ifNull(row.count, 0), 1),
@@ -338,38 +315,26 @@ var CmdStat = class {
338
315
  }]);
339
316
  });
340
317
  }
318
+ static {
319
+ __name(this, "CmdStat");
320
+ }
321
+ renderer;
341
322
  /**
342
- * @method registerCommands
343
- * @description 注册命令,并通过选项支持不同维度的查询。
323
+ * 注册所有相关的子命令到主 `analyse` 命令下。
324
+ * @param analyse {Command} 主 `analyse` 命令实例。
344
325
  */
345
326
  registerCommands(analyse) {
346
- 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 }) => {
347
328
  const userId = options.user ? import_koishi2.h.select(options.user, "user")[0]?.attrs.id : void 0;
348
329
  let guildId = options.guild;
349
330
  if (options.guild === "" && !options.user) {
350
- if (!session.guildId) return "请指定群组 ID";
331
+ if (!session.guildId) return "私聊中请使用 -g <群组ID> 指定群组。";
351
332
  guildId = session.guildId;
352
333
  }
353
334
  try {
354
335
  const stats = await this.getCommandStats(guildId, userId);
355
336
  if (typeof stats === "string") return stats;
356
- let title;
357
- const titleParts = [];
358
- if (userId) {
359
- const user = await session.bot.getUser(userId).catch(() => null);
360
- titleParts.push(`用户 ${user?.name || userId}`);
361
- }
362
- if (guildId) {
363
- const guild = await session.bot.getGuild(guildId).catch(() => null);
364
- titleParts.push(`群组 ${guild?.name || guildId}`);
365
- }
366
- if (userId && !guildId) {
367
- title = `${titleParts[0]}的全局命令统计`;
368
- } else if (titleParts.length > 0) {
369
- title = `${titleParts.join("、")}的命令统计`;
370
- } else {
371
- title = "全局命令统计";
372
- }
337
+ const title = await this.generateTitle(session, guildId, userId);
373
338
  const renderData = {
374
339
  title,
375
340
  time: /* @__PURE__ */ new Date(),
@@ -386,34 +351,41 @@ var CmdStat = class {
386
351
  });
387
352
  }
388
353
  /**
389
- * @private
390
- * @method getCommandStats
391
- * @description 从数据库获取并处理命令统计数据,兼容全局、群组和个人查询。
392
- * @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>} 包含结果列表和总数的对象,或错误/提示信息。
393
377
  */
394
378
  async getCommandStats(guildId, userId) {
395
379
  const query = {};
396
380
  if (guildId) query.channelId = guildId;
397
381
  if (userId) query.userId = userId;
398
- const records = await this.ctx.database.get("analyse_cmd", query);
399
- if (records.length === 0) return "暂无统计数据";
400
- const totalCount = records.reduce((sum, record) => sum + record.count, 0);
401
- let sortedList;
402
- if (userId) {
403
- sortedList = records.map((r) => ({ name: r.command, count: r.count, lastUsed: r.timestamp })).sort((a, b) => b.count - a.count);
404
- } else {
405
- const commandMap = /* @__PURE__ */ new Map();
406
- for (const record of records) {
407
- const existing = commandMap.get(record.command) || { count: 0, lastUsed: /* @__PURE__ */ new Date(0) };
408
- existing.count += record.count;
409
- if (record.timestamp > existing.lastUsed) {
410
- existing.lastUsed = record.timestamp;
411
- }
412
- commandMap.set(record.command, existing);
413
- }
414
- sortedList = Array.from(commandMap.entries()).map(([name2, data]) => ({ name: name2, ...data })).sort((a, b) => b.count - a.count);
415
- }
416
- const list = sortedList.map((item) => [item.name, item.count, item.lastUsed]);
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();
386
+ if (aggregatedStats.length === 0) return "暂无统计数据";
387
+ const totalCount = aggregatedStats.reduce((sum, record) => sum + record.count, 0);
388
+ const list = aggregatedStats.map((item) => [item.command, item.count, item.lastUsed]);
417
389
  return { list, total: totalCount };
418
390
  }
419
391
  };
@@ -432,8 +404,8 @@ var usage = `
432
404
  </div>
433
405
  `;
434
406
  var name = "chat-analyse";
435
- var Config = import_koishi3.Schema.object({});
436
407
  var using = ["database", "puppeteer"];
408
+ var Config = import_koishi3.Schema.object({});
437
409
  function apply(ctx) {
438
410
  new Collector(ctx);
439
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.2",
4
+ "version": "0.2.4",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],