koishi-plugin-chat-analyse 0.5.3 → 0.5.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.
@@ -49,9 +49,7 @@ declare module 'koishi' {
49
49
  export declare class Collector {
50
50
  private ctx;
51
51
  private config;
52
- /** @property FLUSH_INTERVAL - 内存缓存区定时刷入数据库的间隔(毫秒)。 */
53
52
  private static readonly FLUSH_INTERVAL;
54
- /** @property BUFFER_THRESHOLD - 内存缓存区触发刷新的消息数量阈值。 */
55
53
  private static readonly BUFFER_THRESHOLD;
56
54
  private msgStatBuffer;
57
55
  private rankStatBuffer;
package/lib/Data.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { Context, Command } from 'koishi';
2
+ /**
3
+ * @class Data
4
+ * @description 提供数据备份、恢复和清理等高级管理功能。
5
+ */
6
+ export declare class Data {
7
+ private ctx;
8
+ private dataDir;
9
+ constructor(ctx: Context);
10
+ /**
11
+ * @public
12
+ * @method registerCommands
13
+ * @description 在主命令下注册所有数据管理相关的子命令。
14
+ * @param cmd - 主命令实例。
15
+ */
16
+ registerCommands(cmd: Command): void;
17
+ }
package/lib/Renderer.d.ts CHANGED
@@ -1,6 +1,4 @@
1
1
  import { Context } from 'koishi';
2
- /** 定义了渲染列表中单行数据的格式,是一个由字符串、数字或 `Date` 对象构成的数组。 */
3
- export type RenderListItem = (string | number | Date)[];
4
2
  /**
5
3
  * @interface ListRenderData
6
4
  * @description 定义了调用 `renderList` 方法所需的数据结构。
@@ -9,7 +7,7 @@ export interface ListRenderData {
9
7
  title: string;
10
8
  time: Date;
11
9
  total?: string | number;
12
- list: RenderListItem[];
10
+ list: (string | number | Date)[][];
13
11
  }
14
12
  /**
15
13
  * @interface CircadianChartData
@@ -23,27 +21,57 @@ export interface CircadianChartData {
23
21
  }
24
22
  /**
25
23
  * @class Renderer
26
- * @description 负责将结构化的列表数据渲染为设计精美的 PNG 图片。
24
+ * @description 负责将结构化的数据渲染为设计精美的 PNG 图片。
27
25
  */
28
26
  export declare class Renderer {
29
27
  private ctx;
28
+ private readonly COMMON_STYLE;
30
29
  /**
31
- * @private @readonly
32
- * @property COMMON_STYLE - 存储所有卡片共享的基础 CSS 样式。
30
+ * @constructor
31
+ * @description Renderer 类的构造函数。
32
+ * @param {Context} ctx - Koishi 的插件上下文,用于访问 logger 和 puppeteer 服务。
33
33
  */
34
- private readonly COMMON_STYLE;
35
34
  constructor(ctx: Context);
36
35
  /**
37
36
  * @private
38
37
  * @method generateFullHtml
39
- * @description 将卡片内容和特定样式组合成一个完整的 HTML 文档。
40
- * @param cardContent - 卡片部分的 HTML 字符串。
41
- * @param specificStyles - 该卡片类型独有的 CSS 样式字符串。
42
- * @returns 完整的 HTML 字符串。
38
+ * @description 将卡片内容和特定样式组合成一个完整的 HTML 文档,以便进行渲染。
39
+ * @param {string} cardContent - 卡片主体部分的 HTML 字符串。
40
+ * @param {string} specificStyles - 针对该卡片类型的特定 CSS 样式字符串。
41
+ * @returns {string} - 一个完整的、可被浏览器渲染的 HTML 字符串。
43
42
  */
44
43
  private generateFullHtml;
44
+ /**
45
+ * @private
46
+ * @method htmlToImage
47
+ * @description 使用 puppeteer 将给定的 HTML 字符串内容渲染成 PNG 图片的 Buffer。
48
+ * @param {string} fullHtmlContent - 完整的 HTML 内容字符串。
49
+ * @returns {Promise<Buffer | null>} - 成功时返回包含 PNG 图片数据的 Buffer,失败则返回 null。
50
+ */
45
51
  private htmlToImage;
52
+ /**
53
+ * @private
54
+ * @method formatDate
55
+ * @description 将 Date 对象格式化为易于理解的相对时间字符串(如“刚刚”,“5分钟前”)。
56
+ * @param {Date} date - 需要格式化的日期对象。
57
+ * @returns {string} - 格式化后的时间字符串。
58
+ */
46
59
  private formatDate;
60
+ /**
61
+ * @public
62
+ * @method renderList
63
+ * @description 将表格型数据渲染成一个或多个列表形式的图片。如果数据过多,会自动进行分页渲染。
64
+ * @param {ListRenderData} data - 包含标题、时间、总计和列表数据的对象。
65
+ * @param {string[]} [headers] - (可选)列表的表头数组。
66
+ * @returns {Promise<string | Buffer[]>} - 成功时返回包含图片 Buffer 的数组,失败或无数据时返回提示字符串。
67
+ */
47
68
  renderList(data: ListRenderData, headers?: string[]): Promise<string | Buffer[]>;
69
+ /**
70
+ * @public
71
+ * @method renderCircadianChart
72
+ * @description 将 24 小时制的活跃度数据渲染成一张柱状图图片。
73
+ * @param {CircadianChartData} data - 包含标题、时间、总计和 24 小时数据数组的对象。
74
+ * @returns {Promise<string | Buffer[]>} - 成功时返回包含图片 Buffer 的数组,失败或无数据时返回提示字符串。
75
+ */
48
76
  renderCircadianChart(data: CircadianChartData): Promise<string | Buffer[]>;
49
77
  }
package/lib/Stat.d.ts CHANGED
@@ -16,10 +16,10 @@ export declare class Stat {
16
16
  constructor(ctx: Context, config: Config);
17
17
  /**
18
18
  * @public @method registerCommands
19
- * @description 根据配置,动态地将子命令注册到主 `analyse` 命令下。
20
- * @param analyse - 主 `analyse` 命令实例。
19
+ * @description 根据配置,动态地将子命令注册到主命令下。
20
+ * @param cmd - 主命令实例。
21
21
  */
22
- registerCommands(analyse: Command): void;
22
+ registerCommands(cmd: Command): void;
23
23
  /**
24
24
  * @private @method parseQueryScope
25
25
  * @description 解析命令选项,转换为包含 UIDs 和描述性信息的统一查询范围对象。
package/lib/WhoAt.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { Context, Command } from 'koishi';
2
+ import { Config } from './index';
3
+ /**
4
+ * @class WhoAt
5
+ * @description 负责处理谁提及我相关功能,包括查询和定时清理。
6
+ */
7
+ export declare class WhoAt {
8
+ private ctx;
9
+ private config;
10
+ /**
11
+ * @param ctx - Koishi 的插件上下文。
12
+ * @param config - 插件的配置对象。
13
+ */
14
+ constructor(ctx: Context, config: Config);
15
+ /**
16
+ * @public @method registerCommand
17
+ * @description 在主命令下注册子命令。
18
+ * @param cmd - 主命令实例。
19
+ */
20
+ registerCommand(cmd: Command): void;
21
+ }
package/lib/index.d.ts CHANGED
@@ -12,14 +12,14 @@ export interface Config {
12
12
  enableCmdStat: boolean;
13
13
  enableMsgStat: boolean;
14
14
  enableRankStat: boolean;
15
- enableActivityStat: boolean;
15
+ enableActivity: boolean;
16
16
  enableOriRecord: boolean;
17
17
  enableWhoAt: boolean;
18
- enableData: boolean;
18
+ enableDataIO: boolean;
19
19
  atRetentionDays: number;
20
20
  rankRetentionDays: number;
21
21
  }
22
- /** @description 插件的配置项定义,使用 Koishi Schema 构建。 */
22
+ /** @description 插件的配置项定义 */
23
23
  export declare const Config: Schema<Config>;
24
24
  /**
25
25
  * @function apply
package/lib/index.js CHANGED
@@ -50,11 +50,21 @@ var Collector = class _Collector {
50
50
  this.ctx = ctx;
51
51
  this.config = config;
52
52
  this.ctx.model.extend("analyse_user", { uid: "unsigned", channelId: "string", userId: "string", channelName: "string", userName: "string" }, { primary: "uid", autoInc: true, indexes: ["channelId", "userId"] });
53
- this.ctx.model.extend("analyse_cmd", { uid: "unsigned", command: "string", count: "unsigned", timestamp: "timestamp" }, { primary: ["uid", "command"] });
54
- this.ctx.model.extend("analyse_msg", { uid: "unsigned", type: "string", count: "unsigned", timestamp: "timestamp" }, { primary: ["uid", "type"] });
55
- this.ctx.model.extend("analyse_rank", { uid: "unsigned", type: "string", count: "unsigned", timestamp: "timestamp" }, { primary: ["uid", "timestamp", "type"] });
56
- if (this.config.enableOriRecord) this.ctx.model.extend("analyse_cache", { id: "unsigned", uid: "unsigned", content: "text", timestamp: "timestamp" }, { primary: "id", autoInc: true, indexes: ["uid", "timestamp"] });
57
- if (this.config.enableWhoAt) this.ctx.model.extend("analyse_at", { id: "unsigned", uid: "unsigned", target: "string", content: "text", timestamp: "timestamp" }, { primary: "id", autoInc: true, indexes: ["target", "uid"] });
53
+ if (config.enableCmdStat) {
54
+ this.ctx.model.extend("analyse_cmd", { uid: "unsigned", command: "string", count: "unsigned", timestamp: "timestamp" }, { primary: ["uid", "command"] });
55
+ }
56
+ if (config.enableMsgStat) {
57
+ this.ctx.model.extend("analyse_msg", { uid: "unsigned", type: "string", count: "unsigned", timestamp: "timestamp" }, { primary: ["uid", "type"] });
58
+ }
59
+ if (config.enableRankStat || config.enableActivity) {
60
+ this.ctx.model.extend("analyse_rank", { uid: "unsigned", type: "string", count: "unsigned", timestamp: "timestamp" }, { primary: ["uid", "timestamp", "type"] });
61
+ }
62
+ if (this.config.enableOriRecord) {
63
+ this.ctx.model.extend("analyse_cache", { id: "unsigned", uid: "unsigned", content: "text", timestamp: "timestamp" }, { primary: "id", autoInc: true, indexes: ["uid", "timestamp"] });
64
+ }
65
+ if (this.config.enableWhoAt) {
66
+ this.ctx.model.extend("analyse_at", { id: "unsigned", uid: "unsigned", target: "string", content: "text", timestamp: "timestamp" }, { primary: "id", autoInc: true, indexes: ["target", "uid"] });
67
+ }
58
68
  ctx.on("message", (session) => this.onMessage(session));
59
69
  this.flushInterval = setInterval(() => this.flushBuffers(), _Collector.FLUSH_INTERVAL);
60
70
  ctx.on("dispose", () => {
@@ -65,11 +75,9 @@ var Collector = class _Collector {
65
75
  static {
66
76
  __name(this, "Collector");
67
77
  }
68
- /** @property FLUSH_INTERVAL - 内存缓存区定时刷入数据库的间隔(毫秒)。 */
69
78
  static FLUSH_INTERVAL = import_koishi.Time.minute;
70
- /** @property BUFFER_THRESHOLD - 内存缓存区触发刷新的消息数量阈值。 */
71
79
  static BUFFER_THRESHOLD = 60;
72
- // 统一的数据缓冲区
80
+ // 数据缓冲区
73
81
  msgStatBuffer = /* @__PURE__ */ new Map();
74
82
  rankStatBuffer = /* @__PURE__ */ new Map();
75
83
  cmdStatBuffer = /* @__PURE__ */ new Map();
@@ -131,7 +139,7 @@ var Collector = class _Collector {
131
139
  if (!user) return;
132
140
  const { uid } = user;
133
141
  const messageTime = new Date(timestamp);
134
- if (argv?.command) {
142
+ if (this.config.enableCmdStat && argv?.command) {
135
143
  const key = `${uid}:${argv.command.name}`;
136
144
  const entry = this.cmdStatBuffer.get(key) ?? { uid, command: argv.command.name, count: 0, timestamp: messageTime };
137
145
  entry.count++;
@@ -140,15 +148,19 @@ var Collector = class _Collector {
140
148
  }
141
149
  const hourStart = new Date(messageTime.getFullYear(), messageTime.getMonth(), messageTime.getDate(), messageTime.getHours());
142
150
  for (const type of new Set(elements.map((e) => e.type))) {
143
- const msgKey = `${uid}:${type}`;
144
- const msgEntry = this.msgStatBuffer.get(msgKey) ?? { uid, type, count: 0, timestamp: messageTime };
145
- msgEntry.count++;
146
- msgEntry.timestamp = messageTime;
147
- this.msgStatBuffer.set(msgKey, msgEntry);
148
- const rankKey = `${uid}:${hourStart.toISOString()}:${type}`;
149
- const rankEntry = this.rankStatBuffer.get(rankKey) ?? { uid, timestamp: hourStart, type, count: 0 };
150
- rankEntry.count++;
151
- this.rankStatBuffer.set(rankKey, rankEntry);
151
+ if (this.config.enableMsgStat) {
152
+ const msgKey = `${uid}:${type}`;
153
+ const msgEntry = this.msgStatBuffer.get(msgKey) ?? { uid, type, count: 0, timestamp: messageTime };
154
+ msgEntry.count++;
155
+ msgEntry.timestamp = messageTime;
156
+ this.msgStatBuffer.set(msgKey, msgEntry);
157
+ }
158
+ if (this.config.enableRankStat || this.config.enableActivity) {
159
+ const rankKey = `${uid}:${hourStart.toISOString()}:${type}`;
160
+ const rankEntry = this.rankStatBuffer.get(rankKey) ?? { uid, timestamp: hourStart, type, count: 0 };
161
+ rankEntry.count++;
162
+ this.rankStatBuffer.set(rankKey, rankEntry);
163
+ }
152
164
  }
153
165
  if (this.config.enableWhoAt) {
154
166
  const sanitizedContent = this.sanitizeContent(elements.filter((e) => e.type !== "at"));
@@ -210,63 +222,126 @@ var import_koishi3 = require("koishi");
210
222
  // src/Renderer.ts
211
223
  var import_koishi2 = require("koishi");
212
224
  var Renderer = class {
225
+ /**
226
+ * @constructor
227
+ * @description Renderer 类的构造函数。
228
+ * @param {Context} ctx - Koishi 的插件上下文,用于访问 logger 和 puppeteer 服务。
229
+ */
213
230
  constructor(ctx) {
214
231
  this.ctx = ctx;
215
232
  }
216
233
  static {
217
234
  __name(this, "Renderer");
218
235
  }
219
- /**
220
- * @private @readonly
221
- * @property COMMON_STYLE - 存储所有卡片共享的基础 CSS 样式。
222
- */
223
- COMMON_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}`;
236
+ COMMON_STYLE = `
237
+ :root {
238
+ --card-bg: #fff; --text-color: #111827; --header-color: #111827;
239
+ --sub-text-color: #6b7280; --border-color: #e5e7eb; --accent-color: #4a6ee0;
240
+ --chip-bg: #f3f4f6; --stripe-bg: #f9fafb; --gold: #f59e0b;
241
+ --silver: #9ca3af; --bronze: #a16207;
242
+ }
243
+ body {
244
+ display: inline-block; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI',
245
+ Roboto, 'Helvetica Neue', Arial, sans-serif;
246
+ background: transparent; margin: 0; padding: 8px;
247
+ -webkit-font-smoothing: antialiased;
248
+ }
249
+ .container {
250
+ display: inline-block; background: var(--card-bg); border-radius: 12px;
251
+ padding: 0; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,.05);
252
+ }
253
+ .header {
254
+ padding: 12px 16px;
255
+ display: flex;
256
+ justify-content: space-between;
257
+ align-items: center;
258
+ border-bottom: 1px solid var(--border-color);
259
+ }
260
+ .title-text {
261
+ font-size: 18px; font-weight: 600; color: var(--header-color);
262
+ margin: 0; text-align: center;
263
+ }
264
+ .stat-chip, .time-label {
265
+ display: inline-flex; align-items: baseline; padding: 4px 8px;
266
+ border-radius: 8px; background: var(--chip-bg);
267
+ font-size: 13px; color: var(--sub-text-color);
268
+ white-space: nowrap;
269
+ }
270
+ .stat-chip span {
271
+ font-weight: 600; color: var(--text-color); margin-left: 4px;
272
+ }
273
+ `;
224
274
  /**
225
275
  * @private
226
276
  * @method generateFullHtml
227
- * @description 将卡片内容和特定样式组合成一个完整的 HTML 文档。
228
- * @param cardContent - 卡片部分的 HTML 字符串。
229
- * @param specificStyles - 该卡片类型独有的 CSS 样式字符串。
230
- * @returns 完整的 HTML 字符串。
277
+ * @description 将卡片内容和特定样式组合成一个完整的 HTML 文档,以便进行渲染。
278
+ * @param {string} cardContent - 卡片主体部分的 HTML 字符串。
279
+ * @param {string} specificStyles - 针对该卡片类型的特定 CSS 样式字符串。
280
+ * @returns {string} - 一个完整的、可被浏览器渲染的 HTML 字符串。
231
281
  */
232
282
  generateFullHtml(cardContent, specificStyles) {
233
283
  return `<!DOCTYPE html>
234
284
  <html>
235
- <head>
236
- <meta charset="UTF-8">
237
- <style>${this.COMMON_STYLE}${specificStyles}</style>
238
- </head>
239
- <body>
240
- ${cardContent}
241
- </body>
285
+ <head>
286
+ <meta charset="UTF-8">
287
+ <style>${this.COMMON_STYLE}${specificStyles}</style>
288
+ </head>
289
+ <body>
290
+ ${cardContent}
291
+ </body>
242
292
  </html>`;
243
293
  }
294
+ /**
295
+ * @private
296
+ * @method htmlToImage
297
+ * @description 使用 puppeteer 将给定的 HTML 字符串内容渲染成 PNG 图片的 Buffer。
298
+ * @param {string} fullHtmlContent - 完整的 HTML 内容字符串。
299
+ * @returns {Promise<Buffer | null>} - 成功时返回包含 PNG 图片数据的 Buffer,失败则返回 null。
300
+ */
244
301
  async htmlToImage(fullHtmlContent) {
245
302
  const page = await this.ctx.puppeteer.page();
246
303
  try {
247
- await page.setViewport({ width: 720, height: 1080, deviceScaleFactor: 2 });
304
+ await page.setViewport({ width: 720, height: 10, deviceScaleFactor: 2 });
248
305
  await page.setContent(fullHtmlContent, { waitUntil: "networkidle0" });
249
- const dimensions = await page.evaluate(() => ({ width: document.body.scrollWidth, height: document.body.scrollHeight }));
250
- await page.setViewport({ ...dimensions, deviceScaleFactor: 2 });
251
- return await page.screenshot({ type: "png", fullPage: true, omitBackground: true });
306
+ const { width, height } = await page.evaluate(() => ({
307
+ width: document.body.scrollWidth,
308
+ height: document.body.scrollHeight
309
+ }));
310
+ await page.setViewport({ width, height, deviceScaleFactor: 2 });
311
+ return await page.screenshot({ type: "png", omitBackground: true });
252
312
  } catch (error) {
253
313
  this.ctx.logger.error("图片渲染失败:", error);
254
314
  return null;
255
315
  } finally {
256
- if (page) await page.close().catch((e) => this.ctx.logger.error("关闭页面失败:", e));
316
+ await page.close().catch((e) => this.ctx.logger.error("关闭页面失败:", e));
257
317
  }
258
318
  }
319
+ /**
320
+ * @private
321
+ * @method formatDate
322
+ * @description 将 Date 对象格式化为易于理解的相对时间字符串(如“刚刚”,“5分钟前”)。
323
+ * @param {Date} date - 需要格式化的日期对象。
324
+ * @returns {string} - 格式化后的时间字符串。
325
+ */
259
326
  formatDate(date) {
260
327
  if (!date) return "未知";
261
328
  const diff = Date.now() - date.getTime();
262
329
  if (diff < import_koishi2.Time.minute) return "刚刚";
263
- if (diff > 365 * import_koishi2.Time.day) return date.toLocaleDateString("zh-CN").replace(/\//g, "-");
264
- const timeUnits = [["月", 30 * import_koishi2.Time.day], ["天", import_koishi2.Time.day], ["", import_koishi2.Time.hour], ["", import_koishi2.Time.minute]];
265
- for (const [unit, ms] of timeUnits) {
330
+ if (diff > 365 * import_koishi2.Time.day) return date.toLocaleDateString("zh-CN");
331
+ const units = [["月", 30 * import_koishi2.Time.day], ["天", import_koishi2.Time.day], ["小时", import_koishi2.Time.hour], ["分钟", import_koishi2.Time.minute]];
332
+ for (const [unit, ms] of units) {
266
333
  if (diff >= ms) return `${Math.floor(diff / ms)}${unit}前`;
267
334
  }
268
335
  return "刚刚";
269
336
  }
337
+ /**
338
+ * @public
339
+ * @method renderList
340
+ * @description 将表格型数据渲染成一个或多个列表形式的图片。如果数据过多,会自动进行分页渲染。
341
+ * @param {ListRenderData} data - 包含标题、时间、总计和列表数据的对象。
342
+ * @param {string[]} [headers] - (可选)列表的表头数组。
343
+ * @returns {Promise<string | Buffer[]>} - 成功时返回包含图片 Buffer 的数组,失败或无数据时返回提示字符串。
344
+ */
270
345
  async renderList(data, headers) {
271
346
  const { title, time, list } = data;
272
347
  if (!list?.length) return "暂无数据可供渲染";
@@ -278,43 +353,106 @@ var Renderer = class {
278
353
  const renderCell = /* @__PURE__ */ __name((cell, i) => {
279
354
  const headerText = headers?.[i] || "";
280
355
  if (headerText.includes("占比")) {
281
- const percentValue = parseFloat(String(cell).replace("%", ""));
282
- return `<td class="percent-cell"><div class="percent-bar" style="width: ${percentValue}%;"></div><span class="percent-text">${cell}</span></td>`;
356
+ return `<td class="percent-cell"><div class="percent-bar" style="width: ${String(cell)};"></div><span class="percent-text">${cell}</span></td>`;
283
357
  }
284
358
  if (cell instanceof Date) return `<td class="date-cell">${this.formatDate(cell)}</td>`;
285
359
  if (typeof cell === "number") return `<td class="count-cell">${cell.toLocaleString()}</td>`;
286
360
  return `<td class="name-cell">${String(cell)}</td>`;
287
361
  }, "renderCell");
288
- const totalPages = Math.ceil(totalItems / CHUNK_SIZE);
289
- const listStyles = `.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}`;
362
+ const listStyles = `
363
+ .table-container { padding: 0; }
364
+ .main-table { border-collapse: collapse; width: 100%; }
365
+ .main-table th, .main-table td { padding: 9px 16px; vertical-align: middle; text-align: left; }
366
+ .main-table thead { border-bottom: 1px solid var(--border-color); }
367
+ .main-table th { font-size: 12px; font-weight: 500; color: var(--sub-text-color); text-transform: uppercase; }
368
+ .main-table td { font-size: 14px; color: var(--text-color); }
369
+ .main-table tbody tr:nth-child(even) { background-color: var(--stripe-bg); }
370
+ .rank-cell, .count-cell, .date-cell, .percent-cell { text-align: right; white-space: nowrap; width: 1%; font-variant-numeric: tabular-nums; }
371
+ .name-cell { font-weight: 500; }
372
+ .rank-cell { font-weight: 600; color: var(--sub-text-color); }
373
+ .count-cell { font-weight: 600; color: var(--accent-color); }
374
+ .rank-gold, .rank-silver, .rank-bronze { font-weight: 700; }
375
+ .rank-gold { color: var(--gold) !important; }
376
+ .rank-silver { color: var(--silver) !important; }
377
+ .rank-bronze { color: var(--bronze) !important; }
378
+ .percent-cell { position: relative; padding-right: 20px; }
379
+ .percent-bar { position: absolute; top: 50%; right: 0; transform: translateY(-50%); height: 6px; background-color: var(--accent-color); opacity: .2; border-radius: 3px; }
380
+ .percent-text { position: relative; z-index: 1; }
381
+ `;
290
382
  for (let i = 0; i < totalItems; i += CHUNK_SIZE) {
291
383
  const chunk = list.slice(i, i + CHUNK_SIZE);
292
384
  const pageNum = Math.floor(i / CHUNK_SIZE) + 1;
293
- const pageTitle = totalPages > 1 ? `${title} (第 ${pageNum}/${totalPages} 页)` : title;
294
- const tableRowsHtml = chunk.map((row, index) => {
385
+ const pageTitle = totalItems > CHUNK_SIZE ? `${title} (第 ${pageNum}/${Math.ceil(totalItems / CHUNK_SIZE)} 页)` : title;
386
+ const cardHtml = `
387
+ <div class="container">
388
+ <div class="header">
389
+ <div class="stat-chip">总计: <span>${typeof totalCount === "number" ? totalCount.toLocaleString() : totalCount}</span></div>
390
+ <h1 class="title-text">${pageTitle}</h1>
391
+ <div class="time-label">${time.toLocaleString("zh-CN", { hour12: false })}</div>
392
+ </div>
393
+ <div class="table-container">
394
+ <table class="main-table">
395
+ ${headers?.length ? `
396
+ <thead>
397
+ <tr>
398
+ <th class="rank-cell">#</th>
399
+ ${headers.map((h4) => `<th>${h4}</th>`).join("")}
400
+ </tr>
401
+ </thead>` : ""}
402
+ <tbody>
403
+ ${chunk.map((row, index) => {
295
404
  const rank = i + index + 1;
296
405
  const rankClass = rank === 1 ? "rank-gold" : rank === 2 ? "rank-silver" : rank === 3 ? "rank-bronze" : "";
297
406
  return `<tr><td class="rank-cell ${rankClass}">${rank}</td>${row.map(renderCell).join("")}</tr>`;
298
- }).join("");
299
- 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>` : "";
300
- 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>`;
407
+ }).join("")}
408
+ </tbody>
409
+ </table>
410
+ </div>
411
+ </div>`;
301
412
  const fullHtml = this.generateFullHtml(cardHtml, listStyles);
302
413
  const imageBuffer = await this.htmlToImage(fullHtml);
303
414
  if (imageBuffer) imageBuffers.push(imageBuffer);
304
415
  }
305
416
  return imageBuffers.length > 0 ? imageBuffers : "图片渲染失败";
306
417
  }
418
+ /**
419
+ * @public
420
+ * @method renderCircadianChart
421
+ * @description 将 24 小时制的活跃度数据渲染成一张柱状图图片。
422
+ * @param {CircadianChartData} data - 包含标题、时间、总计和 24 小时数据数组的对象。
423
+ * @returns {Promise<string | Buffer[]>} - 成功时返回包含图片 Buffer 的数组,失败或无数据时返回提示字符串。
424
+ */
307
425
  async renderCircadianChart(data) {
308
426
  const { title, time, total, data: hourlyCounts } = data;
309
427
  if (!hourlyCounts || hourlyCounts.every((c) => c === 0)) return "暂无数据可供渲染";
310
428
  const maxCount = Math.max(...hourlyCounts, 1);
311
- const barsHtml = hourlyCounts.map((count, hour) => {
312
- const barHeight = count / maxCount * 100;
313
- const isPeak = count > 0 && count === maxCount;
314
- return `<div class="bar-wrapper"><div class="bar-value ${count === 0 ? "hidden" : ""}">${count}</div><div class="bar-container"><div class="bar ${isPeak ? "peak" : ""}" style="height: ${barHeight}%;"></div></div><div class="bar-label">${hour}</div></div>`;
315
- }).join("");
316
- const cardHtml = `<div class="container"><div class="header"><table class="header-table"><tr><td class="header-table-left"><div class="stat-chip">总计: <span>${typeof total === "number" ? total.toLocaleString() : total}</span></div></td><td class="header-table-center"><h1 class="title-text">${title}</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="chart-container">${barsHtml}</div></div>`;
317
- const chartStyles = `.chart-container{display:flex;align-items:flex-end;gap:4px;height:180px;padding:30px 15px 10px;border-top:1px solid var(--border-color)}.bar-wrapper{flex:1;text-align:center;display:flex;flex-direction:column;height:100%;justify-content:flex-end}.bar-value{font-size:11px;color:var(--sub-text-color);height:16px;line-height:16px;font-weight:500}.bar-value.hidden{visibility:hidden}.bar-container{flex-grow:1;display:flex;align-items:flex-end;width:100%}.bar{width:100%;background-color:var(--accent-color);opacity:.7;border-radius:3px 3px 0 0;transition:height .3s ease-out}.bar.peak{opacity:1;background-color:var(--gold)!important}.bar-label{font-size:10px;color:var(--sub-text-color);margin-top:4px;height:12px}`;
429
+ const chartStyles = `
430
+ .chart-container { display: flex; align-items: flex-end; gap: 4px; height: 180px; padding: 30px 15px 10px; }
431
+ .bar-wrapper { flex: 1; text-align: center; display: flex; flex-direction: column; justify-content: flex-end; height: 100%; }
432
+ .bar-value { font-size: 11px; color: var(--sub-text-color); height: 16px; line-height: 16px; font-weight: 500; visibility: ${maxCount > 50 ? "hidden" : "visible"}; }
433
+ .bar-container { flex-grow: 1; display: flex; align-items: flex-end; width: 100%; }
434
+ .bar { width: 100%; background-color: var(--accent-color); opacity: .7; border-radius: 3px 3px 0 0; transition: height .3s ease-out; }
435
+ .bar.peak { opacity: 1; background-color: var(--gold); }
436
+ .bar-label { font-size: 10px; color: var(--sub-text-color); margin-top: 4px; height: 12px; }
437
+ `;
438
+ const cardHtml = `
439
+ <div class="container">
440
+ <div class="header">
441
+ <div class="stat-chip">总计: <span>${typeof total === "number" ? total.toLocaleString() : total}</span></div>
442
+ <h1 class="title-text">${title}</h1>
443
+ <div class="time-label">${time.toLocaleString("zh-CN", { hour12: false })}</div>
444
+ </div>
445
+ <div class="chart-container">
446
+ ${hourlyCounts.map((count, hour) => `
447
+ <div class="bar-wrapper">
448
+ <div class="bar-value">${count > 0 ? count : ""}</div>
449
+ <div class="bar-container">
450
+ <div class="bar ${count === maxCount ? "peak" : ""}" style="height: ${count / maxCount * 100}%;"></div>
451
+ </div>
452
+ <div class="bar-label">${hour}</div>
453
+ </div>`).join("")}
454
+ </div>
455
+ </div>`;
318
456
  const fullHtml = this.generateFullHtml(cardHtml, chartStyles);
319
457
  const imageBuffer = await this.htmlToImage(fullHtml);
320
458
  return imageBuffer ? [imageBuffer] : "图片渲染失败";
@@ -331,10 +469,10 @@ var Stat = class {
331
469
  this.ctx = ctx;
332
470
  this.config = config;
333
471
  this.renderer = new Renderer(ctx);
334
- if (this.config.rankRetentionDays > 0) {
472
+ if (this.config.enableRankStat && this.config.rankRetentionDays > 0) {
335
473
  this.ctx.cron("0 0 * * *", async () => {
336
474
  const cutoffDate = new Date(Date.now() - this.config.rankRetentionDays * import_koishi3.Time.day);
337
- await this.ctx.database.remove("analyse_rank", { timestamp: { $lt: cutoffDate } }).catch((e) => this.ctx.logger.error("清理发言排行历史记录失败:", e));
475
+ await this.ctx.database.remove("analyse_rank", { timestamp: { $lt: cutoffDate } }).catch((e) => this.ctx.logger.error("清理发言排行记录失败:", e));
338
476
  });
339
477
  }
340
478
  }
@@ -344,10 +482,10 @@ var Stat = class {
344
482
  renderer;
345
483
  /**
346
484
  * @public @method registerCommands
347
- * @description 根据配置,动态地将子命令注册到主 `analyse` 命令下。
348
- * @param analyse - 主 `analyse` 命令实例。
485
+ * @description 根据配置,动态地将子命令注册到主命令下。
486
+ * @param cmd - 主命令实例。
349
487
  */
350
- registerCommands(analyse) {
488
+ registerCommands(cmd) {
351
489
  const createHandler = /* @__PURE__ */ __name((handler) => {
352
490
  return async ({ session, options }) => {
353
491
  const scope = await this.parseQueryScope(session, options);
@@ -355,102 +493,73 @@ var Stat = class {
355
493
  try {
356
494
  const result = await handler(scope, options);
357
495
  if (typeof result === "string") return result;
358
- if (Array.isArray(result)) {
359
- if (result.length === 0) return "图片渲染失败";
496
+ if (Array.isArray(result) && result.length > 0) {
360
497
  for (const buffer of result) await session.sendQueued(import_koishi3.h.image(buffer, "image/png"));
361
498
  return;
362
499
  }
363
- if (Buffer.isBuffer(result)) return import_koishi3.h.image(result, "image/png");
364
500
  } catch (error) {
365
501
  this.ctx.logger.error("渲染统计图片失败:", error);
366
- return "渲染统计图片失败";
502
+ return "图片渲染失败";
367
503
  }
368
504
  };
369
505
  }, "createHandler");
370
- if (this.config.enableCmdStat) analyse.subcommand(".cmd", "命令使用统计").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("all", "-a 全局").action(createHandler(async (scope) => {
371
- const stats = await this.ctx.database.select("analyse_cmd").where({ uid: { $in: scope.uids } }).groupBy("command", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
372
- if (stats.length === 0) return "暂无匹配指令统计数据";
373
- const total = stats.reduce((sum, record) => sum + record.count, 0);
374
- const list = stats.map((item) => [item.command, item.count, item.lastUsed]);
375
- const title = await this.generateTitle(scope.scopeDesc, { main: "命令使用" });
376
- return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, ["命令", "次数", "最后使用"]);
377
- }));
378
- if (this.config.enableMsgStat) analyse.subcommand(".msg", "消息发送统计").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("all", "-a 全局").action(createHandler(async (scope, options) => {
379
- const type = options.type;
380
- if (type) {
381
- const users = await this.ctx.database.get("analyse_user", { uid: { $in: scope.uids } }, ["uid", "userName"]);
382
- const userNameMap = new Map(users.map((u) => [u.uid, u.userName]));
383
- const stats = await this.ctx.database.select("analyse_msg").where({ uid: { $in: scope.uids }, type }).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
384
- if (stats.length === 0) return `暂无“${type}”类型消息数据`;
385
- const total = stats.reduce((sum, r) => sum + r.count, 0);
386
- const list = stats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count, item.lastUsed]);
387
- const title = await this.generateTitle(scope.scopeDesc, { main: "消息", subtype: type });
388
- return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, ["用户", "条数", "最后发言"]);
389
- } else {
506
+ if (this.config.enableCmdStat) {
507
+ cmd.subcommand("cmdstat", "命令统计").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("all", "-a 全局").action(createHandler(async (scope) => {
508
+ const stats = await this.ctx.database.select("analyse_cmd").where({ uid: { $in: scope.uids } }).groupBy("command", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
509
+ if (stats.length === 0) return "暂无统计数据";
510
+ const total = stats.reduce((sum, record) => sum + record.count, 0);
511
+ const list = stats.map((item) => [item.command, item.count, item.lastUsed]);
512
+ const title = await this.generateTitle(scope.scopeDesc, { main: "命令" });
513
+ return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, ["命令", "次数", "最后使用"]);
514
+ }));
515
+ }
516
+ if (this.config.enableMsgStat) {
517
+ cmd.subcommand("msgstat", "发言统计").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("all", "-a 全局").action(createHandler(async (scope, options) => {
518
+ const { type } = options;
519
+ const query = { uid: { $in: scope.uids } };
520
+ if (type) query.type = type;
390
521
  const users = await this.ctx.database.get("analyse_user", { uid: { $in: scope.uids } }, ["uid", "userName"]);
391
522
  const userNameMap = new Map(users.map((u) => [u.uid, u.userName]));
392
- const stats = await this.ctx.database.select("analyse_msg").where({ uid: { $in: scope.uids } }).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
393
- if (stats.length === 0) return "暂无消息数据";
523
+ const stats = await this.ctx.database.select("analyse_msg").where(query).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
524
+ if (stats.length === 0) return "暂无统计数据";
394
525
  const total = stats.reduce((sum, r) => sum + r.count, 0);
395
526
  const list = stats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count, item.lastUsed]);
396
- const title = await this.generateTitle(scope.scopeDesc, { main: "消息发送" });
397
- return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, ["用户", "总计发言", "最后发言"]);
398
- }
399
- }));
400
- if (this.config.enableRankStat) analyse.subcommand(".rank", "用户发言排行").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("hours", "-h <hours:number> 指定时长", { fallback: 24 }).option("all", "-a 全局").action(async ({ session, options }) => {
401
- const guildId = options.all ? void 0 : options.guild || session.guildId;
402
- if (!guildId && !options.all) return "请指定群组或查询全局";
403
- try {
527
+ const title = await this.generateTitle(scope.scopeDesc, { main: "发言", subtype: type });
528
+ const headers = type ? ["用户", "条数", "最后发言"] : ["用户", "总计发言", "最后发言"];
529
+ return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, headers);
530
+ }));
531
+ }
532
+ if (this.config.enableRankStat) {
533
+ cmd.subcommand("rankstat", "发言排行").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("hours", "-h <hours:number> 指定时长", { fallback: 24 }).option("all", "-a 全局").action(createHandler(async (scope, options) => {
404
534
  const { hours, type } = options;
405
535
  const since = new Date(Date.now() - hours * import_koishi3.Time.hour);
406
- const baseQuery = { timestamp: { $gte: since } };
407
- if (type) baseQuery.type = type;
408
- const uidsInScope = guildId ? (await this.ctx.database.get("analyse_user", { channelId: guildId }, ["uid"])).map((u) => u.uid) : void 0;
409
- if (guildId && uidsInScope.length === 0) return "暂无指定时段内发言记录";
410
- if (uidsInScope) baseQuery.uid = { $in: uidsInScope };
411
- 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();
412
- if (rankStats.length === 0) return "暂无指定时段内发言记录";
413
- const uids = rankStats.map((s) => s.uid);
414
- const users = await this.ctx.database.get("analyse_user", { uid: { $in: uids } }, ["uid", "userName"]);
536
+ const query = { uid: { $in: scope.uids }, timestamp: { $gte: since } };
537
+ if (type) query.type = type;
538
+ const rankStats = await this.ctx.database.select("analyse_rank").where(query).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).orderBy("count", "desc").execute();
539
+ if (rankStats.length === 0) return "暂无统计数据";
540
+ const users = await this.ctx.database.get("analyse_user", { uid: { $in: scope.uids } }, ["uid", "userName"]);
415
541
  const userNameMap = new Map(users.map((u) => [u.uid, u.userName]));
416
542
  const total = rankStats.reduce((sum, record) => sum + record.count, 0);
417
543
  const list = rankStats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count]);
418
544
  const listWithPercentage = list.map((row) => [...row, total > 0 ? `${(row[1] / total * 100).toFixed(2)}%` : "0.00%"]);
419
- const title = await this.generateTitle({ guildId }, { main: "发言排行", timeRange: hours, subtype: type });
420
- const result = await this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list: listWithPercentage }, ["用户", "总计发言", "占比"]);
421
- if (typeof result === "string") return result;
422
- if (Array.isArray(result)) {
423
- if (result.length === 0) return "图片渲染失败";
424
- for (const buffer of result) await session.sendQueued(import_koishi3.h.image(buffer, "image/png"));
425
- return;
426
- }
427
- } catch (error) {
428
- this.ctx.logger.error("渲染发言排行图片失败:", error);
429
- return "渲染发言排行图片失败";
430
- }
431
- });
432
- if (this.config.enableActivityStat) analyse.subcommand(".activity", "用户活跃分析").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("all", "-a 全局").action(createHandler(async (scope) => {
433
- const hourlyStats = await this.ctx.database.select("analyse_rank").where({ uid: { $in: scope.uids } }).groupBy(
434
- ["timestamp"],
435
- { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }
436
- ).execute();
437
- if (hourlyStats.length === 0) return "暂无消息数据";
438
- const hourlyCounts = Array(24).fill(0);
439
- let totalMessages = 0;
440
- hourlyStats.forEach((stat) => {
441
- const hour = stat.timestamp.getHours();
442
- hourlyCounts[hour] = stat.count;
443
- totalMessages += stat.count;
444
- });
445
- const title = await this.generateTitle(scope.scopeDesc, { main: "活跃分析" });
446
- const result = await this.renderer.renderCircadianChart({
447
- title,
448
- time: /* @__PURE__ */ new Date(),
449
- total: totalMessages,
450
- data: hourlyCounts
451
- });
452
- return result;
453
- }));
545
+ const title = await this.generateTitle(scope.scopeDesc, { main: "发言排行", timeRange: hours, subtype: type });
546
+ return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list: listWithPercentage }, ["用户", "总计发言", "占比"]);
547
+ }));
548
+ }
549
+ if (this.config.enableActivity) {
550
+ cmd.subcommand("activity", "活跃统计").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("all", "-a 全局").action(createHandler(async (scope) => {
551
+ const hourlyStats = await this.ctx.database.select("analyse_rank").where({ uid: { $in: scope.uids } }).groupBy(["timestamp"], { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).execute();
552
+ if (hourlyStats.length === 0) return "暂无统计数据";
553
+ const hourlyCounts = Array(24).fill(0);
554
+ let totalMessages = 0;
555
+ hourlyStats.forEach((stat) => {
556
+ hourlyCounts[stat.timestamp.getHours()] += stat.count;
557
+ totalMessages += stat.count;
558
+ });
559
+ const title = await this.generateTitle(scope.scopeDesc, { main: "活跃" });
560
+ return this.renderer.renderCircadianChart({ title, time: /* @__PURE__ */ new Date(), total: totalMessages, data: hourlyCounts });
561
+ }));
562
+ }
454
563
  }
455
564
  /**
456
565
  * @private @method parseQueryScope
@@ -463,12 +572,13 @@ var Stat = class {
463
572
  const scopeDesc = { guildId: options.guild, userId: void 0 };
464
573
  if (options.user) scopeDesc.userId = import_koishi3.h.select(options.user, "at")[0]?.attrs.id ?? options.user.trim();
465
574
  if (!options.all && !scopeDesc.guildId && !scopeDesc.userId) scopeDesc.guildId = session.guildId;
466
- if (!options.all && !scopeDesc.guildId) return { error: "请指定群组或查询全局", scopeDesc };
575
+ if (!options.all && !scopeDesc.guildId && !scopeDesc.userId) return { error: "请指定查询范围", scopeDesc };
467
576
  const query = {};
468
577
  if (scopeDesc.guildId) query.channelId = scopeDesc.guildId;
469
578
  if (scopeDesc.userId) query.userId = scopeDesc.userId;
579
+ if (Object.keys(query).length === 0) return { uids: void 0, scopeDesc };
470
580
  const users = await this.ctx.database.get("analyse_user", query, ["uid"]);
471
- if (users.length === 0) return { error: "在指定范围内未找到任何记录", scopeDesc };
581
+ if (users.length === 0) return { error: "暂无统计数据", scopeDesc };
472
582
  return { uids: users.map((u) => u.uid), scopeDesc };
473
583
  }
474
584
  /**
@@ -477,19 +587,25 @@ var Stat = class {
477
587
  * @returns 生成的标题字符串。
478
588
  */
479
589
  async generateTitle(scopeDesc, options) {
480
- let scopeText = "全局";
590
+ let guildName = "", userName = "", scopeText = "全局";
481
591
  if (scopeDesc.guildId) {
482
592
  const [guild] = await this.ctx.database.get("analyse_user", { channelId: scopeDesc.guildId }, ["channelName"]);
483
- scopeText = guild?.channelName || scopeDesc.guildId;
593
+ guildName = guild?.channelName || scopeDesc.guildId;
484
594
  }
485
595
  if (scopeDesc.userId) {
486
596
  const [user] = await this.ctx.database.get("analyse_user", { userId: scopeDesc.userId }, ["userName"]);
487
- const userName = user?.userName || scopeDesc.userId;
488
- scopeText = scopeDesc.guildId ? `${userName} 在 ${scopeText}` : `${userName} 的全局`;
597
+ userName = user?.userName || scopeDesc.userId;
489
598
  }
490
599
  const typeText = options.subtype ? `“${options.subtype}”` : "";
491
- if (options.main.includes("排行")) return `${scopeText}${options.timeRange}小时${typeText}消息排行`;
492
- return `${scopeText}${typeText}${options.main}统计`;
600
+ const mainText = options.main;
601
+ if (mainText.includes("排行")) {
602
+ scopeText = guildName || "全局";
603
+ return `${options.timeRange}小时${scopeText}${typeText}${mainText}`;
604
+ }
605
+ if (userName && guildName) scopeText = `${guildName} ${userName}`;
606
+ else if (userName) scopeText = userName;
607
+ else if (guildName) scopeText = guildName;
608
+ return `${scopeText}${typeText}${mainText}统计`;
493
609
  }
494
610
  };
495
611
 
@@ -503,10 +619,10 @@ var WhoAt = class {
503
619
  constructor(ctx, config) {
504
620
  this.ctx = ctx;
505
621
  this.config = config;
506
- if (this.config.atRetentionDays > 0) {
622
+ if (this.config.enableWhoAt && this.config.atRetentionDays > 0) {
507
623
  this.ctx.cron("0 0 * * *", async () => {
508
624
  const cutoffDate = new Date(Date.now() - this.config.atRetentionDays * import_koishi4.Time.day);
509
- await this.ctx.database.remove("analyse_at", { timestamp: { $lt: cutoffDate } }).catch((e) => this.ctx.logger.error("清理 @ 历史记录失败:", e));
625
+ await this.ctx.database.remove("analyse_at", { timestamp: { $lt: cutoffDate } }).catch((e) => this.ctx.logger.error("清理提及记录失败:", e));
510
626
  });
511
627
  }
512
628
  }
@@ -515,11 +631,11 @@ var WhoAt = class {
515
631
  }
516
632
  /**
517
633
  * @public @method registerCommand
518
- * @description 在主 `analyse` 命令下注册 `whoatme` 子命令。
519
- * @param analyse - 主 `analyse` 命令实例。
634
+ * @description 在主命令下注册子命令。
635
+ * @param cmd - 主命令实例。
520
636
  */
521
- registerCommand(analyse) {
522
- analyse.subcommand(".whoatme", "谁@我").action(async ({ session }) => {
637
+ registerCommand(cmd) {
638
+ cmd.subcommand("whoatme", "谁提及我").usage("查看最近提及我的消息,不分群组。").action(async ({ session }) => {
523
639
  if (!session.userId) return "无法获取用户信息";
524
640
  try {
525
641
  const records = await this.ctx.database.get("analyse_at", { target: session.userId }, {
@@ -531,7 +647,7 @@ var WhoAt = class {
531
647
  const users = await this.ctx.database.get("analyse_user", { uid: { $in: uids } }, ["uid", "userName", "userId"]);
532
648
  const userInfoMap = new Map(users.map((u) => [u.uid, { name: u.userName, id: u.userId }]));
533
649
  const messageElements = records.map((record) => {
534
- const senderInfo = userInfoMap.get(record.uid) ?? { name: "未知用户", id: "0" };
650
+ const senderInfo = userInfoMap.get(record.uid);
535
651
  return (0, import_koishi4.h)("message", {}, [
536
652
  (0, import_koishi4.h)("author", { userId: senderInfo.id, nickname: senderInfo.name }),
537
653
  import_koishi4.h.text(record.content)
@@ -539,7 +655,7 @@ var WhoAt = class {
539
655
  });
540
656
  return (0, import_koishi4.h)("message", { forward: true }, messageElements);
541
657
  } catch (error) {
542
- this.ctx.logger.error("查询 @ 记录失败:", error);
658
+ this.ctx.logger.error("查询提及记录失败:", error);
543
659
  return "查询失败,请稍后重试";
544
660
  }
545
661
  });
@@ -564,11 +680,11 @@ var Data = class {
564
680
  /**
565
681
  * @public
566
682
  * @method registerCommands
567
- * @description 在 `analyse` 命令下注册所有数据管理相关的子命令 (`.backup`, `.restore`, `.clear`, `.list`)。
568
- * @param analyse - 主 `analyse` 命令实例。
683
+ * @description 在主命令下注册所有数据管理相关的子命令。
684
+ * @param cmd - 主命令实例。
569
685
  */
570
- registerCommands(analyse) {
571
- analyse.subcommand(".backup", "备份统计数据", { authority: 4 }).action(async () => {
686
+ registerCommands(cmd) {
687
+ cmd.subcommand(".backup", "备份数据", { authority: 4 }).usage("将所有统计数据导出为 JSON 文件并保存到本地。").action(async () => {
572
688
  try {
573
689
  await fs.mkdir(this.dataDir, { recursive: true });
574
690
  const allUsers = await this.ctx.database.get("analyse_user", {});
@@ -595,7 +711,7 @@ var Data = class {
595
711
  return "数据备份失败";
596
712
  }
597
713
  });
598
- analyse.subcommand(".restore", "恢复统计数据", { authority: 4 }).action(async () => {
714
+ cmd.subcommand(".restore", "恢复数据", { authority: 4 }).usage(`从本地的 JSON 文件中恢复统计数据。`).action(async () => {
599
715
  try {
600
716
  const userTablePath = path.join(this.dataDir, "analyse_user.json");
601
717
  const usersToImport = JSON.parse(await fs.readFile(userTablePath, "utf-8").catch(() => "[]"));
@@ -620,15 +736,14 @@ var Data = class {
620
736
  return "数据恢复失败";
621
737
  }
622
738
  });
623
- analyse.subcommand(".clear", "清理统计数据", { authority: 4 }).option("table", "-t <table:string> 指定表名").option("guild", "-g <guildId:string> 指定群组").option("user", "-u <user:string> 指定用户").option("days", "-d <days:number> 指定天数").option("all", "-a 清理全部数据").action(async ({ options }) => {
624
- if (Object.keys(options).length === 0) return "请提供清理条件";
739
+ cmd.subcommand(".clear", "清除数据", { authority: 4 }).option("table", "-t <table:string> 指定表名").option("guild", "-g <guildId:string> 指定群组").option("user", "-u <user:string> 指定用户").option("days", "-d <days:number> 指定天数").option("command", "-c <command:string> 指定命令").option("all", "-a 清除全部").usage(`根据指定条件清理统计数据,可以组合多个选项以精确控制清除范围。`).action(async ({ options }) => {
740
+ if (Object.keys(options).length === 0) return "请指定清除条件";
741
+ if (options.table && !ALL_TABLES.includes(options.table)) return `表名 ${options.table} 无效`;
625
742
  try {
626
743
  if (options.all) {
627
- await Promise.all(ALL_TABLES.map((tableName) => this.ctx.database.remove(tableName, {})));
628
- return "已清除所有聊天分析数据";
744
+ await Promise.all(ALL_TABLES.map((tableName) => this.ctx.database.drop(tableName)));
745
+ return "已清除所有数据,请重新初始化插件";
629
746
  }
630
- const tablesToClear = options.table ? [options.table] : ALL_TABLES.filter((t) => t !== "analyse_user");
631
- if (options.table && !ALL_TABLES.includes(options.table)) return `无效表名: ${options.table}。`;
632
747
  const query = {};
633
748
  const descParts = [];
634
749
  if (options.guild || options.user) {
@@ -638,35 +753,54 @@ var Data = class {
638
753
  descParts.push(`群组 ${options.guild}`);
639
754
  }
640
755
  if (options.user) {
641
- userQuery.userId = import_koishi5.Element.select(options.user, "at")[0]?.attrs.id ?? options.user;
642
- descParts.push(`用户 ${userQuery.userId}`);
756
+ const userId = import_koishi5.Element.select(options.user, "at")[0]?.attrs.id ?? options.user;
757
+ userQuery.userId = userId;
758
+ descParts.push(`用户 ${userId}`);
643
759
  }
644
760
  const uidsToClear = (await this.ctx.database.get("analyse_user", userQuery)).map((u) => u.uid);
645
- if (uidsToClear.length === 0) return "未找到匹配记录";
761
+ if (uidsToClear.length === 0) return "未找到相关数据";
646
762
  query.uid = { $in: [...new Set(uidsToClear)] };
647
763
  }
648
764
  if (options.days > 0) {
649
765
  query.timestamp = { $lt: new Date(Date.now() - options.days * import_koishi5.Time.day) };
650
- descParts.push(`超过 ${options.days} 天`);
766
+ descParts.push(`${options.days} 天前`);
767
+ }
768
+ if (options.command) {
769
+ query.command = options.command;
770
+ descParts.push(`命令 ${options.command}`);
651
771
  }
772
+ const tablesToClear = options.command ? ["analyse_cmd"] : options.table ? [options.table] : ALL_TABLES.filter((t) => t !== "analyse_user");
773
+ let foundData = false;
774
+ for (const tableName of tablesToClear) {
775
+ const records = await this.ctx.database.get(tableName, query, ["uid"]);
776
+ if (records.length > 0) {
777
+ foundData = true;
778
+ break;
779
+ }
780
+ }
781
+ if (!foundData) return "未找到相关数据";
652
782
  for (const tableName of tablesToClear) await this.ctx.database.remove(tableName, query);
653
- const targetStr = options.table ? `表 ${options.table}` : "所有相关表";
654
- return `已成功清理${targetStr}中${descParts.join("")}的数据`;
783
+ const tableString = options.table ? `表 ${options.table}` : "所有表";
784
+ const descString = descParts.join("");
785
+ if (descString) {
786
+ return `已清除${tableString}中 ${descString} 的数据`;
787
+ } else {
788
+ return `已清除${tableString}中的所有数据`;
789
+ }
655
790
  } catch (error) {
656
791
  this.ctx.logger.error("数据清理失败:", error);
657
792
  return "数据清理失败";
658
793
  }
659
794
  });
660
- analyse.subcommand(".list", "列出频道和命令", { authority: 4 }).action(async () => {
795
+ cmd.subcommand(".list", "列出数据", { authority: 4 }).usage("列出数据库中的频道和命令列表。").action(async () => {
661
796
  const [allChannelInfo, commands] = await Promise.all([
662
797
  this.ctx.database.get("analyse_user", {}, ["channelId", "channelName"]),
663
- this.ctx.database.select("analyse_cmd").distinct("command").execute()
798
+ this.ctx.database.select("analyse_cmd").groupBy("command").execute()
664
799
  ]);
665
800
  const uniqueChannels = [...new Map(allChannelInfo.map((item) => [item.channelId, item])).values()];
666
- const channelOutput = uniqueChannels.length ? "已记录频道列表:\n" + uniqueChannels.map((c) => `[${c.channelId}] ${c.channelName}`).join("\n") : "暂无频道记录";
667
- const commandOutput = commands.length ? "已记录命令列表:\n" + commands.join(", ") : "暂无命令记录";
801
+ const channelOutput = uniqueChannels.length ? "频道列表:\n" + uniqueChannels.map((c) => `[${c.channelId}] ${c.channelName}`).join("\n") : "暂无频道记录";
802
+ const commandOutput = commands.length ? "命令列表:\n" + commands.join(", ") : "暂无命令记录";
668
803
  return `${channelOutput}
669
-
670
804
  ${commandOutput}`;
671
805
  });
672
806
  }
@@ -690,29 +824,27 @@ var using = ["database", "puppeteer", "cron"];
690
824
  var Config = import_koishi6.Schema.intersect([
691
825
  import_koishi6.Schema.object({
692
826
  enableListener: import_koishi6.Schema.boolean().default(true).description("启用消息监听"),
693
- enableData: import_koishi6.Schema.boolean().default(false).description("启用数据管理")
694
- }).description("基础配置"),
827
+ enableDataIO: import_koishi6.Schema.boolean().default(true).description("启用数据管理")
828
+ }).description("杂项配置"),
695
829
  import_koishi6.Schema.object({
696
830
  enableCmdStat: import_koishi6.Schema.boolean().default(true).description("启用命令统计"),
697
831
  enableMsgStat: import_koishi6.Schema.boolean().default(true).description("启用消息统计"),
698
- enableActivityStat: import_koishi6.Schema.boolean().default(true).description("启用活跃分析"),
699
- enableOriRecord: import_koishi6.Schema.boolean().default(true).description("启用原始记录")
700
- }).description("功能配置"),
701
- import_koishi6.Schema.object({
832
+ enableActivity: import_koishi6.Schema.boolean().default(true).description("启用活跃统计"),
702
833
  enableRankStat: import_koishi6.Schema.boolean().default(true).description("启用发言排行"),
703
- rankRetentionDays: import_koishi6.Schema.number().min(0).default(31).description("记录保留天数")
704
- }).description("发言排行配置"),
834
+ rankRetentionDays: import_koishi6.Schema.number().min(0).default(31).description("排行保留天数"),
835
+ enableWhoAt: import_koishi6.Schema.boolean().default(true).description("启用提及记录"),
836
+ atRetentionDays: import_koishi6.Schema.number().min(0).default(7).description("提及保留天数")
837
+ }).description("基础分析配置"),
705
838
  import_koishi6.Schema.object({
706
- enableWhoAt: import_koishi6.Schema.boolean().default(true).description("启用@记录"),
707
- atRetentionDays: import_koishi6.Schema.number().min(0).default(7).description("记录保留天数")
708
- }).description("@记录配置")
839
+ enableOriRecord: import_koishi6.Schema.boolean().default(true).description("启用原始记录")
840
+ }).description("高级分析配置")
709
841
  ]);
710
842
  function apply(ctx, config) {
711
843
  if (config.enableListener) new Collector(ctx, config);
712
- const analyse = ctx.command("analyse", "聊天记录分析");
844
+ const analyse = ctx.command("analyse", "数据分析");
713
845
  new Stat(ctx, config).registerCommands(analyse);
714
846
  if (config.enableWhoAt) new WhoAt(ctx, config).registerCommand(analyse);
715
- if (config.enableData) new Data(ctx).registerCommands(analyse);
847
+ if (config.enableDataIO) new Data(ctx).registerCommands(analyse);
716
848
  }
717
849
  __name(apply, "apply");
718
850
  // Annotate the CommonJS export names for ESM import in node:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-chat-analyse",
3
3
  "description": "聊天记录分析",
4
- "version": "0.5.3",
4
+ "version": "0.5.4",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],