ccus-cli 0.2.24 → 0.2.26

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/README.md CHANGED
@@ -22,9 +22,11 @@
22
22
 
23
23
  个人和聚合面板均提供「周评分」:默认 `50 × √(周消息数 / 200) + 50 × √(周累计额度百分点 / 70)`,消息和额度均合计 Claude + Codex。两项达到基准得 100 分,分数不封顶。
24
24
 
25
- 面板只显示评分结果。权重和基准集中在 `src/lib/weekly-score.ts` 的 `DEFAULT_SCORE_SETTINGS` 修改:`messageWeight` 为消息占比(额度占比为 100 减去消息占比),`messageBaseline` 为周消息基准,`quotaBaseline` 为周额度基准。修改后重新构建并重启面板服务。
25
+ 个人和团队面板提供「出勤天数」选择,默认 5 天,可选 1~7 天,切换后立即更新评分。消息和额度达标线按 `出勤天数 / 5` 调整,原始统计值不变;例如 3 天对应 120 条消息、42 个额度百分点。团队顶部选择批量设置所有成员,每行可单独调整,该成员所展示的各周共用所选天数;刷新页面恢复 5 天。点击「导出周统计 CSV」下载按当前各成员出勤天数评分的 `weekly.csv`,保留现有列,多周逐周导出。命令行导出仍按默认 5 天评分。
26
26
 
27
- 评分按当前查询范围逐周计算,未满一周不折算;查看本周评分请使用 `ccus dashboard serve --range this-week`。聚合面板在「多人对比」中显示周评分,多周取逐周评分的平均值。额度缺失显示 `--`(消息占比为 100% 时可单独评分)。评分仅在页面展示,不改变 bundle 或 CSV。
27
+ 权重和标准 5 天基准集中在 `src/lib/weekly-score.ts` 的 `DEFAULT_SCORE_SETTINGS` 修改:`messageWeight` 为消息占比(额度占比为 100 减去消息占比),`messageBaseline` 为周消息基准,`quotaBaseline` 为周额度基准。修改后重新构建并重启面板服务。
28
+
29
+ 评分按当前查询范围逐周计算,未满一周不折算;查看本周评分请使用 `ccus dashboard serve --range this-week`。聚合面板在「多人对比」中显示周评分,多周取逐周评分的平均值。额度缺失显示 `--`(消息占比为 100% 时可单独评分)。`weekly.csv` 末尾包含 `weeklyScore`、`attendanceDays` 两列:评分与看板共用公式,保留最多 1 位小数,不可用时留空;出勤天数为 1~7,看板导出使用各成员当前选择,命令行默认 5 天。bundle、`daily.csv` 和 `detail.csv` 不变。
28
30
 
29
31
  ## 安装
30
32
 
