dsh-plugin-daily-report 1.0.1 → 1.0.2

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
@@ -17,6 +17,7 @@
17
17
  - **设置持久化**:Git 仓库、文档、AI 分析、仅统计我的提交和日期范围都会保存到 `settings.json`,下次打开页面保持一致;修改后会自动保存,也可以点击右上角「保存设置」立即保存。
18
18
  - **生成进度**:生成任务异步执行,页面实时显示当前阶段、百分比和 AI 已接收字数;AI 分析阶段使用不确定进度条,避免长时间无反馈。
19
19
  - **XLSX 输出**:工作簿包含【日报汇总】【AI分析】(启用时)【Git明细】【文档明细】【数据源】【说明】工作表,带表头样式、冻结窗格、筛选和列宽。日报汇总冻结日期/星期/日类型三列,Git 明细冻结日期/仓库/分支,左右滚动时保持可见。
20
+ - **多格式导出**:右上角「生成日报」支持下拉选择 XLSX、Markdown、CSV、HTML,默认 XLSX;所选格式会保存到 `settings.json`,预览页的导出按钮会跟随格式变化。
20
21
  - **独立页面**:左侧全局菜单新增「日报汇总」入口,点击后在中栏打开独立页面,不依附于任何会话。
21
22
  - **两种入口**:左侧「日报汇总」页面;同时注册 `daily_report` Agent 工具,便于在对话中直接生成。
22
23
 
@@ -54,7 +55,7 @@ plugin_manager
54
55
  1. 点击左侧菜单底部的「日报汇总」图标,在中栏打开独立页面。
55
56
  2. 添加 Git 仓库:填写远程地址或本地路径,分支留空时使用默认分支;也可以点击「扫描目录」,指定一个包含多个仓库的目录,批量识别其中的仓库和当前分支。
56
57
  3. 添加本地文档:填写文件或目录的绝对路径。
57
- 4. 选择日期范围(默认当月),按需勾选「启用 AI 分析」(默认已勾选),点击页面右上角的「生成 XLSX 日报」。
58
+ 4. 选择日期范围(默认当月),按需勾选「启用 AI 分析」(默认已勾选),在右上角「生成日报」下拉中选择格式;选择格式会立即生成,直接点击主按钮则按当前格式生成。
58
59
  5. 生成完成后先在预览区检查:编辑总体/每日 AI 摘要、对某天重新分析、补充手动工作项,确认后点击「导出 XLSX」下载。
59
60
 
60
61
  Git 仓库、文档、AI 分析开关、作者过滤和日期范围会自动保存在 `$DSH_HOME/daily-report/settings.json`,跨会话与重启有效。
@@ -70,6 +71,7 @@ Git 仓库、文档、AI 分析开关、作者过滤和日期范围会自动保
70
71
  | `docs` | 可选,字符串数组;省略时使用已保存配置 |
71
72
  | `outputPath` | 可选,输出路径;省略时写入当前会话工作目录 |
72
73
  | `ai` | 可选,布尔值;`true` 时调用当前默认模型分析提交 diff 与文档摘要 |
74
+ | `format` | 可选,`xlsx` / `markdown` / `csv` / `html`,默认 `xlsx` |
73
75
  | `locale` | `zh` / `en` |
74
76
 
75
77
  ## 结构
@@ -81,6 +83,7 @@ Git 仓库、文档、AI 分析开关、作者过滤和日期范围会自动保
81
83
  | `lib/collect.js` | Git 日志、作者过滤、文档扫描、按天归集、仓库目录扫描 |
82
84
  | `lib/ai.js` | AI 提示词、按天分析、增量缓存、单日重新分析 |
83
85
  | `lib/report.js` | XLSX 工作表内容与样式 |
86
+ | `lib/formats.js` | Markdown、CSV、HTML 日报渲染 |
84
87
  | `lib/xlsx.js` | 无第三方依赖的 XLSX(ZIP + SpreadsheetML)写入器 |
85
88
  | `cordis.patch.yml` | 插入 `daily-report` 插件行 |
86
89
  | `locale/*.json` | 插件管理器展示文案 |
