dsh-ppt 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -3,6 +3,99 @@ window.__ModuleLoader__.load({ id: 'dsh-ppt', factory: require => {
3
3
  const React = require('react');
4
4
  const { useState, useEffect, useRef, useMemo } = React;
5
5
  const h = React.createElement;
6
+
7
+ // Interface language follows DSH; user content and stored records keep their original text.
8
+ let localeService;
9
+ const EN = Object.freeze({
10
+ "标题": "Title",
11
+ "副标题": "Subtitle",
12
+ "眉题": "Kicker",
13
+ "正文": "Body text",
14
+ "要点(每行一条)": "Bullets (one per line)",
15
+ "演讲备注": "Speaker notes",
16
+ "无法读取服务响应,请检查插件是否加载": "Could not read the server response. Check that the plugin is loaded.",
17
+ "操作未完成": "The operation did not complete.",
18
+ "正在处理,请稍候…": "Working, please wait…",
19
+ "操作失败": "Operation failed.",
20
+ "已定位到": "Focused ",
21
+ ",在字段中修改后保存。": ". Edit this field, then save.",
22
+ "点击预览里的文字,或在下方修改字段。": "Click text in the preview, or edit the fields below.",
23
+ "当前页已被删除,输入仍保留,请复制需要的内容后取消修改。": "This slide was deleted. Your input is preserved; copy anything you need before cancelling your edits.",
24
+ "已载入最新版本并保留你的修改,请核对后再保存。": "Latest version loaded with your edits preserved. Review before saving.",
25
+ "内容没有变化。": "No changes to save.",
26
+ "已保存,HTML、PPTX 和工程文件已同步更新。": "Saved. HTML, PPTX and project files were updated together.",
27
+ "页面顺序已更新,可撤销。": "Slides reordered. You can undo this change.",
28
+ "已撤销上次修改。": "Last change undone.",
29
+ "当前 PPTX 已生成逐页图片,请查看画面。": "Slide images were rendered from the current PPTX. Review each slide.",
30
+ "正在渲染最终 PPTX,较大文件可能需要几分钟…": "Rendering the final PPTX. Large files may take a few minutes…",
31
+ "已请求下载,请查看浏览器的下载列表。": "Download requested. Check your browser’s downloads.",
32
+ "请选择 8 MiB 以内的 PNG 或 JPEG 图片。": "Choose a PNG or JPEG image up to 8 MiB.",
33
+ "无法读取图片,请重试。": "Could not read the image. Try again.",
34
+ "演示文稿编辑": "Presentation editor",
35
+ "修改本插件生成的成品,保存时同步更新 HTML、可编辑 PPTX 和工程文件。": "Edit presentations generated by this plugin. Saving updates the HTML, editable PPTX and project file together.",
36
+ "载入最新版本,保留我的输入": "Load latest and keep my input",
37
+ "刷新项目": "Refresh projects",
38
+ "还没有可以编辑的项目": "No editable projects yet",
39
+ "先在对话中让助手生成演示文稿。已有项目可以让助手调用 ppt_check 检查一次,它会出现在这里。": "Ask the assistant to generate a presentation in chat. For an existing project, ask it to run ppt_check; the project will appear here.",
40
+ "示例:请把这份材料制作成可直接交付的演示文稿,生成 HTML、PPTX 和工程文件。": "Example: Turn this material into a presentation ready to deliver, including HTML, PPTX and project files.",
41
+ "返回项目": "Back to projects",
42
+ " 页 · 修订 ": " slides · Revision ",
43
+ "页面": "Slides",
44
+ "未命名页": "Untitled slide",
45
+ "上移": "Move up",
46
+ "下移": "Move down",
47
+ "HTML 预览": "HTML preview",
48
+ "最终 PPTX 预览": "Final PPTX preview",
49
+ "渲染当前 PPTX": "Render current PPTX",
50
+ "演示文稿 HTML 预览": "Presentation HTML preview",
51
+ "此项目尚无 HTML,请保存一次生成预览。": "This project has no HTML yet. Save to generate a preview.",
52
+ "最终 PPTX 第 ": "Final PPTX slide ",
53
+ " 页渲染结果": " rendered image",
54
+ "正在载入当前页渲染…": "Loading the rendered slide…",
55
+ "这是 HTML 放映预览。最终 PPTX 的字体和排版请用上方渲染检查。点击文字可定位编辑字段。": "This is the HTML playback preview. Use the render controls above to check fonts and layout in the final PPTX. Click text to focus its editing field.",
56
+ "这是当前 PPTX 文件的实际渲染图片,仍需检查每页画面;PowerPoint / WPS 可能因字体环境而不同。": "This image is rendered from the current PPTX. Review every slide; PowerPoint / WPS may display it differently depending on installed fonts.",
57
+ "渲染引擎报告缺失字体:": "The renderer reported missing fonts: ",
58
+ "。请替换为目标电脑可用字体后重新渲染。": ". Choose fonts available on the target computer and render again.",
59
+ "编辑第 ": "Edit slide ",
60
+ " 页": "",
61
+ "有未保存修改,请先保存或取消,再切换页面。": "You have unsaved edits. Save or cancel before switching slides.",
62
+ "这些字段也被修改过,请比较后决定是否保存:": "These fields were changed elsewhere. Compare them before saving:",
63
+ "最新版本:": "Latest version: ",
64
+ "你的修改:": "Your edits: ",
65
+ "替换图片(PNG / JPEG)": "Replace image (PNG / JPEG)",
66
+ "添加图片(会改为图文布局)": "Add image (changes to an image-and-text layout)",
67
+ "当前图片": "Current image",
68
+ "已选择:": "Selected: ",
69
+ "图片说明": "Image description",
70
+ "图片适应方式": "Image fit",
71
+ "完整显示": "Show complete image",
72
+ "填满并裁切": "Fill and crop",
73
+ "保存此页": "Save slide",
74
+ "取消修改": "Cancel edits",
75
+ "撤销上次修改": "Undo last change",
76
+ "质量提醒(": "Quality findings (",
77
+ "第 ": "Slide ",
78
+ " 页:": ": ",
79
+ "下载最新 PPTX": "Download latest PPTX",
80
+ "下载 HTML": "Download HTML",
81
+ "备份工程": "Back up project",
82
+ "演示文稿": "Presentations"
83
+ });
84
+ function languageOf(locale = localeService) {
85
+ try { return String(locale?.getSnapshot?.().active || '').startsWith('en') ? 'en' : 'zh'; } catch { return 'zh'; }
86
+ }
87
+ function t(zh) { return languageOf() === 'en' ? (EN[zh] ?? zh) : zh; }
88
+ function englishError(message, status) {
89
+ if (status === 401) return 'Your session has expired. Reopen DSH and try again.';
90
+ if (status === 404) return 'The presentation workbench is unavailable. Check that the plugin is loaded and restart DSH.';
91
+ if (status === 409) return 'The project changed. Load the latest version and review your edits before saving.';
92
+ return /[\u3400-\u9fff]/.test(message || '') ? 'The operation failed. Check the project files and DSH logs, then retry.' : message;
93
+ }
94
+ function useLanguage(locale) {
95
+ const [, rerender] = useState(0);
96
+ useEffect(() => { const update = () => rerender(n => n + 1); update(); return locale?.subscribe?.(update); }, [locale]);
97
+ }
98
+
6
99
  const ROUTE = 'api/dsh-ppt/workbench';
7
100
  const fields = [['title', '标题'], ['subtitle', '副标题'], ['kicker', '眉题'], ['text', '正文'], ['bullets', '要点(每行一条)'], ['notes', '演讲备注']];
8
101
  const css = `
@@ -34,8 +127,8 @@ window.__ModuleLoader__.load({ id: 'dsh-ppt', factory: require => {
34
127
  const response = await fetch(new URL(ROUTE, document.baseURI), { method: 'POST', credentials: 'same-origin', signal,
35
128
  headers: { 'content-type': 'application/json', 'x-dsh-ppt-action': '1' }, body: JSON.stringify(body) });
36
129
  let value;
37
- try { value = await response.json(); } catch { throw new Error('无法读取服务响应,请检查插件是否加载') }
38
- if (!response.ok || value.ok === false) { const error = new Error((value.message || '操作未完成') + (value.installationHint ? '\n' + value.installationHint : '')); error.status = response.status; throw error; }
130
+ try { value = await response.json(); } catch { throw new Error(t("无法读取服务响应,请检查插件是否加载")) }
131
+ if (!response.ok || value.ok === false) { const localized = languageOf() === 'en' ? value.messageEn || englishError(value.message, response.status) : value.message; const hint = languageOf() === 'en' ? value.installationHintEn : value.installationHint; const error = new Error((localized || t("操作未完成")) + (hint ? '\n' + hint : '')); error.status = response.status; throw error; }
39
132
  return value;
40
133
  }
41
134
  function draftFor(slide) {
@@ -57,7 +150,8 @@ window.__ModuleLoader__.load({ id: 'dsh-ppt', factory: require => {
57
150
  // Presenter code contains a quoted </body>; only append at the document end.
58
151
  return html.replace(/<head>/i, '<head>' + policy).replace(/<\/body>(\s*<\/html>\s*)$/i, bridge + '</body>$1');
59
152
  }
60
- function Workbench() {
153
+ function Workbench({ locale } = {}) {
154
+ useLanguage(locale);
61
155
  const [projects, setProjects] = useState([]), [project, setProject] = useState(null), [selected, setSelected] = useState('');
62
156
  const [draft, setDraft] = useState({}), [image, setImage] = useState(null), [dirty, setDirty] = useState(false);
63
157
  const [busy, setBusy] = useState(false), [job, setJob] = useState(''), [message, setMessage] = useState(''), [error, setError] = useState('');
@@ -74,11 +168,11 @@ window.__ModuleLoader__.load({ id: 'dsh-ppt', factory: require => {
74
168
  setSelected(row.id); setDraft(draftFor(row)); setImage(null); setDirty(false); setRaster(''); setOverlaps([]);
75
169
  if (!next.render) setMode('html');
76
170
  }
77
- async function perform(work, success, label = '正在处理,请稍候…') {
171
+ async function perform(work, success, label = t("正在处理,请稍候…")) {
78
172
  setBusy(true); setJob(label); setError(''); setMessage(''); setConflict(false);
79
173
  const controller = new AbortController(); operations.current.add(controller);
80
174
  try { const result = await work(controller.signal); if (alive.current && success) success(result); }
81
- catch (e) { if (alive.current) { setError(e.message || '操作失败'); setConflict(e.status === 409); } }
175
+ catch (e) { if (alive.current) { setError(e.message || t("操作失败")); setConflict(e.status === 409); } }
82
176
  finally { operations.current.delete(controller); if (alive.current) setBusy(false); }
83
177
  }
84
178
  function refreshList() { return perform(signal => action({ operation: 'list' }, signal), value => setProjects(value.projects)); }
@@ -92,7 +186,7 @@ window.__ModuleLoader__.load({ id: 'dsh-ppt', factory: require => {
92
186
  if (event.source !== frame.current?.contentWindow || event.data?.nonce !== nonce.current || event.data.type !== 'dsh-ppt-select' || mode !== 'html') return;
93
187
  const key = event.data.field;
94
188
  const field = fields.find(([field]) => field === key);
95
- if (field) { fieldRefs.current[key]?.focus(); fieldRefs.current[key]?.scrollIntoView({ block: 'nearest' }); setMessage('已定位到' + field[1] + ',在字段中修改后保存。'); }
189
+ if (field) { fieldRefs.current[key]?.focus(); fieldRefs.current[key]?.scrollIntoView({ block: 'nearest' }); setMessage(t("已定位到") + t(field[1]) + t(",在字段中修改后保存。")); }
96
190
  }
97
191
  window.addEventListener('message', receive); return () => window.removeEventListener('message', receive);
98
192
  }, [mode]);
@@ -118,16 +212,16 @@ window.__ModuleLoader__.load({ id: 'dsh-ppt', factory: require => {
118
212
  action({ operation: 'page', id: project.id, page: index + 1 }, abort.signal).then(value => setRaster(value.data)).catch(e => { if (!abort.signal.aborted) setError(e.message) });
119
213
  return () => abort.abort();
120
214
  }, [mode, index, project?.revision]);
121
- function open(id) { return perform(signal => action({ operation: 'open', id }, signal), value => { adopt(value.project); setMessage('点击预览里的文字,或在下方修改字段。'); }); }
215
+ function open(id) { return perform(signal => action({ operation: 'open', id }, signal), value => { adopt(value.project); setMessage(t("点击预览里的文字,或在下方修改字段。")); }); }
122
216
  function keepInputsAndReload() {
123
217
  const original = draftFor(slide), keys = fields.filter(([key]) => draft[key] !== original[key]).map(([key]) => key), pending = { ...draft }, pendingImage = image;
124
218
  return perform(signal => action({ operation: 'open', id: project.id }, signal), value => {
125
219
  const row = value.project.slides.find(s => s.id === selected);
126
- if (!row) { setError('当前页已被删除,输入仍保留,请复制需要的内容后取消修改。'); return; }
220
+ if (!row) { setError(t("当前页已被删除,输入仍保留,请复制需要的内容后取消修改。")); return; }
127
221
  const latest = draftFor(row), merged = { ...latest }, shared = [];
128
222
  for (const key of keys) { merged[key] = pending[key]; if (latest[key] !== original[key] && latest[key] !== pending[key]) shared.push({ key, latest: latest[key], yours: pending[key] }); }
129
223
  adopt(value.project, selected); setDraft(merged); setImage(pendingImage); setDirty(keys.length > 0 || !!pendingImage); setOverlaps(shared);
130
- setMessage('已载入最新版本并保留你的修改,请核对后再保存。');
224
+ setMessage(t("已载入最新版本并保留你的修改,请核对后再保存。"));
131
225
  });
132
226
  }
133
227
  function choose(row) { if (dirty || busy) return; setSelected(row.id); setDraft(draftFor(row)); setImage(null); }
@@ -136,16 +230,16 @@ window.__ModuleLoader__.load({ id: 'dsh-ppt', factory: require => {
136
230
  for (const [key] of fields) if (draft[key] !== original[key]) patch[key] = key === 'bullets' ? draft[key].split('\n').map(v => v.trim()).filter(Boolean) : draft[key];
137
231
  if (image) patch.image = { src: image.src, alt: image.alt, fit: image.fit, caption: image.caption };
138
232
  return perform(signal => action({ operation: 'edit', id: project.id, expectedRevision: project.revision, slide: slide.id, patch }, signal), value => {
139
- adopt(value.project, slide.id); setMessage(value.changed === false ? '内容没有变化。' : '已保存,HTML、PPTX 和工程文件已同步更新。');
233
+ adopt(value.project, slide.id); setMessage(value.changed === false ? t("内容没有变化。") : t("已保存,HTML、PPTX 和工程文件已同步更新。"));
140
234
  });
141
235
  }
142
236
  function move(delta) {
143
237
  const order = project.slides.map(s => s.id); [order[index], order[index + delta]] = [order[index + delta], order[index]];
144
- return perform(signal => action({ operation: 'edit', id: project.id, expectedRevision: project.revision, order }, signal), value => { adopt(value.project, slide.id); setMessage('页面顺序已更新,可撤销。'); });
238
+ return perform(signal => action({ operation: 'edit', id: project.id, expectedRevision: project.revision, order }, signal), value => { adopt(value.project, slide.id); setMessage(t("页面顺序已更新,可撤销。")); });
145
239
  }
146
- function undo() { return perform(signal => action({ operation: 'undo', id: project.id, expectedRevision: project.revision }, signal), value => { adopt(value.project, selected); setMessage('已撤销上次修改。'); }); }
240
+ function undo() { return perform(signal => action({ operation: 'undo', id: project.id, expectedRevision: project.revision }, signal), value => { adopt(value.project, selected); setMessage(t("已撤销上次修改。")); }); }
147
241
  function render() {
148
- return perform(signal => action({ operation: 'render', id: project.id, expectedRevision: project.revision }, signal), value => { adopt(value.project, selected); setMode('pptx'); setMessage('当前 PPTX 已生成逐页图片,请查看画面。'); }, '正在渲染最终 PPTX,较大文件可能需要几分钟…');
242
+ return perform(signal => action({ operation: 'render', id: project.id, expectedRevision: project.revision }, signal), value => { adopt(value.project, selected); setMode('pptx'); setMessage(t("当前 PPTX 已生成逐页图片,请查看画面。")); }, t("正在渲染最终 PPTX,较大文件可能需要几分钟…"));
149
243
  }
150
244
  function download(kind) {
151
245
  const url = new URL('api/dsh-ppt/download', document.baseURI);
@@ -154,56 +248,57 @@ window.__ModuleLoader__.load({ id: 'dsh-ppt', factory: require => {
154
248
  // works in WebViews whose native download handler cannot read blob URLs.
155
249
  const link = document.createElement('a'); link.href = url.href; link.download = '';
156
250
  link.hidden = true; document.body.append(link); link.click(); link.remove();
157
- setMessage('已请求下载,请查看浏览器的下载列表。');
251
+ setMessage(t("已请求下载,请查看浏览器的下载列表。"));
158
252
  }
159
253
  function upload(event) {
160
254
  const file = event.target.files?.[0]; event.target.value = ''; if (!file) return;
161
- if (!['image/png', 'image/jpeg'].includes(file.type) || file.size > 8 * 1024 * 1024) { setError('请选择 8 MiB 以内的 PNG 或 JPEG 图片。'); return; }
255
+ if (!['image/png', 'image/jpeg'].includes(file.type) || file.size > 8 * 1024 * 1024) { setError(t("请选择 8 MiB 以内的 PNG 或 JPEG 图片。")); return; }
162
256
  const reader = new FileReader();
163
257
  reader.onload = () => { if (!alive.current) return; setImage({ src: String(reader.result), alt: slide.image?.alt || file.name.replace(/\.[^.]+$/, ''), caption: slide.image?.caption || '', fit: slide.image?.fit || 'contain', name: file.name }); setDirty(true); setError(''); };
164
- reader.onerror = () => setError('无法读取图片,请重试。'); reader.readAsDataURL(file);
258
+ reader.onerror = () => setError(t("无法读取图片,请重试。")); reader.readAsDataURL(file);
165
259
  }
166
260
  const currentImage = image?.src || (slide?.image?.assetId && project.assets[slide.image.assetId]?.data);
167
261
  return h('div', { className: 'pptw' }, h('style', null, css),
168
- h('h2', null, '演示文稿编辑'), h('p', { className: 'muted' }, '修改本插件生成的成品,保存时同步更新 HTML、可编辑 PPTX 和工程文件。'),
262
+ h('h2', null, t("演示文稿编辑")), h('p', { className: 'muted' }, t("修改本插件生成的成品,保存时同步更新 HTML、可编辑 PPTX 和工程文件。")),
169
263
  error && h('div', { role: 'alert', className: 'notice error' }, error),
170
- conflict && project && h('button', { disabled: busy, onClick: keepInputsAndReload }, '载入最新版本,保留我的输入'),
264
+ conflict && project && h('button', { disabled: busy, onClick: keepInputsAndReload }, t("载入最新版本,保留我的输入")),
171
265
  message && h('div', { role: 'status', className: 'notice' }, message),
172
266
  busy && h('div', { role: 'status', className: 'muted' }, job),
173
267
  !project ? h(React.Fragment, null,
174
- h('div', { className: 'tools' }, h('button', { onClick: refreshList, disabled: busy }, '刷新项目')),
268
+ h('div', { className: 'tools' }, h('button', { onClick: refreshList, disabled: busy }, t("刷新项目"))),
175
269
  projects.length ? h('div', { className: 'project-list' }, projects.map(row => h('button', { key: row.id, onClick: () => open(row.id), disabled: busy }, h('strong', null, row.title), h('div', { className: 'file muted' }, row.path)))) :
176
- h('div', { className: 'card' }, h('h3', null, '还没有可以编辑的项目'), h('p', null, '先在对话中让助手生成演示文稿。已有项目可以让助手调用 ppt_check 检查一次,它会出现在这里。'), h('p', { className: 'muted' }, '示例:请把这份材料制作成可直接交付的演示文稿,生成 HTML、PPTX 和工程文件。'))
270
+ h('div', { className: 'card' }, h('h3', null, t("还没有可以编辑的项目")), h('p', null, t("先在对话中让助手生成演示文稿。已有项目可以让助手调用 ppt_check 检查一次,它会出现在这里。")), h('p', { className: 'muted' }, t("示例:请把这份材料制作成可直接交付的演示文稿,生成 HTML、PPTX 和工程文件。")))
177
271
  ) : h(React.Fragment, null,
178
- h('div', { className: 'tools' }, h('button', { disabled: busy || dirty, onClick: () => { setProject(null); refreshList(); } }, '返回项目'), h('strong', null, project.title), h('span', { className: 'muted' }, project.slides.length + ' 页 · 修订 ' + project.revision)),
272
+ h('div', { className: 'tools' }, h('button', { disabled: busy || dirty, onClick: () => { setProject(null); refreshList(); } }, t("返回项目")), h('strong', null, project.title), h('span', { className: 'muted' }, project.slides.length + t(" 页 · 修订 ") + project.revision)),
179
273
  h('div', { className: 'layout' },
180
- h('div', null, h('h3', null, '页面'), h('div', { className: 'pages' }, project.slides.map((row, i) => h('button', { key: row.id, 'aria-current': row.id === selected ? 'true' : undefined, disabled: busy || dirty, onClick: () => choose(row) }, (i + 1) + '. ' + (row.title || '未命名页')))),
181
- h('div', { className: 'tools' }, h('button', { disabled: busy || dirty || index === 0, onClick: () => move(-1) }, '上移'), h('button', { disabled: busy || dirty || index === project.slides.length - 1, onClick: () => move(1) }, '下移'))),
274
+ h('div', null, h('h3', null, t("页面")), h('div', { className: 'pages' }, project.slides.map((row, i) => h('button', { key: row.id, 'aria-current': row.id === selected ? 'true' : undefined, disabled: busy || dirty, onClick: () => choose(row) }, (i + 1) + '. ' + (row.title || t("未命名页"))))),
275
+ h('div', { className: 'tools' }, h('button', { disabled: busy || dirty || index === 0, onClick: () => move(-1) }, t("上移")), h('button', { disabled: busy || dirty || index === project.slides.length - 1, onClick: () => move(1) }, t("下移")))),
182
276
  h('div', null,
183
- h('div', { className: 'tools' }, h('button', { disabled: busy, 'aria-pressed': mode === 'html', onClick: () => setMode('html') }, 'HTML 预览'),
184
- h('button', { disabled: busy || !project.render, 'aria-pressed': mode === 'pptx', onClick: () => setMode('pptx') }, '最终 PPTX 预览'),
185
- h('button', { disabled: busy || dirty, onClick: render }, '渲染当前 PPTX')),
186
- h('div', { className: 'preview', ref: previewBox }, mode === 'html' ? (project.html ? h('iframe', { ref: frame, title: '演示文稿 HTML 预览', sandbox: 'allow-scripts', srcDoc: previewDocument, onLoad: jump }) : h('p', null, '此项目尚无 HTML,请保存一次生成预览。')) : raster ? h('img', { className: 'render-page', src: raster, alt: '最终 PPTX 第 ' + (index + 1) + ' 页渲染结果' }) : h('p', null, '正在载入当前页渲染…')),
187
- h('p', { className: 'muted' }, mode === 'html' ? '这是 HTML 放映预览。最终 PPTX 的字体和排版请用上方渲染检查。点击文字可定位编辑字段。' : '这是当前 PPTX 文件的实际渲染图片,仍需检查每页画面;PowerPoint / WPS 可能因字体环境而不同。'),
188
- project.render?.missingFonts?.length ? h('div', { role: 'alert', className: 'notice error' }, '渲染引擎报告缺失字体:' + project.render.missingFonts.join('、') + '。请替换为目标电脑可用字体后重新渲染。') : null,
189
- h('div', { className: 'card' }, h('h3', null, '编辑第 ' + (index + 1) + ' 页'),
190
- dirty && h('p', { className: 'dirty' }, '有未保存修改,请先保存或取消,再切换页面。'),
191
- overlaps.length ? h('div', { className: 'notice' }, h('strong', null, '这些字段也被修改过,请比较后决定是否保存:'), overlaps.map(row => h('div', { key: row.key }, h('strong', null, fields.find(([key]) => key === row.key)[1]), h('p', null, '最新版本:' + row.latest), h('p', null, '你的修改:' + row.yours)))) : null,
192
- fields.filter(([key]) => key === 'title' || key === 'notes' || key === 'bullets' && (slide.layout === 'bullets' || slide.bullets?.length > 0) || key !== 'bullets' && (slide[key] || key === 'subtitle' && ['cover','section','closing','quote'].includes(slide.layout) || key === 'kicker' && ['cover','section','closing'].includes(slide.layout))).map(([key, label]) => h('div', { key }, h('label', { htmlFor: 'pptw-' + key }, label), h(key === 'title' || key === 'kicker' ? 'input' : 'textarea', { id: 'pptw-' + key, ref: el => { fieldRefs.current[key] = el }, disabled: busy, value: draft[key] || '', rows: key === 'bullets' ? 4 : 2, maxLength: 8000, onChange: e => { setDraft({ ...draft, [key]: e.target.value }); setDirty(true); } }))),
193
- h('label', { htmlFor: 'pptw-image' }, slide.image ? '替换图片(PNG / JPEG)' : '添加图片(会改为图文布局)'),
277
+ h('div', { className: 'tools' }, h('button', { disabled: busy, 'aria-pressed': mode === 'html', onClick: () => setMode('html') }, t("HTML 预览")),
278
+ h('button', { disabled: busy || !project.render, 'aria-pressed': mode === 'pptx', onClick: () => setMode('pptx') }, t("最终 PPTX 预览")),
279
+ h('button', { disabled: busy || dirty, onClick: render }, t("渲染当前 PPTX"))),
280
+ h('div', { className: 'preview', ref: previewBox }, mode === 'html' ? (project.html ? h('iframe', { ref: frame, title: t("演示文稿 HTML 预览"), sandbox: 'allow-scripts', srcDoc: previewDocument, onLoad: jump }) : h('p', null, t("此项目尚无 HTML,请保存一次生成预览。"))) : raster ? h('img', { className: 'render-page', src: raster, alt: t("最终 PPTX 第 ") + (index + 1) + t(" 页渲染结果") }) : h('p', null, t("正在载入当前页渲染…"))),
281
+ h('p', { className: 'muted' }, mode === 'html' ? t("这是 HTML 放映预览。最终 PPTX 的字体和排版请用上方渲染检查。点击文字可定位编辑字段。") : t("这是当前 PPTX 文件的实际渲染图片,仍需检查每页画面;PowerPoint / WPS 可能因字体环境而不同。")),
282
+ project.render?.missingFonts?.length ? h('div', { role: 'alert', className: 'notice error' }, t("渲染引擎报告缺失字体:") + project.render.missingFonts.join('、') + t("。请替换为目标电脑可用字体后重新渲染。")) : null,
283
+ h('div', { className: 'card' }, h('h3', null, t("编辑第 ") + (index + 1) + t(" 页")),
284
+ dirty && h('p', { className: 'dirty' }, t("有未保存修改,请先保存或取消,再切换页面。")),
285
+ overlaps.length ? h('div', { className: 'notice' }, h('strong', null, t("这些字段也被修改过,请比较后决定是否保存:")), overlaps.map(row => h('div', { key: row.key }, h('strong', null, t(fields.find(([key]) => key === row.key)[1])), h('p', null, t("最新版本:") + row.latest), h('p', null, t("你的修改:") + row.yours)))) : null,
286
+ fields.filter(([key]) => key === 'title' || key === 'notes' || key === 'bullets' && (slide.layout === 'bullets' || slide.bullets?.length > 0) || key !== 'bullets' && (slide[key] || key === 'subtitle' && ['cover','section','closing','quote'].includes(slide.layout) || key === 'kicker' && ['cover','section','closing'].includes(slide.layout))).map(([key, label]) => h('div', { key }, h('label', { htmlFor: 'pptw-' + key }, t(label)), h(key === 'title' || key === 'kicker' ? 'input' : 'textarea', { id: 'pptw-' + key, ref: el => { fieldRefs.current[key] = el }, disabled: busy, value: draft[key] || '', rows: key === 'bullets' ? 4 : 2, maxLength: 8000, onChange: e => { setDraft({ ...draft, [key]: e.target.value }); setDirty(true); } }))),
287
+ h('label', { htmlFor: 'pptw-image' }, slide.image ? t("替换图片(PNG / JPEG)") : t("添加图片(会改为图文布局)")),
194
288
  h('input', { id: 'pptw-image', type: 'file', accept: 'image/png,image/jpeg', disabled: busy, onChange: upload }),
195
- currentImage && h('img', { className: 'image-preview', src: currentImage, alt: image?.alt || slide.image?.alt || '当前图片' }),
196
- image && h(React.Fragment, null, h('p', { className: 'muted' }, '已选择:' + image.name), h('label', { htmlFor: 'pptw-alt' }, '图片说明'), h('input', { id: 'pptw-alt', value: image.alt, disabled: busy, onChange: e => setImage({ ...image, alt: e.target.value }) }), h('label', { htmlFor: 'pptw-fit' }, '图片适应方式'), h('select', { id: 'pptw-fit', value: image.fit, disabled: busy, onChange: e => setImage({ ...image, fit: e.target.value }) }, h('option', { value: 'contain' }, '完整显示'), h('option', { value: 'cover' }, '填满并裁切'))),
197
- h('div', { className: 'tools' }, h('button', { className: 'primary', disabled: busy || !dirty, onClick: save }, '保存此页'), h('button', { disabled: busy || !dirty, onClick: () => { setDraft(draftFor(slide)); setImage(null); setDirty(false); setError(''); setConflict(false); setOverlaps([]); } }, '取消修改'), h('button', { disabled: busy || dirty || !project.undoAvailable, onClick: undo }, '撤销上次修改'))),
198
- h('details', { className: 'card' }, h('summary', null, '质量提醒(' + project.quality.warningCount + ')'), (project.quality.issues || []).map((issue, i) => h('p', { key: i }, '第 ' + issue.slide + ' 页:' + issue.message + ';' + issue.suggestion))),
199
- h('div', { className: 'tools' }, h('button', { disabled: busy || dirty, onClick: () => download('pptx') }, '下载最新 PPTX'), h('button', { disabled: busy || dirty, onClick: () => download('html') }, '下载 HTML'), h('button', { disabled: busy || dirty, onClick: () => download('json') }, '备份工程'))
289
+ currentImage && h('img', { className: 'image-preview', src: currentImage, alt: image?.alt || slide.image?.alt || t("当前图片") }),
290
+ image && h(React.Fragment, null, h('p', { className: 'muted' }, t("已选择:") + image.name), h('label', { htmlFor: 'pptw-alt' }, t("图片说明")), h('input', { id: 'pptw-alt', value: image.alt, disabled: busy, onChange: e => setImage({ ...image, alt: e.target.value }) }), h('label', { htmlFor: 'pptw-fit' }, t("图片适应方式")), h('select', { id: 'pptw-fit', value: image.fit, disabled: busy, onChange: e => setImage({ ...image, fit: e.target.value }) }, h('option', { value: 'contain' }, t("完整显示")), h('option', { value: 'cover' }, t("填满并裁切")))),
291
+ h('div', { className: 'tools' }, h('button', { className: 'primary', disabled: busy || !dirty, onClick: save }, t("保存此页")), h('button', { disabled: busy || !dirty, onClick: () => { setDraft(draftFor(slide)); setImage(null); setDirty(false); setError(''); setConflict(false); setOverlaps([]); } }, t("取消修改")), h('button', { disabled: busy || dirty || !project.undoAvailable, onClick: undo }, t("撤销上次修改")))),
292
+ h('details', { className: 'card' }, h('summary', null, t("质量提醒(") + project.quality.warningCount + (languageOf() === 'en' ? ')' : ')')), (project.quality.issues || []).map((issue, i) => h('p', { key: i }, t("第 ") + issue.slide + t(" 页:") + (languageOf() === 'en' ? issue.messageEn : issue.message) + (languageOf() === 'en' ? ' ' : ';') + (languageOf() === 'en' ? issue.suggestionEn : issue.suggestion)))),
293
+ h('div', { className: 'tools' }, h('button', { disabled: busy || dirty, onClick: () => download('pptx') }, t("下载最新 PPTX")), h('button', { disabled: busy || dirty, onClick: () => download('html') }, t("下载 HTML")), h('button', { disabled: busy || dirty, onClick: () => download('json') }, t("备份工程")))
200
294
  )
201
295
  )
202
296
  )
203
297
  );
204
298
  }
205
299
  function apply(ctx) {
206
- ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'dsh-ppt', order: 58, label: () => '演示文稿', inject: () => ({}) }, Workbench));
300
+ try { localeService = ctx.get?.('locale') || ctx.locale; } catch { localeService = ctx.locale; }
301
+ ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'dsh-ppt', order: 58, label: () => t("演示文稿"), inject: () => ({ locale: localeService }) }, Workbench));
207
302
  }
208
- return { inject: ['slots'], apply, __internals: { draftFor, editorHtml } };
303
+ return { inject: ['slots', 'locale'], apply, __internals: { draftFor, editorHtml } };
209
304
  } });
@@ -0,0 +1,4 @@
1
+ export declare function englishWorkbenchMessage(message: string): string;
2
+ export declare function bilingualQuality<T extends {
3
+ issues?: any[];
4
+ }>(report: T): T;
@@ -0,0 +1,65 @@
1
+ /** UI messages are independent of the deck's language; slide content is never translated. */
2
+ const messages = {
3
+ '文件不是普通文件或超过 64 MiB': 'The file is not a regular file or exceeds 64 MiB.',
4
+ '项目索引超过 8 MiB,请备份后整理 projects.jsonl': 'The project index exceeds 8 MiB. Back it up before reviewing projects.jsonl.',
5
+ '项目索引已满,请备份后整理 projects.jsonl': 'The project index is full. Back it up before reviewing projects.jsonl.',
6
+ '请选择已有项目': 'Select an existing project.',
7
+ '项目不在本机索引中,请先在对话中创建或检查演示文稿': 'This project is not in the local index. Create it or run ppt_check in chat first.',
8
+ '项目位置已改变,请在对话中重新检查': 'The project location changed. Run ppt_check in chat again.',
9
+ '项目已被修改,请重新打开后再编辑;当前输入保留': 'The project changed. Load the latest version before editing; your input is preserved.',
10
+ '项目已被修改,请重新打开后下载': 'The project changed. Open the latest version before downloading.',
11
+ '修改内容无效': 'The edits are invalid.', '文字字段过长或无效': 'A text field is invalid or exceeds 8,000 characters.',
12
+ '要点内容无效': 'Bullets must contain up to 50 lines, each no longer than 2,000 characters.',
13
+ '请选择 PNG 或 JPEG 图片': 'Choose a PNG or JPEG image.',
14
+ '图片必须从面板上传,不能读取其他本地文件': 'Upload the image from this panel. Other local file paths cannot be read.',
15
+ '图片说明或适应方式无效': 'The image description or fit setting is invalid.',
16
+ '不支持此文件类型': 'This file type is unsupported.', '请先渲染当前版本的 PPTX': 'Render the current PPTX version first.',
17
+ '页码无效': 'The slide number is invalid.', '渲染图片已变化,请重新渲染当前 PPTX': 'The rendered image changed. Render the current PPTX again.',
18
+ '项目已被修改,请重新渲染': 'The project changed. Render it again.', '页面不存在': 'This slide no longer exists.',
19
+ '不支持此操作': 'This operation is unsupported.', '请使用 GET': 'Use GET.', '请使用 POST': 'Use POST.',
20
+ '拒绝跨站操作': 'Cross-site operations are refused.', '拒绝跨源操作': 'Cross-origin operations are refused.', '无效来源': 'Invalid request origin.',
21
+ '请从演示文稿面板操作': 'Perform this operation from the presentation panel.', '缺少请求内容': 'The request body is missing.',
22
+ '图片或请求超过 12 MiB,请压缩后重试': 'The image or request exceeds 12 MiB. Reduce its size and try again.', '请求必须是对象': 'The request must be an object.',
23
+ };
24
+ export function englishWorkbenchMessage(message) {
25
+ if (messages[message])
26
+ return messages[message];
27
+ if (/修订号冲突|已被修改|正在被另一次|其他操作修改/.test(message))
28
+ return 'The project changed or is busy. Load the latest version and review your edits before retrying.';
29
+ if (/渲染引擎不可用/.test(message))
30
+ return 'The optional PPTX renderer is unavailable. Install the supported LibreOffice Kit version and restart DSH.';
31
+ if (/渲染超过|超时/.test(message))
32
+ return 'PPTX rendering timed out. Try a smaller presentation and check the renderer logs.';
33
+ if (/面板不支持修改此字段/.test(message))
34
+ return 'This field cannot be edited from the panel.';
35
+ if (/没有可撤销|无法撤销|历史记录为空/.test(message))
36
+ return 'No change is available to undo.';
37
+ if (/没有.*图片|超过.*MiB|图片.*无效/.test(message))
38
+ return 'The image is invalid or too large. Choose another PNG or JPEG image.';
39
+ return /[\u3400-\u9fff]/.test(message) ? 'The operation failed. Check the project files and DSH logs, then try again.' : message;
40
+ }
41
+ const quality = {
42
+ 'missing-title': ['This slide has no title.', 'Add a title that explains the slide.'],
43
+ 'title-overflow': ['The title may wrap too much or exceed its area.', 'Shorten the title and move explanations into the body.'],
44
+ 'dense-bullets': ['This slide has too many bullets for comfortable reading.', 'Reduce the bullets or split them across slides.'],
45
+ 'body-overflow': ['The body may exceed the available height.', 'Shorten or split the text before reducing font size.'],
46
+ 'template-unfilled': ['Template placeholders remain.', 'Fill in actual facts, evidence and actions, then check again.'],
47
+ 'small-font': ['This table uses a small font and may be hard to read.', 'Reduce the columns or use a chart; check readability after export.'],
48
+ 'table-density': ['Long table cells may cause crowded wrapping.', 'Shorten the cells or reduce the columns.'],
49
+ 'missing-image': ['The embedded image is missing.', 'Edit the slide with a new image.'],
50
+ 'invalid-image': ['The embedded image is invalid.', 'Replace it with a valid local image.'],
51
+ 'low-resolution': ['The image may look blurry when enlarged.', 'Use a higher-resolution image. Small logos may be acceptable.'],
52
+ 'missing-alt': ['The image has no description.', 'Fill in image.alt for accessibility and context.'],
53
+ 'invalid-chart': ['The chart data is invalid.', 'Check the categories, series and numeric values.'],
54
+ 'chart-density': ['Chart categories or labels may overlap.', 'Reduce categories or use a horizontal bar chart.'],
55
+ 'chart-legend': ['Chart legend labels are long.', 'Shorten series names and check the render.'],
56
+ 'low-contrast': ['Body text and background contrast is below 4.5:1.', 'Adjust the brand background or text colour.'],
57
+ 'missing-logo': ['The brand logo asset is missing.', 'Provide the logo again.'],
58
+ 'invalid-logo': ['The brand logo asset is invalid.', 'Replace the logo with a valid image.'],
59
+ };
60
+ export function bilingualQuality(report) {
61
+ return { ...report, issues: report.issues?.map(issue => {
62
+ const entry = quality[issue.code];
63
+ return { ...issue, messageEn: entry?.[0] ?? englishWorkbenchMessage(issue.message || ''), suggestionEn: entry?.[1] ?? englishWorkbenchMessage(issue.suggestion || '') };
64
+ }) };
65
+ }
package/lib/workbench.js CHANGED
@@ -4,6 +4,7 @@ import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpat
4
4
  import { homedir } from 'node:os';
5
5
  import { basename, dirname, join, resolve } from 'node:path';
6
6
  import { getEngine } from './execution.js';
7
+ import { bilingualQuality, englishWorkbenchMessage } from './workbench-language.js';
7
8
  export const PPT_WORKBENCH_ROUTE = '/api/dsh-ppt/workbench';
8
9
  export const PPT_DOWNLOAD_ROUTE = '/api/dsh-ppt/download';
9
10
  const REQUEST_LIMIT = 12 * 1024 * 1024;
@@ -19,7 +20,7 @@ function pathsFor(path) { const base = path.slice(0, -5); return { json: path, h
19
20
  function json(status, value) {
20
21
  return new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } });
21
22
  }
22
- function fail(status, message) { return json(status, { ok: false, message }); }
23
+ function fail(status, message) { return json(status, { ok: false, message, messageEn: englishWorkbenchMessage(message) }); }
23
24
  function originVerdict(request) {
24
25
  const site = request.headers.get('sec-fetch-site'), origin = request.headers.get('origin');
25
26
  if (site && !['same-origin', 'none'].includes(site))
@@ -94,7 +95,7 @@ export class PptWorkbench {
94
95
  }
95
96
  async view(id) {
96
97
  const { entry, engine, manifest, files } = await this.project(id);
97
- const check = engine.checkDeck({ deckPath: entry.path });
98
+ const check = bilingualQuality(engine.checkDeck({ deckPath: entry.path }));
98
99
  let html = '';
99
100
  try {
100
101
  html = ordinaryFile(files.html).toString('utf8');
@@ -172,7 +173,10 @@ export class PptWorkbench {
172
173
  const view = await this.view(entry.id);
173
174
  if (result.ok && !view.project.render)
174
175
  throw new Error('项目已被修改,请重新渲染');
175
- return { ...result, project: view.project };
176
+ return { ...result, ...(result.ok ? {} : {
177
+ messageEn: englishWorkbenchMessage(result.message || 'PPTX rendering failed.'),
178
+ installationHintEn: 'Optional renderer: @deepseek-ai/libreoffice-kit@0.1.5. Install it in this DSH profile and restart DSH.',
179
+ }), project: view.project };
176
180
  }
177
181
  if (body.operation === 'edit') {
178
182
  if (body.slide !== undefined && (typeof body.slide !== 'string' || !manifest.slides.some((s) => s.id === body.slide)))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ppt",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "DSH 演示文稿插件:生成 HTML、可编辑 PPTX 和工程文件,支持图表、轻量编辑、撤销与质量检查。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -1,11 +1,11 @@
1
- {
2
- "name": "dsh-ppt-skill",
3
- "version": "0.7.0",
4
- "private": true,
5
- "type": "module",
6
- "description": "Standalone dsh-ppt skill dependencies for image/chart export",
7
- "dependencies": { "@office-kit/pptx": "0.21.0" },
8
- "peerDependencies": { "@deepseek-ai/libreoffice-kit": "0.1.3" },
9
- "peerDependenciesMeta": { "@deepseek-ai/libreoffice-kit": { "optional": true } },
10
- "engines": { "node": "^22.19.0 || >=24.0.0" }
11
- }
1
+ {
2
+ "name": "dsh-ppt-skill",
3
+ "version": "0.7.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Standalone dsh-ppt skill dependencies for image/chart export",
7
+ "dependencies": { "@office-kit/pptx": "0.21.0" },
8
+ "peerDependencies": { "@deepseek-ai/libreoffice-kit": "0.1.3" },
9
+ "peerDependenciesMeta": { "@deepseek-ai/libreoffice-kit": { "optional": true } },
10
+ "engines": { "node": "^22.19.0 || >=24.0.0" }
11
+ }
@@ -1,59 +1,59 @@
1
- # dsh-ppt 幻灯片写作规则(中英双语)
2
-
3
- ## 结构模板
4
-
5
- 任何 deck 都遵循同一叙事骨架:
6
-
7
- 1. **封面**:标题(判断句或名词短语 + 价值承诺)+ 副标题(一句话定位)。
8
- 2. **背景/问题**:现状 → 痛点 → 为什么现在。
9
- 3. **方案/论点**:每个论点一页,先结论后理由。
10
- 4. **证据/案例**:数字、对比、客户/数据点。
11
- 5. **行动号召**:希望听众下一步做什么。
12
- 6. **结束页**:谢谢 + 联系方式或 CTA 重复。
13
-
14
- 一句话输入的最小完整结构:封面 → 核心观点页 → 结束页。
15
-
16
- ## 单页规则
17
-
18
- - 一个页面只讲一个观点。
19
- - 标题是完整的判断句,例如「AI 客服把首响时间压缩到 8 秒」,而不是「关于客服」。
20
- - bullets 1–8 条,中文每条 ≤ 20 字,英文每条 ≤ 12 词。
21
- - 优先使用数字、对比、动词;避免「赋能、抓手、闭环」等空词。
22
- - 最后一页要给出可执行的 CTA,不是只写「谢谢」。
23
-
24
- ## 双语规则
25
-
26
- - 插件 `lang` 参数只控制界面(页码/标签/结束页默认文案);**内容双语由写作阶段完成**。
27
- - 双语 deck 推荐两种做法:
28
- 1. 同一页内:中文标题 + 英文副标题;bullets 中文为主,关键词括号英文。
29
- 2. 两份 deck:同一大纲分别以 `zh` 与 `en` 生成,主题保持一致。
30
- - 英文标题用 Title Case;术语首现给中文+英文,如「检索增强生成(RAG)」。
31
- - 双语时不要逐句直译,按目标语言习惯重写。
32
-
33
- ## Markdown 与结构化输入
34
-
35
- 解析规则、表格/金句/备注语法与结构化 `slides` JSON 见 `syntax.md`。
36
-
37
- ## 示例
38
-
39
- 输入:
40
-
41
- ```markdown
42
- # 让会议少一半
43
-
44
- 用异步决策替代同步例会。
45
-
46
- ## 问题
47
- - 工程师每周 6 小时在例会上
48
- - 决策没有记录,反复讨论
49
-
50
- ## 方案
51
- - 会前异步文档 + 24h 评论期
52
- - 只有分歧项才开会
53
-
54
- ## 效果
55
- - 周会从 4 次降到 1 次
56
- - 决策记录 100% 沉淀
57
- ```
58
-
59
- 产出:封面 → 问题 → 方案 → 效果 → 结束页,共 5 页,主题可选 `swiss`(数据/效率)或 `soft`(团队协作)。
1
+ # dsh-ppt 幻灯片写作规则(中英双语)
2
+
3
+ ## 结构模板
4
+
5
+ 任何 deck 都遵循同一叙事骨架:
6
+
7
+ 1. **封面**:标题(判断句或名词短语 + 价值承诺)+ 副标题(一句话定位)。
8
+ 2. **背景/问题**:现状 → 痛点 → 为什么现在。
9
+ 3. **方案/论点**:每个论点一页,先结论后理由。
10
+ 4. **证据/案例**:数字、对比、客户/数据点。
11
+ 5. **行动号召**:希望听众下一步做什么。
12
+ 6. **结束页**:谢谢 + 联系方式或 CTA 重复。
13
+
14
+ 一句话输入的最小完整结构:封面 → 核心观点页 → 结束页。
15
+
16
+ ## 单页规则
17
+
18
+ - 一个页面只讲一个观点。
19
+ - 标题是完整的判断句,例如「AI 客服把首响时间压缩到 8 秒」,而不是「关于客服」。
20
+ - bullets 1–8 条,中文每条 ≤ 20 字,英文每条 ≤ 12 词。
21
+ - 优先使用数字、对比、动词;避免「赋能、抓手、闭环」等空词。
22
+ - 最后一页要给出可执行的 CTA,不是只写「谢谢」。
23
+
24
+ ## 双语规则
25
+
26
+ - 插件 `lang` 参数只控制界面(页码/标签/结束页默认文案);**内容双语由写作阶段完成**。
27
+ - 双语 deck 推荐两种做法:
28
+ 1. 同一页内:中文标题 + 英文副标题;bullets 中文为主,关键词括号英文。
29
+ 2. 两份 deck:同一大纲分别以 `zh` 与 `en` 生成,主题保持一致。
30
+ - 英文标题用 Title Case;术语首现给中文+英文,如「检索增强生成(RAG)」。
31
+ - 双语时不要逐句直译,按目标语言习惯重写。
32
+
33
+ ## Markdown 与结构化输入
34
+
35
+ 解析规则、表格/金句/备注语法与结构化 `slides` JSON 见 `syntax.md`。
36
+
37
+ ## 示例
38
+
39
+ 输入:
40
+
41
+ ```markdown
42
+ # 让会议少一半
43
+
44
+ 用异步决策替代同步例会。
45
+
46
+ ## 问题
47
+ - 工程师每周 6 小时在例会上
48
+ - 决策没有记录,反复讨论
49
+
50
+ ## 方案
51
+ - 会前异步文档 + 24h 评论期
52
+ - 只有分歧项才开会
53
+
54
+ ## 效果
55
+ - 周会从 4 次降到 1 次
56
+ - 决策记录 100% 沉淀
57
+ ```
58
+
59
+ 产出:封面 → 问题 → 方案 → 效果 → 结束页,共 5 页,主题可选 `swiss`(数据/效率)或 `soft`(团队协作)。