package/dist/cli.js CHANGED
@@ -613,7 +613,7 @@ async function handleAggregate(options) {
613
613
  (0, debug_1.debugLog)("aggregate", "bundles loaded", { bundleCount: bundles.length, detailRows: detailRows.length, dailyRows: dailyRows.length, weeklyRows: weeklyRows.length });
614
614
  const detailCsv = (0, export_1.buildAggregatedDetailCsv)(detailRows);
615
615
  const dailyCsv = (0, export_1.buildAggregatedDailyCsv)(dailyRows);
616
- const weeklyCsv = (0, export_1.buildAggregatedWeeklyCsv)(weeklyRows);
616
+ const weeklyCsv = (0, export_1.buildAggregatedWeeklyCsv)(weeklyRows, 5);
617
617
  const detailPath = node_path_1.default.join(outputDir, "detail.csv");
618
618
  const dailyPath = node_path_1.default.join(outputDir, "daily.csv");
619
619
  const weeklyPath = node_path_1.default.join(outputDir, "weekly.csv");
@@ -9,6 +9,7 @@ const time_1 = require("./time");
9
9
  const api_equivalent_cost_1 = require("./api-equivalent-cost");
10
10
  const api_pricing_table_1 = require("./api-pricing-table");
11
11
  const weekly_score_1 = require("./weekly-score");
12
+ const export_1 = require("./export");
12
13
  /** 折线 / 图例统一的调色板,保证同一个人在不同图表里颜色一致。 */
13
14
  const CHART_PALETTE = ["#5eead4", "#f59e0b", "#a855f7", "#22c55e", "#f87171", "#60a5fa", "#fbbf24", "#34d399"];
14
15
  /**
@@ -433,21 +434,16 @@ function renderPeopleLeaderboard(people, weeklyRows) {
433
434
  const scores = new Map();
434
435
  for (const row of weeklyRows) {
435
436
  const values = scores.get(row.personKey) ?? [];
436
- values.push((0, weekly_score_1.calculateWeeklyScore)(row.userMessageCount, row.sevenDayCumulativeUsagePct, weekly_score_1.DEFAULT_SCORE_SETTINGS));
437
+ values.push(row);
437
438
  scores.set(row.personKey, values);
438
439
  }
439
- const scoreText = (personKey) => {
440
- const values = scores.get(personKey) ?? [];
441
- if (values.length === 0 || values.some(value => value === null))
442
- return "--";
443
- return (values.reduce((sum, value) => sum + (value ?? 0), 0) / values.length).toFixed(1);
444
- };
445
440
  const rows = people
446
441
  .map((person, index) => `
447
442
  <tr>
448
443
  <td class="rank">${index + 1}</td>
449
444
  <td><strong>${escapeHtml(person.personKey)}</strong></td>
450
- <td><strong>${scoreText(person.personKey)}</strong></td>
445
+ <td><strong>${(0, weekly_score_1.renderAttendanceScore)(scores.get(person.personKey) ?? [])}</strong></td>
446
+ <td>${(0, weekly_score_1.renderAttendanceControl)(person.personKey)}</td>
451
447
  <td>${formatNumber(person.userMessageCount)}</td>
452
448
  <td>${formatTokensM(person.inputTokens)}</td>
453
449
  <td>${formatTokensM(person.outputTokens)}</td>
@@ -468,6 +464,8 @@ function renderPeopleLeaderboard(people, weeklyRows) {
468
464
  <h2>多人对比</h2>
469
465
  </div>
470
466
  <p class="muted">按用户消息数降序排列;评分按周计算,多周取平均,缺失额度显示 --。</p>
467
+ ${(0, weekly_score_1.renderAttendanceControl)()}
468
+ <button type="button" id="export-weekly-csv">导出周统计 CSV</button>
471
469
  </div>
472
470
  <div class="table-wrap">
473
471
  <table>
@@ -476,6 +474,7 @@ function renderPeopleLeaderboard(people, weeklyRows) {
476
474
  <th>#</th>
477
475
  <th>personKey</th>
478
476
  <th>周评分</th>
477
+ <th>出勤天数</th>
479
478
  <th>消息</th>
480
479
  <th>Input tokens</th>
481
480
  <th>Output tokens</th>
@@ -494,6 +493,38 @@ function renderPeopleLeaderboard(people, weeklyRows) {
494
493
  </section>
495
494
  `;
496
495
  }
496
+ function weeklyCsvDownloadScript(rows) {
497
+ const header = (0, export_1.buildAggregatedWeeklyCsv)([]);
498
+ const data = rows.map(row => ({
499
+ personKey: row.personKey,
500
+ csv: [1, 2, 3, 4, 5, 6, 7].map(days => (0, export_1.buildAggregatedWeeklyCsv)([row], days).slice(header.length + 1)),
501
+ }));
502
+ // 防止用户字段闭合内联脚本;CSV 转义和公式防护复用导出模块。
503
+ const serialized = JSON.stringify({ header, rows: data }).replaceAll("<", "\\u003c");
504
+ return `<script>
505
+ (() => {
506
+ const button = document.getElementById('export-weekly-csv');
507
+ if (!button) return;
508
+ const data = ${serialized};
509
+ button.addEventListener('click', () => {
510
+ const attendance = new Map();
511
+ document.querySelectorAll('[data-attendance-person]').forEach(control => {
512
+ const days = Number(control.value);
513
+ attendance.set(control.dataset.attendancePerson, Number.isInteger(days) && days >= 1 && days <= 7 ? days : 5);
514
+ });
515
+ const csv = [data.header, ...data.rows.map(row => row.csv[(attendance.get(row.personKey) ?? 5) - 1])].join('\\n');
516
+ const url = URL.createObjectURL(new Blob(['\\uFEFF', csv], { type: 'text/csv;charset=utf-8' }));
517
+ const link = document.createElement('a');
518
+ link.href = url;
519
+ link.download = 'weekly.csv';
520
+ document.body.appendChild(link);
521
+ link.click();
522
+ link.remove();
523
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
524
+ });
525
+ })();
526
+ </script>`;
527
+ }
497
528
  /** 把 daily 行按 personKey × date 排成一个矩阵表。 */
498
529
  function renderDailyMatrix(people, dailyIndex, dateAxis) {
499
530
  if (people.length === 0 || dateAxis.length === 0) {
@@ -816,6 +847,8 @@ function buildAggregateDashboardHtml(detailRows, dailyRows, weeklyRows, generate
816
847
  ${renderDailyMatrix(people, dailyIndex, dateAxis)}
817
848
  ${renderWeeklyTable(weeklyRows)}
818
849
  </main>
850
+ ${(0, weekly_score_1.attendanceScoreScript)()}
851
+ ${weeklyCsvDownloadScript(weeklyRows)}
819
852
  ${(0, chart_assets_1.uplotBodyScripts)()}
820
853
  </body>
821
854
  </html>`;
@@ -697,6 +697,7 @@ function fallbackWeeklyUsage(days) {
697
697
  /**
698
698
  * 展开 weekly.csv:不同机器的同人同天数据已在 selectDailyRepresentatives 层按 sessionId 去重分组,
699
699
  * 这里直接按 (person, 周) 把所有代表的 token / 计数累加上卷,usage 从该周所有代表事件重算。
700
+ * 出勤天数由 CSV 导出层提供,不从原始事件推算。
700
701
  */
701
702
  function buildAggregatedWeeklyRows(bundles) {
702
703
  const repsMap = selectDailyRepresentatives(bundles);
@@ -325,9 +325,6 @@ function buildDashboardHtml(events, rangeLabel, start, end, dailyUserMessages =
325
325
  const combinedUserMessages = totalUserMessages + codexTotalUserMessages;
326
326
  const scoreWeek = (0, time_1.localDateKey)((0, time_1.resolveRange)("this-week", end, (0, time_1.parseDayStart)(dayStart)).start);
327
327
  const scoreRow = buildPersonalWeeklyScores(events, dailyUserMessages, dayStart).find(row => row.week === scoreWeek);
328
- const weeklyScore = scoreRow
329
- ? (0, weekly_score_1.calculateWeeklyScore)(scoreRow.userMessageCount, scoreRow.sevenDayCumulativeUsagePct, weekly_score_1.DEFAULT_SCORE_SETTINGS)
330
- : null;
331
328
  return `<!doctype html>
332
329
  <html lang="zh-CN">
333
330
  <head>
@@ -507,7 +504,8 @@ function buildDashboardHtml(events, rangeLabel, start, end, dailyUserMessages =
507
504
  <section class="stats">
508
505
  <article class="panel stat-card" id="weekly-score">
509
506
  <h2>周评分</h2>
510
- <p class="stat-value">${weeklyScore?.toFixed(1) ?? "--"}</p>
507
+ <p class="stat-value">${(0, weekly_score_1.renderAttendanceScore)(scoreRow ? [scoreRow] : [])}</p>
508
+ ${(0, weekly_score_1.renderAttendanceControl)()}
511
509
  <p class="stat-note">${scoreWeek} 当周 · 当前查询范围内数据</p>
512
510
  </article>
513
511
  <article class="panel stat-card">
@@ -527,6 +525,7 @@ function buildDashboardHtml(events, rangeLabel, start, end, dailyUserMessages =
527
525
  ${renderDailyMessages(dailyUserMessages)}
528
526
  ${renderRecentEvents(events)}
529
527
  </main>
528
+ ${(0, weekly_score_1.attendanceScoreScript)()}
530
529
  ${(0, chart_assets_1.uplotBodyScripts)()}
531
530
  </body>
532
531
  </html>`;
@@ -19,6 +19,7 @@ const node_path_1 = __importDefault(require("node:path"));
19
19
  const node_util_1 = require("node:util");
20
20
  const node_zlib_1 = require("node:zlib");
21
21
  const time_1 = require("./time");
22
+ const weekly_score_1 = require("./weekly-score");
22
23
  const gzipAsync = (0, node_util_1.promisify)(node_zlib_1.gzip);
23
24
  /** CSV 字符串字段统一做转义,避免逗号和引号破坏列结构。 */
24
25
  function quoteCsv(value) {
@@ -248,7 +249,7 @@ function buildAggregatedDailyCsv(rows) {
248
249
  return [header.join(","), ...lines].join("\n");
249
250
  }
250
251
  /** 多人汇总的 weekly.csv。 */
251
- function buildAggregatedWeeklyCsv(rows) {
252
+ function buildAggregatedWeeklyCsv(rows, attendanceDays = 5) {
252
253
  const header = [
253
254
  "personKey",
254
255
  "week",
@@ -267,26 +268,33 @@ function buildAggregatedWeeklyCsv(rows) {
267
268
  "uniqueWorkspaces",
268
269
  "estimatedApiEquivalentCostUsd",
269
270
  "pricingCatalogVersion",
271
+ "weeklyScore",
272
+ "attendanceDays",
270
273
  ];
271
- const lines = rows.map((row) => toCsvLine([
272
- row.personKey,
273
- row.week,
274
- row.userMessageCount,
275
- row.apiRequestCount,
276
- toMillions(row.inputTokens),
277
- toMillions(row.outputTokens),
278
- toMillions(row.cacheReadInputTokens),
279
- row.sampleCount,
280
- row.fiveHourPeakUsagePct,
281
- row.fiveHourLatestUsagePct,
282
- row.sevenDayPeakUsagePct,
283
- row.sevenDayLatestUsagePct,
284
- row.sevenDayCumulativeUsagePct,
285
- row.uniqueSessions,
286
- row.uniqueWorkspaces,
287
- formatApiEquivalentCost(row.estimatedApiEquivalentCostUsd),
288
- row.pricingCatalogVersion,
289
- ]));
274
+ const lines = rows.map((row) => {
275
+ const score = (0, weekly_score_1.calculateWeeklyScore)(row.userMessageCount, row.sevenDayCumulativeUsagePct, weekly_score_1.DEFAULT_SCORE_SETTINGS, attendanceDays);
276
+ return toCsvLine([
277
+ row.personKey,
278
+ row.week,
279
+ row.userMessageCount,
280
+ row.apiRequestCount,
281
+ toMillions(row.inputTokens),
282
+ toMillions(row.outputTokens),
283
+ toMillions(row.cacheReadInputTokens),
284
+ row.sampleCount,
285
+ row.fiveHourPeakUsagePct,
286
+ row.fiveHourLatestUsagePct,
287
+ row.sevenDayPeakUsagePct,
288
+ row.sevenDayLatestUsagePct,
289
+ row.sevenDayCumulativeUsagePct,
290
+ row.uniqueSessions,
291
+ row.uniqueWorkspaces,
292
+ formatApiEquivalentCost(row.estimatedApiEquivalentCostUsd),
293
+ row.pricingCatalogVersion,
294
+ score === null ? null : Number(score.toFixed(1)),
295
+ attendanceDays,
296
+ ]);
297
+ });
290
298
  return [header.join(","), ...lines].join("\n");
291
299
  }
292
300
  /** 统一负责写文本文件,顺带确保父目录存在。 */
@@ -2,15 +2,19 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DEFAULT_SCORE_SETTINGS = void 0;
4
4
  exports.calculateWeeklyScore = calculateWeeklyScore;
5
+ exports.renderAttendanceControl = renderAttendanceControl;
6
+ exports.renderAttendanceScore = renderAttendanceScore;
7
+ exports.attendanceScoreScript = attendanceScoreScript;
5
8
  exports.renderWeeklyScores = renderWeeklyScores;
6
9
  exports.DEFAULT_SCORE_SETTINGS = {
7
10
  messageWeight: 50,
8
11
  messageBaseline: 200,
9
12
  quotaBaseline: 70,
10
13
  };
11
- function calculateWeeklyScore(messages, quota, settings) {
14
+ function calculateWeeklyScore(messages, quota, settings, attendanceDays = 5) {
12
15
  const { messageWeight, messageBaseline, quotaBaseline } = settings;
13
- if (!Number.isFinite(messageWeight) || messageWeight < 0 || messageWeight > 100
16
+ if (!Number.isInteger(attendanceDays) || attendanceDays < 1 || attendanceDays > 7
17
+ || !Number.isFinite(messageWeight) || messageWeight < 0 || messageWeight > 100
14
18
  || !Number.isFinite(messageBaseline) || messageBaseline <= 0
15
19
  || !Number.isFinite(quotaBaseline) || quotaBaseline <= 0
16
20
  || !Number.isFinite(messages) || messages < 0
@@ -18,8 +22,48 @@ function calculateWeeklyScore(messages, quota, settings) {
18
22
  return null;
19
23
  if (quota === null && messageWeight < 100)
20
24
  return null;
21
- return messageWeight * Math.sqrt(messages / messageBaseline)
22
- + (100 - messageWeight) * Math.sqrt((quota ?? 0) / quotaBaseline);
25
+ const attendanceRatio = attendanceDays / 5;
26
+ return messageWeight * Math.sqrt(messages / (messageBaseline * attendanceRatio))
27
+ + (100 - messageWeight) * Math.sqrt((quota ?? 0) / (quotaBaseline * attendanceRatio));
28
+ }
29
+ function renderAttendanceControl(personKey) {
30
+ const attributes = personKey === undefined ? 'id="attendance-days"' : `data-attendance-person="${escapeHtml(personKey)}" aria-label="出勤天数"`;
31
+ const select = `<select ${attributes}>${[1, 2, 3, 4, 5, 6, 7].map(days => `<option value="${days}"${days === 5 ? " selected" : ""}>${days} 天</option>`).join("")}</select>`;
32
+ return personKey === undefined ? `<label>出勤天数 ${select}</label>` : select;
33
+ }
34
+ /** 多周逐周评分后取平均;浏览器切换预先按各出勤天数算好的结果。 */
35
+ function renderAttendanceScore(rows) {
36
+ const scores = [1, 2, 3, 4, 5, 6, 7].map(days => {
37
+ const values = rows.map(row => calculateWeeklyScore(row.userMessageCount, row.sevenDayCumulativeUsagePct, exports.DEFAULT_SCORE_SETTINGS, days));
38
+ if (!values.length || values.some(value => value === null))
39
+ return "--";
40
+ return (values.reduce((sum, value) => sum + (value ?? 0), 0) / values.length).toFixed(1);
41
+ });
42
+ return `<span data-attendance-scores="${escapeHtml(JSON.stringify(scores))}">${scores[4]}</span>`;
43
+ }
44
+ function attendanceScoreScript() {
45
+ return `<script>
46
+ (() => {
47
+ const select = document.getElementById('attendance-days');
48
+ if (!select) return;
49
+ select.addEventListener('change', () => {
50
+ const days = Number(select.value);
51
+ if (!Number.isInteger(days) || days < 1 || days > 7) return;
52
+ document.querySelectorAll('[data-attendance-scores]').forEach(element => {
53
+ element.textContent = JSON.parse(element.dataset.attendanceScores)[days - 1];
54
+ });
55
+ document.querySelectorAll('[data-attendance-person]').forEach(element => { element.value = select.value; });
56
+ });
57
+ document.querySelectorAll('[data-attendance-person]').forEach(control => {
58
+ control.addEventListener('change', () => {
59
+ const days = Number(control.value);
60
+ if (!Number.isInteger(days) || days < 1 || days > 7) return;
61
+ const score = control.closest('tr').querySelector('[data-attendance-scores]');
62
+ score.textContent = JSON.parse(score.dataset.attendanceScores)[days - 1];
63
+ });
64
+ });
65
+ })();
66
+ </script>`;
23
67
  }
24
68
  function escapeHtml(value) {
25
69
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccus-cli",
3
- "version": "0.2.24",
3
+ "version": "0.2.26",
4
4
  "description": "Claude Code statusline usage logger and dashboard CLI",
5
5
  "type": "commonjs",
6
6
  "bin": {