package/client.js CHANGED
@@ -63,7 +63,12 @@ window.__ModuleLoader__.load({
63
63
  "progress.hint": "AI 分析需要调用模型,耗时通常比普通汇总更长;请保持页面打开。",
64
64
  optional: "可选",
65
65
  remove: "移除",
66
- generate: "生成 XLSX 日报",
66
+ generate: "生成日报",
67
+ "format.label": "格式",
68
+ "format.xlsx": "XLSX 工作簿",
69
+ "format.markdown": "Markdown 文档",
70
+ "format.csv": "CSV 表格",
71
+ "format.html": "HTML 网页",
67
72
  generating: "正在收集并生成…",
68
73
  "save.saving": "正在保存设置…",
69
74
  "save.savingShort": "正在保存…",
@@ -85,7 +90,7 @@ window.__ModuleLoader__.load({
85
90
  "result.ai.failed": "AI 分析未完成",
86
91
  "preview.title": "生成结果预览",
87
92
  "preview.desc": "先检查并编辑每日 AI 摘要、补充非代码工作,再导出 XLSX。",
88
- "preview.export": "导出 XLSX",
93
+ "preview.export": "导出",
89
94
  "preview.expandAll": "全部展开",
90
95
  "preview.collapseAll": "全部收起",
91
96
  "preview.exporting": "正在导出…",
@@ -171,7 +176,12 @@ window.__ModuleLoader__.load({
171
176
  "progress.hint": "AI analysis calls a model, so it usually takes longer than a plain digest. Keep this page open.",
172
177
  optional: "optional",
173
178
  remove: "Remove",
174
- generate: "Generate XLSX report",
179
+ generate: "Generate report",
180
+ "format.label": "Format",
181
+ "format.xlsx": "XLSX workbook",
182
+ "format.markdown": "Markdown document",
183
+ "format.csv": "CSV table",
184
+ "format.html": "HTML page",
175
185
  generating: "Collecting and generating…",
176
186
  "save.saving": "Saving settings…",
177
187
  "save.savingShort": "Saving…",
@@ -193,7 +203,7 @@ window.__ModuleLoader__.load({
193
203
  "result.ai.failed": "AI analysis did not complete",
194
204
  "preview.title": "Review before export",
195
205
  "preview.desc": "Check and edit each day's AI summary, add non-code work, then export the XLSX.",
196
- "preview.export": "Export XLSX",
206
+ "preview.export": "Export",
197
207
  "preview.expandAll": "Expand all",
198
208
  "preview.collapseAll": "Collapse all",
199
209
  "preview.exporting": "Exporting…",
@@ -289,7 +299,7 @@ window.__ModuleLoader__.load({
289
299
  return { git, docs, mineOnly: source.mineOnly !== false };
290
300
  }
291
301
 
292
- function cleanSettings(settings, aiEnabled, range) {
302
+ function cleanSettings(settings, aiEnabled, range, format) {
293
303
  return {
294
304
  git: settings.git
295
305
  .map((item) => ({
@@ -306,6 +316,7 @@ window.__ModuleLoader__.load({
306
316
  .filter((item) => item.location.length > 0),
307
317
  aiEnabled: aiEnabled !== false,
308
318
  mineOnly: settings.mineOnly !== false,
319
+ format: FORMAT_OPTIONS.includes(format) ? format : "xlsx",
309
320
  range:
310
321
  range && typeof range.start === "string" && typeof range.end === "string"
311
322
  ? { start: range.start, end: range.end }
@@ -339,6 +350,23 @@ window.__ModuleLoader__.load({
339
350
  }
340
351
  }
341
352
 
353
+ const FORMAT_OPTIONS = ["xlsx", "markdown", "csv", "html"];
354
+
355
+ function formatLabel(format, t) {
356
+ if (format === "markdown") return t("format.markdown", "Markdown");
357
+ if (format === "csv") return t("format.csv", "CSV");
358
+ if (format === "html") return t("format.html", "HTML");
359
+ return t("format.xlsx", "XLSX");
360
+ }
361
+
362
+ function formatExtension(format) {
363
+ return format === "markdown" ? "md" : format;
364
+ }
365
+
366
+ function formatShortLabel(format) {
367
+ return format === "markdown" ? "Markdown" : format.toUpperCase();
368
+ }
369
+
342
370
  function useTranslate(ctx) {
343
371
  const [, force] = React.useState(0);
344
372
  React.useEffect(() => {
@@ -359,8 +387,20 @@ window.__ModuleLoader__.load({
359
387
  ".dsdr-hero-copy { flex: 1; min-width: 0; }",
360
388
  ".dsdr-hero-action { display: flex; flex: none; flex-direction: column; align-items: flex-end; gap: 5px; margin-left: auto; }",
361
389
  ".dsdr-hero-buttons { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; }",
362
- ".dsdr-save-btn { min-height: 38px; }",
363
- ".dsdr-generate { min-height: 38px; padding: 9px 18px; font-size: 13px; white-space: nowrap; }",
390
+ ".dsdr-save-btn { min-height: 38px; background: #3b4046; border-color: #3b4046; color: #ffffff; font-weight: 600; }",
391
+ ".dsdr-save-btn:hover { background: #292d31; border-color: #292d31; }",
392
+ ".dsdr-save-btn:disabled { background: #666b70; border-color: #666b70; color: #ffffff; opacity: .8; }",
393
+ ".dsdr-generate { min-height: 38px; padding: 9px 16px; font-size: 13px; white-space: nowrap; }",
394
+ ".dsdr-btn-with-icon { display: inline-flex; align-items: center; justify-content: center; gap: 6px; }",
395
+ ".dsdr-split { position: relative; display: inline-flex; align-items: stretch; }",
396
+ ".dsdr-split .dsdr-generate { border-radius: 8px 0 0 8px; }",
397
+ ".dsdr-split-toggle { min-height: 38px; padding: 0 9px; border-radius: 0 8px 8px 0; border-left: 1px solid rgba(255, 255, 255, .32); }",
398
+ ".dsdr-menu { position: absolute; top: calc(100% + 6px); right: 0; z-index: 30; min-width: 190px; padding: 5px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; background: var(--dsw-alias-bg-overlay); box-shadow: 0 10px 28px rgba(0, 0, 0, .2); }",
399
+ ".dsdr-menu-title { padding: 5px 8px 4px; color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 16px; }",
400
+ ".dsdr-menu-item { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; padding: 7px 8px; border: 0; border-radius: 7px; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 12px; line-height: 18px; text-align: left; cursor: pointer; }",
401
+ ".dsdr-menu-item:hover { background: var(--dsw-alias-bg-layer-2); }",
402
+ ".dsdr-menu-item.is-active { background: var(--dsw-alias-bg-layer-2); font-weight: 600; }",
403
+ ".dsdr-menu-check { width: 14px; color: #146b5a; text-align: center; }",
364
404
  ".dsdr-save { color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 16px; }",
365
405
  ".dsdr-mark { flex: none; width: 42px; height: 42px; border-radius: 11px; display: grid; place-items: center; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); }",
366
406
  ".dsdr-title { margin: 0; font-size: 22px; line-height: 30px; font-weight: 600; }",
@@ -380,9 +420,9 @@ window.__ModuleLoader__.load({
380
420
  ".dsdr-btn:hover { background: var(--dsw-alias-bg-layer-1); border-color: var(--dsw-alias-brand-primary); }",
381
421
  ".dsdr-btn:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }",
382
422
  ".dsdr-btn:disabled { opacity: .7; cursor: not-allowed; }",
383
- ".dsdr-btn-primary { background: #15557f; border-color: #15557f; color: #ffffff; font-weight: 600; box-shadow: 0 2px 6px rgba(0, 0, 0, .18); }",
384
- ".dsdr-btn-primary:hover { background: #103f5f; border-color: #103f5f; opacity: 1; }",
385
- ".dsdr-btn-primary:disabled { background: #5e7c8f; border-color: #5e7c8f; color: #ffffff; opacity: .8; }",
423
+ ".dsdr-btn-primary { background: #146b5a; border-color: #146b5a; color: #ffffff; font-weight: 600; box-shadow: 0 2px 6px rgba(0, 0, 0, .18); }",
424
+ ".dsdr-btn-primary:hover { background: #0f5547; border-color: #0f5547; opacity: 1; }",
425
+ ".dsdr-btn-primary:disabled { background: #6b8a82; border-color: #6b8a82; color: #ffffff; opacity: .8; }",
386
426
  ".dsdr-btn-danger { border-color: transparent; background: transparent; color: var(--dsw-alias-state-error-primary); font-weight: 600; }",
387
427
  ".dsdr-btn-danger:hover { background: var(--dsw-alias-bg-layer-2); border-color: transparent; }",
388
428
  ".dsdr-check { display: inline-flex; align-items: center; gap: 7px; color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 20px; cursor: pointer; white-space: nowrap; }",
@@ -443,7 +483,7 @@ window.__ModuleLoader__.load({
443
483
  ".dsdr-code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; }",
444
484
  ".dsdr-footer { color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 17px; }",
445
485
  ".dsdr-spacer { flex: 1; }",
446
- "@media (max-width: 780px) { .dsdr-page { padding: 20px 16px 40px; } .dsdr-hero-action { flex: 1 0 100%; width: 100%; align-items: stretch; margin-left: 0; } .dsdr-hero-buttons { width: 100%; } .dsdr-hero-buttons .dsdr-btn { flex: 1; } .dsdr-generate { width: 100%; } .dsdr-range { grid-template-columns: 1fr 1fr; } .dsdr-range .dsdr-actions { grid-column: 1 / -1; } .dsdr-stats { grid-template-columns: 1fr 1fr; } .dsdr-scan-row { grid-template-columns: 1fr; } .dsdr-manual-add { grid-template-columns: 1fr; } .dsdr-source, .dsdr-source-docs { grid-template-columns: 1fr; } .dsdr-source .dsdr-icon-btn { justify-self: end; } }",
486
+ "@media (max-width: 780px) { .dsdr-page { padding: 20px 16px 40px; } .dsdr-hero-action { flex: 1 0 100%; width: 100%; align-items: stretch; margin-left: 0; } .dsdr-hero-buttons { width: 100%; } .dsdr-hero-buttons .dsdr-btn { flex: 1; } .dsdr-split { flex: 1; } .dsdr-split .dsdr-generate { flex: 1; } .dsdr-generate { width: 100%; } .dsdr-range { grid-template-columns: 1fr 1fr; } .dsdr-range .dsdr-actions { grid-column: 1 / -1; } .dsdr-stats { grid-template-columns: 1fr 1fr; } .dsdr-scan-row { grid-template-columns: 1fr; } .dsdr-manual-add { grid-template-columns: 1fr; } .dsdr-source, .dsdr-source-docs { grid-template-columns: 1fr; } .dsdr-source .dsdr-icon-btn { justify-self: end; } }",
447
487
  ].join(NL);
448
488
 
449
489
  function installStyles(ctx) {
@@ -488,11 +528,73 @@ window.__ModuleLoader__.load({
488
528
  );
489
529
  }
490
530
 
531
+ function SaveIcon(props) {
532
+ const size = Number(props && props.size) > 0 ? Number(props.size) : 15;
533
+ return h(
534
+ "svg",
535
+ {
536
+ viewBox: "0 0 24 24",
537
+ width: size,
538
+ height: size,
539
+ fill: "none",
540
+ stroke: "currentColor",
541
+ strokeWidth: 1.8,
542
+ strokeLinecap: "round",
543
+ strokeLinejoin: "round",
544
+ "aria-hidden": true,
545
+ },
546
+ h("path", { d: "M5 3.5h11l3.5 3.5v13.5H5z" }),
547
+ h("path", { d: "M8 3.5v5h7v-5M8 20.5v-6h8v6" }),
548
+ );
549
+ }
550
+
551
+ function GenerateIcon(props) {
552
+ const size = Number(props && props.size) > 0 ? Number(props.size) : 15;
553
+ return h(
554
+ "svg",
555
+ {
556
+ viewBox: "0 0 24 24",
557
+ width: size,
558
+ height: size,
559
+ fill: "none",
560
+ stroke: "currentColor",
561
+ strokeWidth: 1.8,
562
+ strokeLinecap: "round",
563
+ strokeLinejoin: "round",
564
+ "aria-hidden": true,
565
+ },
566
+ h("path", { d: "M6 3.5h8l4 4v13H6z" }),
567
+ h("path", { d: "M14 3.5v4h4M9 12h6M9 15.5h4" }),
568
+ h("path", { d: "M18.5 16.5v4M16.5 18.5h4" }),
569
+ );
570
+ }
571
+
572
+ function ChevronIcon(props) {
573
+ const size = Number(props && props.size) > 0 ? Number(props.size) : 12;
574
+ return h(
575
+ "svg",
576
+ {
577
+ viewBox: "0 0 24 24",
578
+ width: size,
579
+ height: size,
580
+ fill: "none",
581
+ stroke: "currentColor",
582
+ strokeWidth: 2,
583
+ strokeLinecap: "round",
584
+ strokeLinejoin: "round",
585
+ "aria-hidden": true,
586
+ },
587
+ h("path", { d: "M6 9l6 6 6-6" }),
588
+ );
589
+ }
590
+
491
591
  function DailyReportView(props) {
492
592
  const ctx = props.ctx;
493
593
  const t = useTranslate(ctx);
494
594
  const [settings, setSettings] = React.useState({ git: [], docs: [] });
495
595
  const [range, setRange] = React.useState(currentMonthRange);
596
+ const [exportFormat, setExportFormat] = React.useState("xlsx");
597
+ const [formatMenuOpen, setFormatMenuOpen] = React.useState(false);
496
598
  const [aiInfo, setAiInfo] = React.useState({ available: false, provider: "", model: "" });
497
599
  const [useAi, setUseAi] = React.useState(true);
498
600
  const [loading, setLoading] = React.useState(true);
@@ -519,6 +621,7 @@ window.__ModuleLoader__.load({
519
621
  const pollAbortRef = React.useRef(null);
520
622
  const saveAbortRef = React.useRef(null);
521
623
  const saveTimerRef = React.useRef(null);
624
+ const formatMenuRef = React.useRef(null);
522
625
  const sessionId = typeof props.sessionId === "string" ? props.sessionId : "";
523
626
  const localeIsEnglish = currentLocaleId(ctx).toLowerCase().startsWith("en");
524
627
 
@@ -535,6 +638,17 @@ window.__ModuleLoader__.load({
535
638
  };
536
639
  }, []);
537
640
 
641
+ React.useEffect(() => {
642
+ if (!formatMenuOpen) return;
643
+ const close = (event) => {
644
+ if (formatMenuRef.current && !formatMenuRef.current.contains(event.target)) {
645
+ setFormatMenuOpen(false);
646
+ }
647
+ };
648
+ document.addEventListener("mousedown", close);
649
+ return () => document.removeEventListener("mousedown", close);
650
+ }, [formatMenuOpen]);
651
+
538
652
  React.useEffect(() => {
539
653
  let alive = true;
540
654
  const query = sessionId.length > 0 ? "?sessionId=" + encodeURIComponent(sessionId) : "";
@@ -547,6 +661,10 @@ window.__ModuleLoader__.load({
547
661
  if (!alive) return;
548
662
  setSettings(normalizeClientSettings(payload && payload.settings));
549
663
  setUseAi(!(payload && payload.settings && payload.settings.aiEnabled === false));
664
+ const savedFormat = payload && payload.settings && payload.settings.format;
665
+ if (typeof savedFormat === "string" && FORMAT_OPTIONS.includes(savedFormat)) {
666
+ setExportFormat(savedFormat);
667
+ }
550
668
  const nextAi = payload && payload.ai ? payload.ai : { available: false, provider: "", model: "" };
551
669
  setAiInfo({
552
670
  available: nextAi.available === true,
@@ -590,7 +708,7 @@ window.__ModuleLoader__.load({
590
708
  saveTimerRef.current = null;
591
709
  }
592
710
  };
593
- }, [settings, useAi, range, loading]);
711
+ }, [settings, useAi, range, exportFormat, loading]);
594
712
 
595
713
  function updateGit(index, patch) {
596
714
  setSettings((previous) => ({
@@ -637,7 +755,7 @@ window.__ModuleLoader__.load({
637
755
  const response = await fetch(API + "/state", {
638
756
  method: "PUT",
639
757
  headers: { "content-type": "application/json" },
640
- body: JSON.stringify({ settings: cleanSettings(settings, useAi, range) }),
758
+ body: JSON.stringify({ settings: cleanSettings(settings, useAi, range, exportFormat) }),
641
759
  signal: controller.signal,
642
760
  });
643
761
  if (!response.ok) throw new Error("save-failed");
@@ -821,7 +939,7 @@ window.__ModuleLoader__.load({
821
939
  const response = await fetch(API + "/job/export", {
822
940
  method: "POST",
823
941
  headers: { "content-type": "application/json", accept: "application/octet-stream" },
824
- body: JSON.stringify({ jobId: previewJobId, edits: previewEdits() }),
942
+ body: JSON.stringify({ jobId: previewJobId, edits: previewEdits(), format: exportFormat }),
825
943
  });
826
944
  if (!response.ok) {
827
945
  let payload = null;
@@ -837,7 +955,7 @@ window.__ModuleLoader__.load({
837
955
  const filenameHeader = response.headers.get("x-report-filename");
838
956
  const filename = filenameHeader
839
957
  ? decodeURIComponent(filenameHeader)
840
- : "日报汇总_" + preview.range.start + "_" + preview.range.end + ".xlsx";
958
+ : "日报汇总_" + preview.range.start + "_" + preview.range.end + "." + formatExtension(exportFormat);
841
959
  const blob = await response.blob();
842
960
  const url = window.URL.createObjectURL(blob);
843
961
  const next = Object.assign({}, summary, { filename, url, size: blob.size });
@@ -919,9 +1037,12 @@ window.__ModuleLoader__.load({
919
1037
  });
920
1038
  }
921
1039
 
922
- async function generate() {
1040
+ async function generate(formatOverride) {
923
1041
  if (generating) return;
924
- const cleaned = cleanSettings(settings, useAi, range);
1042
+ const format = FORMAT_OPTIONS.includes(formatOverride) ? formatOverride : exportFormat;
1043
+ setExportFormat(format);
1044
+ setFormatMenuOpen(false);
1045
+ const cleaned = cleanSettings(settings, useAi, range, format);
925
1046
  if (cleaned.git.length === 0 && cleaned.docs.length === 0) {
926
1047
  setError(t("error.noSources", "请至少添加一个 Git 仓库或本地文档。"));
927
1048
  return;
@@ -1080,16 +1201,66 @@ window.__ModuleLoader__.load({
1080
1201
  "button",
1081
1202
  {
1082
1203
  type: "button",
1083
- className: "dsdr-btn dsdr-save-btn",
1204
+ className: "dsdr-btn dsdr-save-btn dsdr-btn-with-icon",
1084
1205
  onClick: saveSettingsNow,
1085
1206
  disabled: saveState === "saving",
1086
1207
  },
1087
- saveState === "saving" ? t("save.savingShort", "正在保存…") : t("save.button", "保存设置"),
1208
+ h(SaveIcon, { size: 15 }),
1209
+ h("span", null, saveState === "saving" ? t("save.savingShort", "正在保存…") : t("save.button", "保存设置")),
1088
1210
  ),
1089
1211
  h(
1090
- "button",
1091
- { type: "button", className: "dsdr-btn dsdr-btn-primary dsdr-generate", onClick: generate, disabled: generating },
1092
- generating ? t("generating", "正在收集并生成…") : t("generate", "生成 XLSX 日报"),
1212
+ "div",
1213
+ { className: "dsdr-split", ref: formatMenuRef },
1214
+ h(
1215
+ "button",
1216
+ {
1217
+ type: "button",
1218
+ className: "dsdr-btn dsdr-btn-primary dsdr-generate dsdr-btn-with-icon",
1219
+ onClick: () => generate(),
1220
+ disabled: generating,
1221
+ },
1222
+ h(GenerateIcon, { size: 15 }),
1223
+ h("span", null, generating ? t("generating", "正在收集并生成…") : t("generate", "生成日报")),
1224
+ ),
1225
+ h(
1226
+ "button",
1227
+ {
1228
+ type: "button",
1229
+ className: "dsdr-btn dsdr-btn-primary dsdr-split-toggle",
1230
+ onClick: () => setFormatMenuOpen((open) => !open),
1231
+ disabled: generating,
1232
+ "aria-haspopup": "menu",
1233
+ "aria-expanded": formatMenuOpen,
1234
+ "aria-label": t("format.label", "格式"),
1235
+ },
1236
+ h(ChevronIcon, { size: 12 }),
1237
+ ),
1238
+ formatMenuOpen
1239
+ ? h(
1240
+ "div",
1241
+ { className: "dsdr-menu", role: "menu" },
1242
+ h("div", { className: "dsdr-menu-title" }, t("format.label", "格式")),
1243
+ FORMAT_OPTIONS.map((format) =>
1244
+ h(
1245
+ "button",
1246
+ {
1247
+ type: "button",
1248
+ role: "menuitemradio",
1249
+ "aria-checked": exportFormat === format,
1250
+ className: "dsdr-menu-item" + (exportFormat === format ? " is-active" : ""),
1251
+ key: format,
1252
+ onClick: () => {
1253
+ setExportFormat(format);
1254
+ setFormatMenuOpen(false);
1255
+ generate(format);
1256
+ },
1257
+ },
1258
+ h("span", null, formatLabel(format, t)),
1259
+ h("span", { className: "dsdr-menu-check" }, exportFormat === format ? "✓" : ""),
1260
+ ),
1261
+ ),
1262
+ )
1263
+ : null,
1093
1264
  ),
1094
1265
  ),
1095
1266
  saveText ? h("div", { className: "dsdr-save" }, saveText) : null,
@@ -1150,7 +1321,7 @@ window.__ModuleLoader__.load({
1150
1321
  h(
1151
1322
  "button",
1152
1323
  { type: "button", className: "dsdr-btn dsdr-btn-primary", onClick: exportPreview, disabled: exporting || regenerating.length > 0 },
1153
- exporting ? t("preview.exporting", "正在导出…") : t("preview.export", "导出 XLSX"),
1324
+ exporting ? t("preview.exporting", "正在导出…") : t("preview.export", "导出") + " " + formatShortLabel(exportFormat),
1154
1325
  ),
1155
1326
  ),
1156
1327
  ),
package/index.js CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  sanitizeSettings,
19
19
  } from "./lib/collect.js";
20
20
  import { aiInfo, analyzeReport, regenerateDay } from "./lib/ai.js";
21
+ import { buildCsvReport, buildHtmlReport, buildMarkdownReport } from "./lib/formats.js";
21
22
  import { buildReportWorkbook, reportFileName, summarizeReport, buildReportPreview, applyReportEdits } from "./lib/report.js";
22
23
 
23
24
  export const inject = ["webServer"];
@@ -36,12 +37,32 @@ const MAX_RANGE_DAYS = 366;
36
37
  const JOB_TTL = 60 * 60 * 1000;
37
38
  const MAX_JOBS = 20;
38
39
  const jobs = new Map();
40
+ const REPORT_FORMATS = new Set(["xlsx", "markdown", "csv", "html"]);
41
+
42
+ function normalizeFormat(value) {
43
+ return typeof value === "string" && REPORT_FORMATS.has(value) ? value : "xlsx";
44
+ }
45
+
46
+ function formatContentType(format) {
47
+ if (format === "markdown") return "text/markdown; charset=utf-8";
48
+ if (format === "csv") return "text/csv; charset=utf-8";
49
+ if (format === "html") return "text/html; charset=utf-8";
50
+ return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
51
+ }
52
+
53
+ function buildReportBuffer(result, format) {
54
+ if (format === "markdown") return Buffer.from(buildMarkdownReport(result), "utf8");
55
+ if (format === "csv") return Buffer.from(buildCsvReport(result), "utf8");
56
+ if (format === "html") return Buffer.from(buildHtmlReport(result), "utf8");
57
+ return buildReportWorkbook(result);
58
+ }
39
59
 
40
60
  const DEFAULT_SETTINGS = {
41
61
  git: [],
42
62
  docs: [],
43
63
  aiEnabled: true,
44
64
  mineOnly: true,
65
+ format: "xlsx",
45
66
  range: null,
46
67
  };
47
68
 
@@ -80,12 +101,13 @@ function sendError(res, status, code, message, details) {
80
101
  sendJson(res, status, body);
81
102
  }
82
103
 
83
- function sendReportFile(res, buffer, filename, summary) {
104
+ function sendReportFile(res, buffer, filename, summary, contentType) {
84
105
  const summaryHeader = Buffer.from(JSON.stringify(summary), "utf8").toString("base64");
85
106
  const encodedName = encodeURIComponent(filename);
107
+ const extension = filename.includes(".") ? filename.slice(filename.lastIndexOf(".") + 1) : "xlsx";
86
108
  res.statusCode = 200;
87
- res.setHeader("content-type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
88
- res.setHeader("content-disposition", 'attachment; filename="daily-report.xlsx"; filename*=UTF-8\'\'' + encodedName);
109
+ res.setHeader("content-type", contentType || "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
110
+ res.setHeader("content-disposition", 'attachment; filename="daily-report.' + extension + '"; filename*=UTF-8\'\'' + encodedName);
89
111
  res.setHeader("content-length", String(buffer.length));
90
112
  res.setHeader("cache-control", "no-store");
91
113
  res.setHeader("x-report-filename", encodedName);
@@ -320,10 +342,6 @@ function registerRoute(ctx, path, handler, label) {
320
342
  ctx.effect(() => dispose, label);
321
343
  }
322
344
 
323
- function outputPathFor(cwd, start, end) {
324
- return join(cwd, reportFileName(start, end));
325
- }
326
-
327
345
  function ensureAbsolute(cwd, value) {
328
346
  if (typeof value !== "string" || value.trim().length === 0) return "";
329
347
  return isAbsolute(value) ? resolve(value) : resolve(cwd, value);
@@ -380,8 +398,9 @@ function registerTool(ctx) {
380
398
  description: "Local document files or directories. When omitted, the saved plugin configuration is used.",
381
399
  items: { type: "string" },
382
400
  },
383
- outputPath: { type: "string", description: "Optional output XLSX path. Defaults to the session working directory." },
401
+ outputPath: { type: "string", description: "Optional output path. Defaults to the session working directory." },
384
402
  ai: { type: "boolean", description: "Analyze commit diffs, commit messages and document excerpts with the current default model, then add per-day AI summaries to the workbook." },
403
+ format: { type: "string", enum: ["xlsx", "markdown", "csv", "html"], description: "Output format. Defaults to xlsx." },
385
404
  locale: { type: "string", enum: ["zh", "en"], description: "Report language. Defaults to zh." },
386
405
  },
387
406
  },
@@ -481,9 +500,9 @@ function registerTool(ctx) {
481
500
  for (const warning of result.ai.warnings || []) result.warnings.push("AI 分析:" + warning);
482
501
  }
483
502
  }
484
- const filename = reportFileName(start, end);
485
- const target = ensureAbsolute(cwd, input.outputPath) || outputPathFor(cwd, start, end);
486
- const buffer = buildReportWorkbook(result);
503
+ const filename = reportFileName(start, end, normalizeFormat(input.format));
504
+ const target = ensureAbsolute(cwd, input.outputPath) || join(cwd, filename);
505
+ const buffer = buildReportBuffer(result, normalizeFormat(input.format));
487
506
  await fs.mkdir(dirname(target), { recursive: true });
488
507
  await fs.writeFile(target, buffer);
489
508
  return {
@@ -720,8 +739,10 @@ export function apply(ctx) {
720
739
  if (job.status !== "done" || !job.result) return sendError(res, 409, "job-not-ready", "生成任务尚未完成。");
721
740
  try {
722
741
  const edited = applyReportEdits(job.result, body.edits || {});
723
- const buffer = buildReportWorkbook(edited);
724
- return sendReportFile(res, buffer, job.filename, summarizeReport(edited));
742
+ const format = normalizeFormat(body.format);
743
+ const buffer = buildReportBuffer(edited, format);
744
+ const filename = reportFileName(edited.range.start, edited.range.end, format);
745
+ return sendReportFile(res, buffer, filename, summarizeReport(edited), formatContentType(format));
725
746
  } catch (error) {
726
747
  return sendError(res, 400, "export-failed", error.message || "导出日报失败。");
727
748
  }
package/lib/collect.js CHANGED
@@ -166,6 +166,7 @@ export function sanitizeSettings(raw) {
166
166
  docs,
167
167
  aiEnabled: source.aiEnabled !== false,
168
168
  mineOnly: source.mineOnly !== false,
169
+ format: ["xlsx", "markdown", "csv", "html"].includes(source.format) ? source.format : "xlsx",
169
170
  range: start && end && start <= end ? { start: range.start, end: range.end } : null,
170
171
  };
171
172
  }
package/lib/formats.js ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Plain-text and HTML renderings of the same report result used by the XLSX
3
+ * writer. Keeping them here means every export format follows the same AI
4
+ * summaries, manual items and source data.
5
+ */
6
+
7
+ function manualMap(result) {
8
+ const map = new Map();
9
+ for (const item of result.manualItems || []) {
10
+ if (!item || typeof item.date !== "string" || typeof item.text !== "string") continue;
11
+ if (!map.has(item.date)) map.set(item.date, []);
12
+ map.get(item.date).push(item.text);
13
+ }
14
+ return map;
15
+ }
16
+
17
+ function aiDayMap(result) {
18
+ const map = new Map();
19
+ for (const day of result.ai && result.ai.ok && Array.isArray(result.ai.days) ? result.ai.days : []) {
20
+ map.set(day.date, day);
21
+ }
22
+ return map;
23
+ }
24
+
25
+ function cellText(value) {
26
+ return value === null || value === undefined ? "" : String(value);
27
+ }
28
+
29
+ function markdownCell(value) {
30
+ return cellText(value).replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
31
+ }
32
+
33
+ function csvCell(value) {
34
+ return '"' + cellText(value).replace(/"/g, '""') + '"';
35
+ }
36
+
37
+ function htmlEscape(value) {
38
+ return cellText(value)
39
+ .replace(/&/g, "&amp;")
40
+ .replace(/</g, "&lt;")
41
+ .replace(/>/g, "&gt;")
42
+ .replace(/"/g, "&quot;");
43
+ }
44
+
45
+ function summaryRows(result) {
46
+ const manual = manualMap(result);
47
+ const ai = aiDayMap(result);
48
+ return result.days.map((day) => {
49
+ const dayAi = ai.get(day.date);
50
+ return [
51
+ day.date,
52
+ day.weekday,
53
+ day.dayType,
54
+ day.commitCount,
55
+ day.docCount,
56
+ day.sources || "—",
57
+ day.summary,
58
+ (manual.get(day.date) || []).join(";"),
59
+ dayAi ? dayAi.summary || "" : "",
60
+ ];
61
+ });
62
+ }
63
+
64
+ export function buildMarkdownReport(result) {
65
+ const ai = result.ai && result.ai.ok ? result.ai : null;
66
+ const rows = summaryRows(result);
67
+ const lines = [
68
+ "# 日报汇总 " + result.range.start + " ~ " + result.range.end,
69
+ "",
70
+ "- Git 提交:" + result.stats.commits + " 条",
71
+ "- 文档:" + result.stats.docs + " 份",
72
+ "- 有记录天数:" + result.stats.activeDays + " 天",
73
+ "- 数据源:" + result.stats.okSources + "/" + result.stats.sources + " 个正常",
74
+ "",
75
+ ];
76
+ if (ai) {
77
+ lines.push("## 总体 AI 总结", "", ai.overall || "—", "");
78
+ } else if (result.ai && result.ai.error) {
79
+ lines.push("> AI 分析未完成:" + result.ai.error, "");
80
+ }
81
+ lines.push(
82
+ "## 每日汇总",
83
+ "",
84
+ "| 日期 | 星期 | 类型 | Git提交 | 文档 | 涉及仓库/文档 | 当日摘要 | 手动补充 | AI 分析 |",
85
+ "|---|---|---|---:|---:|---|---|---|---|",
86
+ );
87
+ for (const [date, weekday, dayType, commitCount, docCount, sources, summary, manual, aiSummary] of rows) {
88
+ lines.push(
89
+ "| " +
90
+ [date, weekday, dayType, commitCount, docCount, sources, summary, manual, aiSummary]
91
+ .map(markdownCell)
92
+ .join(" | ") +
93
+ " |",
94
+ );
95
+ }
96
+ if (result.commits.length > 0) {
97
+ lines.push("", "## Git 明细", "", "| 日期 | 仓库 | 分支 | 提交 | 作者 | 说明 |", "|---|---|---|---|---|---|");
98
+ for (const commit of result.commits) {
99
+ lines.push(
100
+ "| " +
101
+ [commit.date, commit.repo, commit.branch, commit.shortHash, commit.author, commit.subject]
102
+ .map(markdownCell)
103
+ .join(" | ") +
104
+ " |",
105
+ );
106
+ }
107
+ }
108
+ if (result.docs.length > 0) {
109
+ lines.push("", "## 文档明细", "", "| 日期 | 来源 | 文件 | 标题 | 摘要 |", "|---|---|---|---|---|");
110
+ for (const doc of result.docs) {
111
+ lines.push("| " + [doc.date, doc.source, doc.file, doc.title, doc.excerpt].map(markdownCell).join(" | ") + " |");
112
+ }
113
+ }
114
+ return lines.join("\n") + "\n";
115
+ }
116
+
117
+ export function buildCsvReport(result) {
118
+ const header = ["日期", "星期", "日类型", "Git提交", "文档", "涉及仓库/文档", "当日摘要", "手动补充", "AI 分析"];
119
+ const rows = summaryRows(result);
120
+ return "\ufeff" + [header, ...rows].map((row) => row.map(csvCell).join(",")).join("\r\n") + "\r\n";
121
+ }
122
+
123
+ export function buildHtmlReport(result) {
124
+ const ai = result.ai && result.ai.ok ? result.ai : null;
125
+ const rows = summaryRows(result);
126
+ const tableRows = rows
127
+ .map(
128
+ (row) =>
129
+ "<tr>" +
130
+ row
131
+ .map((value, index) => {
132
+ const cls = index >= 3 && index <= 4 ? ' class="num"' : "";
133
+ return "<td" + cls + ">" + htmlEscape(value) + "</td>";
134
+ })
135
+ .join("") +
136
+ "</tr>",
137
+ )
138
+ .join("\n");
139
+ const gitRows = result.commits
140
+ .map(
141
+ (commit) =>
142
+ "<tr>" +
143
+ [commit.date, commit.repo, commit.branch, commit.shortHash, commit.author, commit.subject, commit.fileCount]
144
+ .map((value) => "<td>" + htmlEscape(value) + "</td>")
145
+ .join("") +
146
+ "</tr>",
147
+ )
148
+ .join("\n");
149
+ return `<!doctype html>
150
+ <html lang="zh-CN">
151
+ <head>
152
+ <meta charset="utf-8">
153
+ <meta name="viewport" content="width=device-width, initial-scale=1">
154
+ <title>日报汇总 ${htmlEscape(result.range.start)} ~ ${htmlEscape(result.range.end)}</title>
155
+ <style>
156
+ :root { color-scheme: light dark; }
157
+ body { margin: 0; padding: 32px; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; background: #f5f7fa; color: #1f2933; }
158
+ main { max-width: 1400px; margin: 0 auto; background: #fff; border-radius: 12px; padding: 28px 32px; box-shadow: 0 2px 16px rgba(0,0,0,.08); }
159
+ h1 { margin: 0 0 8px; font-size: 26px; }
160
+ .meta { color: #617080; font-size: 13px; margin-bottom: 18px; }
161
+ .stats { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0 22px; }
162
+ .stat { border: 1px solid #d9e1ea; border-radius: 8px; padding: 8px 12px; min-width: 110px; }
163
+ .stat b { display: block; font-size: 20px; }
164
+ .stat span { color: #617080; font-size: 12px; }
165
+ h2 { margin: 26px 0 10px; font-size: 18px; }
166
+ p { line-height: 1.75; margin: 0 0 12px; white-space: pre-wrap; }
167
+ table { width: 100%; border-collapse: collapse; font-size: 12px; }
168
+ th, td { border: 1px solid #d9e1ea; padding: 6px 8px; text-align: left; vertical-align: top; }
169
+ th { background: #1f4e79; color: #fff; font-weight: 600; }
170
+ tr:nth-child(even) td { background: #f7f9fc; }
171
+ td.num { text-align: center; white-space: nowrap; }
172
+ .note { color: #617080; font-size: 12px; }
173
+ @media print { body { padding: 0; background: #fff; } main { box-shadow: none; border-radius: 0; } }
174
+ </style>
175
+ </head>
176
+ <body>
177
+ <main>
178
+ <h1>日报汇总 ${htmlEscape(result.range.start)} ~ ${htmlEscape(result.range.end)}</h1>
179
+ <div class="meta">Git 提交 ${result.stats.commits} 条 · 文档 ${result.stats.docs} 份 · 有记录 ${result.stats.activeDays} 天 · 数据源 ${result.stats.okSources}/${result.stats.sources} 个正常</div>
180
+ ${ai ? "<h2>总体 AI 总结</h2><p>" + htmlEscape(ai.overall || "—") + "</p>" : result.ai && result.ai.error ? '<p class="note">AI 分析未完成:' + htmlEscape(result.ai.error) + "</p>" : ""}
181
+ <h2>每日汇总</h2>
182
+ <table>
183
+ <thead><tr><th>日期</th><th>星期</th><th>类型</th><th>Git提交</th><th>文档</th><th>涉及仓库/文档</th><th>当日摘要</th><th>手动补充</th><th>AI 分析</th></tr></thead>
184
+ <tbody>${tableRows}</tbody>
185
+ </table>
186
+ ${gitRows ? "<h2>Git 明细</h2><table><thead><tr><th>日期</th><th>仓库</th><th>分支</th><th>提交</th><th>作者</th><th>说明</th><th>变更文件数</th></tr></thead><tbody>" + gitRows + "</tbody></table>" : ""}
187
+ </main>
188
+ </body>
189
+ </html>`;
190
+ }
package/lib/report.js CHANGED
@@ -25,8 +25,9 @@ function commitFileText(commit) {
25
25
  return text;
26
26
  }
27
27
 
28
- export function reportFileName(start, end) {
29
- return "日报汇总_" + start + "_" + end + ".xlsx";
28
+ export function reportFileName(start, end, format = "xlsx") {
29
+ const extension = format === "markdown" ? "md" : format === "html" || format === "csv" ? format : "xlsx";
30
+ return "日报汇总_" + start + "_" + end + "." + extension;
30
31
  }
31
32
 
32
33
  export function summarizeReport(result) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-daily-report",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "按天汇总多个 Git 仓库、分支与本地文档,支持 AI 分析并导出 XLSX 日报。",
5
5
  "license": "MIT",
6
6
  "type": "module",