.cube-actions button:hover{border-color:var(--blue);color:var(--blue)}
.cube-actions .trash{border-color:#f0c4bd;color:var(--red)}
.cube-actions .trash:hover{background:#fdeae6;border-color:var(--red)}
+/* 醒目 AI 操作按钮(渐变蓝紫) */
+.btn-ai{display:inline-flex;align-items:center;gap:4px;background:linear-gradient(135deg,#2563eb,#7c3aed);color:#fff;border:1px solid transparent;border-radius:5px;height:26px;padding:0 11px;font-size:11px;font-weight:600;cursor:pointer;box-shadow:0 1px 4px rgba(37,99,235,.35);white-space:nowrap}
+.btn-ai:hover{filter:brightness(1.08)}
+.btn-ai:active{transform:translateY(1px)}
+.btn-ai:disabled{opacity:.65;cursor:default;box-shadow:none}
+.cube-actions .btn-ai{height:26px}
.menu-editor{display:flex;flex-direction:column;gap:10px}
.menu-row{display:grid;grid-template-columns:1.4fr 1fr 1.4fr auto;gap:8px;align-items:center}
.menu-row input,.menu-row select{border:1px solid var(--line);border-radius:3px;height:30px;padding:0 8px;background:#fff;width:100%}
finally{ clearTimeout(watchdog); setCompleting(false); }
};
- return <Panel><div className="tabs"><button className={tab==="mu"?"active":""} onClick={()=>setTab("mu")}>MU 标准页面 <i>{effSelected.length}</i></button><button className={tab==="cube"?"active":""} onClick={()=>setTab("cube")}>Cube 扩展页面 <i>{site.cubes.length}</i></button><button className={tab==="menu"?"active":""} onClick={()=>setTab("menu")}>菜单结构</button></div>
+ return <Panel><div className="tabs"><button className={tab==="cube"?"active":""} onClick={()=>setTab("cube")}>领域扩展页面 <i>{site.cubes.length}</i></button><button className={tab==="mu"?"active":""} onClick={()=>setTab("mu")}>MU 标准页面 <i>{effSelected.length}</i></button><button className={tab==="menu"?"active":""} onClick={()=>setTab("menu")}>菜单结构</button></div>
{/* MU 标准页面:树状上下结构,仅保留勾选框,默认全选中 */}
{tab==="mu"&&<div className="mu-tree">{grouped.map(g=><div key={g.parentMenu} className="mu-group"><b>{g.parentMenu}</b><div className="mu-items">{g.items.map(item=><label key={item.key} className={item.disabled?"disabled":""}><input type="checkbox" disabled={item.disabled} checked={effSelected.includes(item.key)} onChange={()=>toggleMu(item.key)}/><span>{item.label}</span></label>)}</div></div>)}</div>}
{/* Cube 扩展页面:左右两栏,右栏为填写建议 + AI 智能补全 */}
<td><input value={cube.name} maxLength={6} placeholder="例如:运行总览" onChange={e=>updateCube(i,{name:e.target.value})}/></td>
<td><input value={cube.content} placeholder="描述该页面要展示的内容和指标" onChange={e=>updateCube(i,{content:e.target.value})}/></td>
<td>{cube.status==="generated"? <button className="link" onClick={()=>downloadCube(cube)}>下载 {cube.gen_filename}</button> : <span className="muted">未生成</span>}</td>
- <td className="cube-actions"><button onClick={()=>previewCube(cube)}>预览</button><button onClick={()=>generateCube(i)}>生成</button><button onClick={()=>saveCube(i)}>保存</button><button className="trash" onClick={()=>removeCube(i)}>删除</button></td>
+ <td className="cube-actions"><button onClick={()=>previewCube(cube)}>预览</button><button className="btn-ai" onClick={()=>generateCube(i)}>AI生成</button><button onClick={()=>saveCube(i)}>保存</button><button className="trash" onClick={()=>removeCube(i)}>删除</button></td>
</tr>)}</tbody></table><Button onClick={addCube}>+ 添加 Cube 页面</Button></div>
<div className="style-picker">
<h4>站点页面样式 <small>站点级 · 全部 Cube 页面共用</small></h4>
<div className="style-pick-thumb none">无</div><span>不使用样式</span>
</button>
{pageStyles.map(s=><button key={s.id} className={"style-pick"+(site.style_id===s.id?" active":"")} onClick={()=>selectStyle(s.id)}>
- <div className="style-pick-thumb"><img src={`/api/db/page-styles/${s.id}/image`} alt={s.name} loading="lazy"/></div>
+ <div className="style-pick-thumb"><img src={`/api/db/page-styles/${s.id}/image?w=360`} alt={s.name} loading="lazy"/></div>
<span>{s.name}</span>
</button>)}
</div>
}catch(e:any){ notify("生成失败:"+(e?.message||e)); }
finally{ setGenIds(prev=>{ const n=new Set(prev); n.delete(p.id); return n; }); await load(true); }
};
+ // 统一生成:对所有「未生成」且未在生成中的页面并发触发(每个页面独立上锁,后端按 AI_CONCURRENCY 排队),
+ // 各自按钮会随 genIds 自动显示「AI生成中」。
+ const generateAllPages=()=>{
+ const pending=pages.filter(p=>p.status!=="generated" && !genIds.has(p.id));
+ if(!pending.length){ notify("没有待生成的页面"); return; }
+ notify(`已开始排队生成 ${pending.length} 个页面…`);
+ for(const p of pending) generatePage(p);
+ };
const downloadPage=(p:DomainPage)=>{ const blob=new Blob([p.json_text||"{}"],{type:"application/json"}); downloadBlob(blob, p.gen_filename||`${p.name||"page"}.json`); };
if(loading||!t||!m) return <p>加载中…</p>;
{step==="functions" && <FunctionsStep
muTree={muTreeLocal} grouped={grouped} effSelected={effSelected} onToggleMu={toggleMu}
- pages={pages} onAddPage={addPage} onUploadPageJson={uploadPageJson} onSavePageMeta={savePageMeta} onDeletePage={deletePage} onGeneratePage={generatePage} onDownloadPage={downloadPage} onPreviewPage={setPreviewPage}
+ pages={pages} onAddPage={addPage} onUploadPageJson={uploadPageJson} onSavePageMeta={savePageMeta} onDeletePage={deletePage} onGeneratePage={generatePage} onDownloadPage={downloadPage} onPreviewPage={setPreviewPage} onGenerateAll={generateAllPages}
genIds={genIds} genLog={genLog}
cubesAi={cubesAi} onRunCubesComplete={runCubesComplete} onViewCompleteCubesPrompt={()=>viewCompleteCubesPrompt(id,m.name,domainName,m.description,m.supplement||"",assets.map(a=>({type:a.device_name,count:a.count,rule:a.rule})))}
updatePageField={(i,patchRow)=>setPages(prev=>prev.map((p,idx)=>idx===i?{...p,...patchRow}:p))}
return <Panel title={title}><div className="placeholder-block"><span className="badge todo">待开发</span><p>{note}</p></div></Panel>;
}
-function FunctionsStep({muTree,grouped,effSelected,onToggleMu,pages,onAddPage,onUploadPageJson,onSavePageMeta,onDeletePage,onGeneratePage,onDownloadPage,onPreviewPage,genIds,genLog,cubesAi,onRunCubesComplete,onViewCompleteCubesPrompt,updatePageField,tName,saving,notify,pageStyles,styleId,onSelectStyle}:{muTree:MuNode[];grouped:any[];effSelected:string[];onToggleMu:(k:string)=>void;pages:DomainPage[];onAddPage:()=>Promise<void>;onUploadPageJson:(f:File)=>Promise<void>;onSavePageMeta:(p:DomainPage)=>Promise<void>;onDeletePage:(p:DomainPage)=>Promise<void>;onGeneratePage:(p:DomainPage)=>Promise<void>;onDownloadPage:(p:DomainPage)=>void;onPreviewPage:(p:DomainPage)=>void;genIds:Set<number>;genLog:string;cubesAi:ReturnType<typeof useStreamComplete>;onRunCubesComplete:()=>Promise<void>;onViewCompleteCubesPrompt:()=>void;updatePageField:(i:number,patch:Partial<DomainPage>)=>void;tName:string;saving:boolean;notify:(m:string)=>void;pageStyles:PageStyle[];styleId:number|null;onSelectStyle:(id:number|null)=>void}){
+function FunctionsStep({muTree,grouped,effSelected,onToggleMu,pages,onAddPage,onUploadPageJson,onSavePageMeta,onDeletePage,onGeneratePage,onDownloadPage,onPreviewPage,genIds,genLog,cubesAi,onRunCubesComplete,onViewCompleteCubesPrompt,updatePageField,tName,saving,notify,pageStyles,styleId,onSelectStyle,onGenerateAll}:{muTree:MuNode[];grouped:any[];effSelected:string[];onToggleMu:(k:string)=>void;pages:DomainPage[];onAddPage:()=>Promise<void>;onUploadPageJson:(f:File)=>Promise<void>;onSavePageMeta:(p:DomainPage)=>Promise<void>;onDeletePage:(p:DomainPage)=>Promise<void>;onGeneratePage:(p:DomainPage)=>Promise<void>;onDownloadPage:(p:DomainPage)=>void;onPreviewPage:(p:DomainPage)=>void;genIds:Set<number>;genLog:string;cubesAi:ReturnType<typeof useStreamComplete>;onRunCubesComplete:()=>Promise<void>;onViewCompleteCubesPrompt:()=>void;updatePageField:(i:number,patch:Partial<DomainPage>)=>void;tName:string;saving:boolean;notify:(m:string)=>void;pageStyles:PageStyle[];styleId:number|null;onSelectStyle:(id:number|null)=>void;onGenerateAll:()=>void}){
const sourceBadge=(src?:string|null)=>{
if(src==="upload") return <span className="badge src-upload">↑ 上传</span>;
if(src==="ai") return <span className="badge src-ai">✦ AI生成</span>;
return <span className="badge src-manual">手动</span>;
};
- const [tab,setTab]=useState<"mu"|"cube"|"menu">("mu");
+ const [tab,setTab]=useState<"mu"|"cube"|"menu">("cube");
const genLogRef=useRef<HTMLPreElement>(null);
useEffect(()=>{ if(genLogRef.current) genLogRef.current.scrollTop=genLogRef.current.scrollHeight; },[genLog]);
- return <Panel><div className="tabs"><button className={tab==="mu"?"active":""} onClick={()=>setTab("mu")}>MU 标准页面 <i>{effSelected.length}</i></button><button className={tab==="cube"?"active":""} onClick={()=>setTab("cube")}>Cube 扩展页面 <i>{pages.length}</i></button><button className={tab==="menu"?"active":""} onClick={()=>setTab("menu")}>菜单结构</button></div>
+ return <Panel><div className="tabs"><button className={tab==="cube"?"active":""} onClick={()=>setTab("cube")}>领域扩展页面 <i>{pages.length}</i></button><button className={tab==="mu"?"active":""} onClick={()=>setTab("mu")}>MU 标准页面 <i>{effSelected.length}</i></button><button className={tab==="menu"?"active":""} onClick={()=>setTab("menu")}>菜单结构</button></div>
{tab==="mu" && <div className="mu-tree">{grouped.map(g=><div key={g.parentMenu} className="mu-group"><b>{g.parentMenu}</b><div className="mu-items">{g.items.map((item:any)=><label key={item.key} className={item.disabled?"disabled":""}><input type="checkbox" disabled={item.disabled} checked={effSelected.includes(item.key)} onChange={()=>onToggleMu(item.key)}/><span>{item.label}</span></label>)}</div></div>)}</div>}
{tab==="cube" && <div className="two-column">
<div>
<div className="style-pick-thumb none">无</div><span>不使用样式</span>
</button>
{pageStyles.map(s=><button key={s.id} className={"style-pick"+(styleId===s.id?" active":"")} onClick={()=>onSelectStyle(s.id)}>
- <div className="style-pick-thumb"><img src={`/api/db/page-styles/${s.id}/image`} alt={s.name} loading="lazy"/></div>
+ <div className="style-pick-thumb"><img src={`/api/db/page-styles/${s.id}/image?w=360`} alt={s.name} loading="lazy"/></div>
<span>{s.name}</span>
</button>)}
</div>
<td><input value={p.description} placeholder="描述该页面要展示的内容与指标" onChange={e=>updatePageField(i,{description:e.target.value})}/></td>
<td>{sourceBadge(p.source)}</td>
<td>{p.gen_filename? <button className="link" onClick={()=>onDownloadPage(p)}>下载 {p.gen_filename}</button> : <span className="muted">未生成</span>}</td>
- <td className="cube-actions"><button onClick={()=>onPreviewPage(p)}>预览</button><button disabled={genIds.has(p.id)} onClick={()=>onGeneratePage(p)}>{genIds.has(p.id)?"生成中…":"生成"}</button><button onClick={()=>onSavePageMeta(p)}>保存</button><button className="trash" onClick={()=>onDeletePage(p)}>删除</button></td>
+ <td className="cube-actions"><button onClick={()=>onPreviewPage(p)}>预览</button><button className="btn-ai" disabled={genIds.has(p.id)} onClick={()=>onGeneratePage(p)}>{genIds.has(p.id)?"AI生成中":"AI生成"}</button><button onClick={()=>onSavePageMeta(p)}>保存</button><button className="trash" onClick={()=>onDeletePage(p)}>删除</button></td>
</tr>)}</tbody></table></div>
- <div style={{display:"flex",gap:8,marginTop:8}}><Button onClick={onAddPage}>+ 添加 Cube 页面</Button><label className="button secondary" style={{cursor:"pointer"}}>↑ 上传页面 Json<input type="file" accept=".json,application/json" hidden onChange={e=>{const f=e.target.files?.[0]; if(f) onUploadPageJson(f); e.target.value="";}}/></label></div>
+ <div style={{display:"flex",gap:8,marginTop:8,alignItems:"center",flexWrap:"wrap"}}>
+ <Button onClick={onAddPage}>+ 添加 Cube 页面</Button>
+ <label className="button secondary" style={{cursor:"pointer"}}>↑ 上传页面 Json<input type="file" accept=".json,application/json" hidden onChange={e=>{const f=e.target.files?.[0]; if(f) onUploadPageJson(f); e.target.value="";}}/></label>
+ <button className="btn-ai" style={{height:30}} onClick={onGenerateAll} title="对所有未生成的页面排队生成">⚡ AI 统一生成全部({pages.filter(p=>p.status!=="generated").length})</button>
+ </div>
{(genIds.size>0||genLog)&&<div className="modal-console"><div className="modal-console-head">AI 生成页面</div><pre className="modal-console-body" ref={genLogRef}>{genLog||"(等待生成…)"}</pre></div>}
</div>
<div className="stack">
? <p className="hint">暂无页面样式,点击右上角「+ 新增样式」上传 JSON 与预览图即可创建。</p>
: <div className="style-grid">{pageStyles.map(s=><article key={s.id} className="style-card">
<div className="style-thumb" onClick={()=>setPreview(s)} title="点击查看大图">
- <img src={`/api/db/page-styles/${s.id}/image`} alt={s.name} loading="lazy"/>
+ <img src={`/api/db/page-styles/${s.id}/image?w=560`} alt={s.name} loading="lazy"/>
<span className="zoom">⛶</span>
</div>
<div className="style-info"><h3>{s.name}</h3><small>{s.created_at||""}</small></div>
const [name,setName]=useState("");
const [jsonText,setJsonText]=useState("");
const [image,setImage]=useState<{filename:string;data:string}|null>(null);
- const [imageUrl,setImageUrl]=useState<string|null>(id?`/api/db/page-styles/${id}/image`:null);
+ const [imageUrl,setImageUrl]=useState<string|null>(id?`/api/db/page-styles/${id}/image?w=640`:null);
const [saving,setSaving]=useState(false);
const readText=(file:File)=>new Promise<string>((res,rej)=>{ const r=new FileReader(); r.onload=()=>res(String(r.result)); r.onerror=()=>rej(r.error); r.readAsText(file); });
const readImage=(file:File)=>new Promise<{filename:string;data:string}>((res,rej)=>{ const r=new FileReader(); r.onload=()=>res({filename:file.name, data:String(r.result).split(",")[1]}); r.onerror=()=>rej(r.error); r.readAsDataURL(file); });
import http from "node:http";
import { spawn } from "node:child_process";
import { DatabaseSync } from "node:sqlite";
-import { writeFile, readFile, rm, readdir } from "node:fs/promises";
+import { writeFile, readFile, rm, readdir, stat } from "node:fs/promises";
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { dirname, join, basename, extname } from "node:path";
import { fileURLToPath } from "node:url";
import mammoth from "mammoth";
import * as XLSX from "xlsx";
import { PDFParse } from "pdf-parse";
+import sharp from "sharp";
const PORT = 37823;
const PROJECT_DIR = dirname(fileURLToPath(import.meta.url));
: "application/octet-stream";
}
+// 生成/读取缩略图:原图较大(常 >1MB)而前端仅以小图展示,按宽度生成 WebP 缩略图并落盘缓存,
+// 后续请求直接返回缓存文件,显著降低首次加载耗时。sharp 失败则回退到原图。
+const _thumbBusy = new Map();
+async function serveImageThumb(res, imagePath, width) {
+ const w = Math.max(40, Math.min(2000, Number(width) || 0));
+ if (!w) return false;
+ const cachePath = `${imagePath}.thumb-${w}.webp`;
+ try {
+ if (existsSync(cachePath) && existsSync(imagePath) && (await stat(cachePath)).mtimeMs >= (await stat(imagePath)).mtimeMs) {
+ const buf = await readFile(cachePath);
+ res.writeHead(200, { "Content-Type": "image/webp", "Content-Length": buf.length, "Cache-Control": "public, max-age=3600" });
+ res.end(buf);
+ return true;
+ }
+ // 防止同一原图并发重复生成
+ if (_thumbBusy.has(cachePath)) { await _thumbBusy.get(cachePath); }
+ else {
+ const p = (async () => {
+ const out = await sharp(imagePath).resize({ width: w, withoutEnlargement: true }).webp({ quality: 78 }).toBuffer();
+ await writeFile(cachePath, out);
+ })();
+ _thumbBusy.set(cachePath, p);
+ try { await p; } finally { _thumbBusy.delete(cachePath); }
+ }
+ const buf = await readFile(cachePath);
+ res.writeHead(200, { "Content-Type": "image/webp", "Content-Length": buf.length, "Cache-Control": "public, max-age=3600" });
+ res.end(buf);
+ return true;
+ } catch (e) {
+ return false; // 回退到原图
+ }
+}
+
async function seedPageStyles() {
const cnt = db.prepare("SELECT COUNT(*) AS c FROM page_styles").get().c;
if (cnt > 0) return;
} catch (e) { respondError(res, 500, e.message); }
}
-async function handlePageStyleImage(res, id) {
+async function handlePageStyleImage(res, id, query) {
const row = db.prepare("SELECT image_path FROM page_styles WHERE id=?").get(id);
if (!row || !row.image_path || !existsSync(row.image_path)) return respondError(res, 404, "图片不存在");
+ const w = query ? Number(query.get("w")) : 0;
+ if (w && await serveImageThumb(res, row.image_path, w)) return;
const buf = await readFile(row.image_path);
res.writeHead(200, { "Content-Type": imgContentType(row.image_path), "Content-Length": buf.length, "Cache-Control": "public, max-age=3600" });
res.end(buf);
}
-async function handleDeviceTemplateImage(res, id) {
+async function handleDeviceTemplateImage(res, id, query) {
const row = db.prepare("SELECT image_path FROM device_templates WHERE id=?").get(id);
if (!row || !row.image_path || !existsSync(row.image_path)) return respondError(res, 404, "图片不存在");
+ const w = query ? Number(query.get("w")) : 0;
+ if (w && await serveImageThumb(res, row.image_path, w)) return;
const buf = await readFile(row.image_path);
res.writeHead(200, { "Content-Type": imgContentType(row.image_path), "Content-Length": buf.length, "Cache-Control": "public, max-age=3600" });
res.end(buf);
if (req.method === "GET") return handleFileDownload(res, fileId);
}
// 页面样式库
- if (mPageStylesImage) return handlePageStyleImage(res, Number(mPageStylesImage[1]));
- if (mDevImage) return handleDeviceTemplateImage(res, Number(mDevImage[1]));
+ if (mPageStylesImage) return handlePageStyleImage(res, Number(mPageStylesImage[1]), parsed.searchParams);
+ if (mDevImage) return handleDeviceTemplateImage(res, Number(mDevImage[1]), parsed.searchParams);
if (mPageStyles) {
if (req.method === "GET") return respondJson(res, 200, db.prepare("SELECT id,name,created_at FROM page_styles ORDER BY id").all());
if (req.method === "POST") return handleCreatePageStyle(req, res);
<nav class="doc-nav"><div class="doc-nav-inner" id="toc"></div></nav>
<main id="doc">
- <p class="muted">文档版本:v0.28 最后更新:2026-08-21 修改人:wang.yu 状态:开发中(演示数据已标注)</p>
+ <p class="muted">文档版本:v0.29 最后更新:2026-08-21 修改人:wang.yu 状态:开发中(演示数据已标注)</p>
<h1>StationBuilder 产品与交互设计方案</h1>
<h2>1. 文档说明</h2>
<table>
<tr><th>元素</th><th>功能</th><th>数据来源</th><th>交互效果</th></tr>
<tr><td>MU 标准页面(树状复选)</td><td>树状上下结构列出标准页面,仅保留勾选框</td><td>GET /api/db/mu-tree</td><td>默认全选中(site.mu 为空时按全部叶子键显示,首次取消才落地保存);勾选即保存(PUT)</td></tr>
- <tr><td>Cube 扩展页面表格(名称/内容/生成文件/操作)</td><td>定义并管理扩展页面</td><td>GET/POST/PUT/DELETE /api/db/sites/:id/cubes</td><td>行内编辑、生成、保存、删除、下载;可添加页面</td></tr>
- <tr><td>「生成」单页按钮</td><td>单独生成某个 Cube</td><td>POST /api/db/sites/:id/cubes/:cid/generate</td><td>触发生成(该路径为非流式,无实时日志) <span class="tag">与生成确认页不同</span></td></tr>
- <tr><td>站点页面样式选择器</td><td>为全站 Cube 套用视觉样式</td><td>GET /api/db/page-styles;PUT /api/db/sites</td><td>选择样书后应用到站点(写入 style_id),生成时参考其主题</td></tr>
+ <tr><td>领域扩展页面表格(名称/内容/生成文件/操作,原「Cube 扩展页面」)</td><td>定义并管理扩展页面(<b>现为「功能与菜单」首个页签,置于 MU 标准页面之前</b>)</td><td>GET/POST/PUT/DELETE /api/db/sites/:id/cubes</td><td>行内编辑、保存、删除、下载;可添加页面;每行「AI生成」按钮(醒目渐变样式)单独生成该页,生成中显示「AI生成中」并禁用</td></tr>
+ <tr><td>「AI生成」单页按钮</td><td>单独生成某个 Cube(醒目渐变按钮)</td><td>POST /api/db/sites/:id/cubes/:cid/generate</td><td>触发生成(该路径为非流式,无实时日志);按钮文案由「生成」升级为「AI生成」,生成中显示「AI生成中」 <span class="tag">与生成确认页不同</span></td></tr>
+ <tr><td>站点页面样式选择器</td><td>为全站 Cube 套用视觉样式</td><td>GET /api/db/page-styles;PUT /api/db/sites</td><td>选择样书后应用到站点(写入 style_id),生成时参考其主题;缩略图经后端 <code>?w=</code> 参数由 sharp 生成 WebP 缩略图(原图约 1.4MB → 缩略图约 9KB,首屏加载提速约 150 倍)</td></tr>
<tr><td>Cube「AI 智能补全」面板</td><td>基于站点名称 / 领域 / 描述 / 补充要求 + 设备资产层级,由 AI 推断并补充 Cube 扩展页面(页面名称 + 详细内容)</td><td>POST /api/db/sites/:id/complete-cubes(流式 NDJSON);GET .../complete-cubes-prompt 取提示词</td><td>「开始补全」后控制台流式输出思考过程,结束将 AI 页面追加到用户已建页面下方(不改动用户内容)并自动保存;「查看提示词」可查看完整组合提示词</td></tr>
<tr><td>菜单结构(只读默认预览)</td><td>展示默认菜单结构</td><td>本地计算(MU + Cube)</td><td>MU 标准页面在上;所有 Cube 页面归入「综合监控」一级菜单,二级为各 Cube 页面名称;本页仅为展示,无编辑/保存 <span class="tag">默认结构</span></td></tr>
</table>
<h4>4.5.1 交互细节</h4>
<figure><img src="design-doc-assets/functions.png" alt="功能与菜单"/><figcaption>功能与菜单:MU 页面选择、Cube 扩展页面编辑、页面样式选择、菜单结构预览。</figcaption></figure>
- <p>ä¸\89个页ç¾ï¼\9aMU æ \87å\87\86页é\9d¢ / Cube æ\89©å±\95页面 / 菜单结构。</p>
+ <p>ä¸\89个页ç¾ï¼\88<b>顺åº\8få·²è°\83æ\95´</b>ï¼\89ï¼\9a<b>é¢\86å\9f\9fæ\89©å±\95页é\9d¢</b>ï¼\88å\8e\9fã\80\8cCube æ\89©å±\95页é\9d¢ã\80\8dï¼\8cç\8e°ç½®é¦\96ï¼\89/ MU æ \87å\87\86页面 / 菜单结构。</p>
<p><b>① 字段 / 表格逐列</b></p>
<table>
<tr><th>区域 / 列</th><th>数据来源</th><th>是否可编辑</th><th>编辑类型</th><th>上线范围</th></tr>
<tr><th>按钮</th><th>点击效果</th><th>编辑方式</th><th>确定 / 取消 / 关闭</th></tr>
<tr><td>MU 复选框</td><td>勾选/取消 → <code>saveMu</code> PUT /sites/:id/mu</td><td>—</td><td>即时保存(失败提示)</td></tr>
<tr><td>Cube「预览」</td><td><code>notify("预览 X")</code></td><td>—</td><td>占位(<span class="todo">真实预览尚未实现</span>)</td></tr>
- <tr><td>Cube「生成」</td><td><code>generateCube</code> POST /cubes/:id/generate(单页,非流式,无实时日志)</td><td>—</td><td>生成后刷新该页状态</td></tr>
+ <tr><td>Cube「AI生成」</td><td><code>generateCube</code> POST /cubes/:id/generate(单页,非流式,无实时日志;按钮文案由「生成」升级为「AI生成」,生成中显示「AI生成中」)</td><td>—</td><td>生成后刷新该页状态</td></tr>
<tr><td>Cube「保存」</td><td><code>saveCube</code> PUT/POST /cubes</td><td>—</td><td>已保存提示</td></tr>
<tr><td>Cube「删除」</td><td><code>removeCube</code> DELETE(有 id 才删后端)</td><td>—</td><td>即时移除</td></tr>
<tr><td>+ 添加 Cube 页面</td><td>追加空白 Cube</td><td>—</td><td>无弹窗</td></tr>
<li><b>下一步</b>:生成确认(generate)。(generate 之后无 UI 入口进入站点结果/页面预览,见 4.7 / 4.8)</li>
</ul>
- <h4>4.5.2 AI 智能补全(功能与菜单 · Cube 扩展页面)</h4>
+ <h4>4.5.2 AI 智能补全(功能与菜单 · 领域扩展页面)</h4>
<figure><img src="design-doc-assets/ai-complete-functions.png" alt="AI 智能补全·Cube"/><figcaption>AI Cube 页面补全进行中:基于站点信息与设备资产层级,实时推断并补充 Cube 扩展页面。</figcaption></figure>
- <p>在「Cube 扩展页面」页签中点击「开始补全」,调用 <code>POST /api/db/sites/:id/complete-cubes</code>(流式 NDJSON)。终端实时输出 AI 对站点所需 Cube 扩展页面的推断(页面名称 + 详细内容);结果追加在用户已建页面下方并自动保存。点击「查看提示词」可查看完整组合提示词。</p>
+ <p>在「领域扩展页面」(原「Cube 扩展页面」)页签中点击「开始补全」,调用 <code>POST /api/db/sites/:id/complete-cubes</code>(流式 NDJSON)。终端实时输出 AI 对站点所需 Cube 扩展页面的推断(页面名称 + 详细内容);结果追加在用户已建页面下方并自动保存。点击「查看提示词」可查看完整组合提示词。</p>
<h3>4.6 生成确认(generate)</h3>
<table>
<tr><td>模板信息</td><td>编辑所属领域 / 名称 / 版本 / 状态 / 创建人 / 描述</td><td>GET /api/db/domain-templates/:id 详情;PUT /api/db/domain-templates/:id 保存</td><td>「保存模板信息」写回并重新加载</td></tr>
<tr><td>选用设备模版(DeviceTemplatePicker)</td><td>从「设备模版」页中勾选本方案要用的设备模版</td><td>详情 devices[](已选用的设备模版 id 列表);GET /api/db/device-templates(全部可选);PUT /api/db/domain-templates/:id(传 <code>device_template_ids</code>,服务端重写 device_template_domain_templates 关联)</td><td>勾选即保存:PUT 携带当前全部选中 id,服务端先 DELETE 旧关联再按 id 重建;未找到合适模版可点「+ 新增设备模版」跳到 deviceTemplates 页新建。设备模版的编辑(名称 / 说明 / 图片 / 采集点)已移至独立的「设备模版」页,不再在本面板内联编辑;设备不再维护「所属领域」多选(改为由领域 / 方案侧选用设备)</td></tr>
<tr><td>采集点</td><td>设备下的采集点(名称 / 说明 / 单位 / 数值范围 / 描述)</td><td>POST /api/db/device-templates/:id/collection-points(名称);DELETE /api/db/collection-points/:id</td><td>「+ 采集点」填名称新增;行内「删」移除</td></tr>
- <tr><td>领域页面(Cube 扩展页面)</td><td>编辑领域页面(=StationBuilder 的 Cube 扩展页面)</td><td>详情 pages[];POST /api/db/domain-templates/:id/pages;PUT / DELETE /api/db/domain-pages/:id;POST /api/db/domain-templates/:id/pages/:pid/generate、/refine(流式)</td><td>「功能与菜单 → Cube 扩展页面」表格逐行管理,列:页面名称(≤6 字)/ 页面描述 / 来源 / Json文件 / 操作;来源以徽标区分「↑ 上传 / ✦ AI生成 / 手动」;Json文件列对已上传或已生成的页面提供「下载 文件名」按钮(上传=原始 .json 文件本身,AI生成=生成的 Cube .json),未生成显示"未生成";支持行内编辑名称/描述、预览/生成/保存/删除、+ 添加页面、↑ 上传页面 Json;保存前校验 JSON 合法性;生成确认页对每页显示「AI已生成 / 已上传 / 未生成 · 来源」</td></tr>
+ <tr><td>领域页面(领域扩展页面,原「Cube 扩展页面」)</td><td>编辑领域页面(=StationBuilder 的 Cube 扩展页面)</td><td>详情 pages[];POST /api/db/domain-templates/:id/pages;PUT / DELETE /api/db/domain-pages/:id;POST /api/db/domain-templates/:id/pages/:pid/generate、/refine(流式)</td><td>「功能与菜单 → 领域扩展页面(现为首个页签)」表格逐行管理,列:页面名称(≤6 字)/ 页面描述 / 来源 / Json文件 / 操作;来源以徽标区分「↑ 上传 / ✦ AI生成 / 手动」;Json文件列对已上传或已生成的页面提供「下载 文件名」按钮,未生成显示"未生成";每行「AI生成」按钮(醒目渐变,生成中显示「AI生成中」)单独生成该页;表格上方另有「⚡ AI 统一生成全部(N)」按钮,一键对所有「未生成」页面排队生成(每页按后端 AI 并发上限 2 自动排队,各自按钮同步显示「AI生成中」),免去逐个点击;支持行内编辑名称/描述、预览/保存/删除、+ 添加页面、↑ 上传页面 Json;保存前校验 JSON 合法性;生成确认页对每页显示「AI已生成 / 已上传 / 未生成 · 来源」</td></tr>
<tr><td>模板菜单</td><td>编辑模板菜单(=StationBuilder 的 MU + Cube 菜单保存结构)</td><td>详情 menu[];POST /api/db/domain-templates/:id/menu;DELETE /api/db/template-menu-items/:id</td><td>表格展示菜单项 / 类型 / 父级 / 关联;「+ 新增菜单项」建占位项</td></tr>
<tr><td>知识文档</td><td>管理知识文档(名称 + 本体)</td><td>详情 docs[];POST /api/db/domain-templates/:id/docs(doc_type=link 传 url,或 doc_type=file 传 base64 文件);GET /api/db/knowledge-docs/:id/file 下载;DELETE /api/db/knowledge-docs/:id</td><td>新增时选择「超链」(填 URL) 或「文件」(上传,落盘 <code>data/db/uploads/docs/</code>);列表可下载 / 删除</td></tr>
<tr><td>运维流程(OpsFlowsViewer)</td><td>查看运维流程(流程名称 + 内容 JSON 串)</td><td>GET /api/ops-flows</td><td>仅展示占位记录,本功能 <span class="todo">尚未实现</span></td></tr>
<tr><th>面板 / 区域</th><th>数据来源 / 接口</th><th>是否可编辑</th><th>编辑类型</th><th>上线范围</th></tr>
<tr><td>模板信息</td><td>GET/PUT /api/db/domain-templates/:id</td><td>可编辑</td><td>所属领域(select) / 模板名称 / 版本 / 状态(select) / 创建人 / 领域描述(textarea)</td><td>方案元信息,被站点方案引用</td></tr>
<tr><td>选用设备模版(DeviceTemplatePicker)</td><td>详情 <code>devices[]</code>;GET /api/db/device-templates;PUT /api/db/domain-templates/:id(<code>device_template_ids</code>,服务端重写关联)</td><td>可编辑(勾选即保存)</td><td>复选框列表</td><td>该方案选用的设备模版集合(供站点方案引用)</td></tr>
- <tr><td>领域页面(Cube 扩展页面)</td><td>详情 <code>pages[]</code>;POST /api/db/domain-templates/:id/pages;PUT/DELETE /api/db/domain-pages/:id;POST .../:pid/generate、/refine</td><td>可编辑</td><td>表格逐列:页面名称(≤6字)/ 页面描述 / 来源(徽标:上传·AI生成·手动)/ Json文件(已上传或已生成→「下载 文件名」;否则"未生成")/ 操作(预览·生成·保存·删除)</td><td>方案内置页面(=StationBuilder 的 Cube);source 字段区分来源,gen_filename 为下载文件名</td></tr>
+ <tr><td>领域页面(领域扩展页面,原「Cube 扩展页面」)</td><td>详情 <code>pages[]</code>;POST /api/db/domain-templates/:id/pages;PUT/DELETE /api/db/domain-pages/:id;POST .../:pid/generate、/refine</td><td>可编辑</td><td>表格逐列:页面名称(≤6字)/ 页面描述 / 来源(徽标:上传·AI生成·手动)/ Json文件(已上传或已生成→「下载 文件名」;否则"未生成")/ 操作(预览·AI生成·保存·删除);另含「⚡ AI 统一生成全部(N)」一键排队生成全部未生成页面</td><td>方案内置页面(=StationBuilder 的 Cube);source 字段区分来源,gen_filename 为下载文件名</td></tr>
<tr><td>模板菜单</td><td>详情 <code>menu[]</code>;POST /api/db/domain-templates/:id/menu;DELETE /api/db/template-menu-items/:id</td><td>可编辑(新增占位项)</td><td>表格(菜单项/类型/父级/关联)+ 删除</td><td>方案菜单结构</td></tr>
<tr><td>知识文档</td><td>详情 <code>docs[]</code>;POST /api/db/domain-templates/:id/docs;GET /api/db/knowledge-docs/:id/file;DELETE</td><td>可编辑</td><td>新增选「超链」(填 URL) 或「文件」(上传 base64);列表可下载/删</td><td>方案知识库</td></tr>
<tr><td>运维流程(OpsFlowsViewer)</td><td>GET /api/ops-flows</td><td>只读</td><td>占位展示</td><td><span class="todo">尚未实现</span></td></tr>
<p><b>① 字段 / 表格逐列</b></p>
<table>
<tr><th>区域 / 列</th><th>数据来源</th><th>是否可编辑</th><th>编辑类型</th><th>上线范围</th></tr>
- <tr><td>样式卡片网格(缩略图 / 名称 / 创建时间 / 编辑·删除)</td><td>GET /api/db/page-styles</td><td>卡片只读;编辑/删除可触发</td><td>缩略图 = GET /page-styles/:id/image;3 列网格</td><td>样式浏览与维护</td></tr>
+ <tr><td>样式卡片网格(缩略图 / 名称 / 创建时间 / 编辑·删除)</td><td>GET /api/db/page-styles</td><td>卡片只读;编辑/删除可触发</td><td>缩略图 = GET /page-styles/:id/image<code>?w=560</code>(后端 sharp 生成 WebP 缩略图,首屏 5 张由约 7MB 降至约 45KB,加载提速约 150 倍);3 列网格</td><td>样式浏览与维护</td></tr>
<tr><td>StyleEditorModal · 样式名称(必填)</td><td>POST/PUT /api/db/page-styles</td><td>可编辑</td><td>文本输入</td><td>样式库展示名</td></tr>
<tr><td>StyleEditorModal · 预览图(新建必填)</td><td>同上(image base64)</td><td>可编辑</td><td>图片上传(建议 16:10 PNG/JPG)</td><td>样式缩略图与大图预览源</td></tr>
<tr><td>StyleEditorModal · 仪表盘 JSON(必填,需合法 JSON)</td><td>同上(<code>jsonText</code>)</td><td>可编辑</td><td>textarea 粘贴 或 上传 .json 文件</td><td>Cube 页面视觉主题(生成时参考)</td></tr>
<h3>10.1 版本变更记录(Changelog)</h3>
<table class="changelog">
<tr><th>版本</th><th>日期</th><th>修改人</th><th>说明</th></tr>
+ <tr><td>v0.29</td><td>2026-08-21</td><td>wang.yu</td><td>领域方案库 / 站点「功能与菜单 → Cube 扩展页面」遗留问题修复与体验提升:① <b>页签改名与排序</b>:站点与领域两处「Cube 扩展页面」页签统一更名为「领域扩展页面」,并调整顺序置于「MU 标准页面」之前(默认进入该页签);文档同步。② <b>生成按钮醒目化</b>:两处扩展页面表格的「生成」按钮文案升级为「AI生成」、生成中显示「AI生成中」,并采用醒目渐变蓝紫样式(<code>.btn-ai</code>,覆盖 <code>.cube-actions button</code> 白底默认态)。③ <b>统一生成</b>:领域方案「领域扩展页面」表格上方新增「⚡ AI 统一生成全部(N)」按钮(N = 未生成页面数),一键对所有「未生成」且未在生成中的页面并发触发 <code>generatePage</code>;后端按每页独立锁 + <code>AI_CONCURRENCY=2</code> 信号量排队,各自页面按钮同步显示「AI生成中」,免去逐个点击(仅保留单页生成)。④ <b>图片加载提速</b>:后端 db-sidecar 的 <code>handlePageStyleImage</code> / <code>handleDeviceTemplateImage</code> 新增 <code>?w=</code> 宽度参数,经 sharp 生成 WebP 缩略图并落盘缓存(mtime 校验失效重生成),原图约 1.4MB → 缩略图约 9KB(提速约 150 倍);前端样式选择器(?<w=360)、页面样式库列表(?<w=560)、编辑预览(?<w=640)均改用缩略图,大图灯箱仍取原图。需 <code>systemctl restart stationbuilder-db</code> 生效(新增 sharp 依赖,Node 原生模块已就绪)。Playwright E2E:领域方案进入「领域扩展页面」页签确认置首、10 个页面按钮显示「AI生成」、统一按钮计数正确;新增待生成页面后点「AI 统一生成全部」其按钮即时翻为「AI生成中」、其余已生成页保持「AI生成」,0 控制台报错;页面样式库 5 张缩略图均带 <code>?w=</code>、缩略图接口实测 1355KB→8.9KB。</td></tr>
<tr><td>v0.28</td><td>2026-08-21</td><td>wang.yu</td><td>设备模板「采集点」AI 智能补全(两处入口):① 领域方案「AI 资产补全」自动创建设备模板后,后端立即以设备名称为其生成 6~12 个合适采集点并写库(resolveDeviceTemplate 返回 created:true 时触发;追加模式,单个失败不影响整体,推导过程回流到资产补全控制台;已存在模板不重复补全)。② 设备模板详情(DeviceTemplateCard)新增「AI 智能补全 · 采集点」面板:「开始补全」调用新增接口 POST /api/db/device-templates/:id/complete-collection-points(流式 NDJSON,codebuddy 生成 + 写库,前端补全后重新拉取详情刷新表格),「查看提示词」GET /api/db/device-templates/:id/complete-collection-points-prompt 弹窗展示完整组合提示词。后端新增 buildCompleteCollectionPointsPrompt / extractCollectionPoints / generateDeviceTemplatePoints(按 name 去重追加,不覆盖用户已录入采集点;数值范围优先取预设集合);前端新增 DeviceTemplateCard 的 cpAi=useStreamComplete() 流式面板,并透传 onViewCpPrompt 复用 App 级提示词弹窗;资产补全前端超时守卫由 6 分钟放宽到 10 分钟(因额外补全采集点)。Playwright E2E:临时设备点「开始补全」→ 实际运行 codebuddy 生成 12 个采集点并持久化(GET 详情 collection_points.length=12),控制台显示「✓ 已补全 12 个采集点」,0 控制台报错,临时设备已清理。设计文档同步更新 4.12 / 新增 4.12.2、版本 v0.28。</td></tr>
<tr><td>v0.27</td><td>2026-08-21</td><td>wang.yu</td><td>领域编辑迁移至左侧导航栏:① 移除「领域方案库」首页的「管理领域」面板,首页方案列表相应上移(直接进入列表,不再被管理面板挤占首屏)。② 领域增删改入口迁至左侧「领域方案库模块 → 领域分类」分组,UI 与现有领域分类导航项一致:每个领域行尾加 × 删除按钮(仅非「其他」领域,点击经 window.confirm 二次确认后 DELETE /api/domains/:id,其下方案归入「其他」,随后 reloadDomains 刷新导航);行末新增「+ 新增领域」按钮,点击展开行内输入框(最多 6 字,Enter 确认 / Esc 取消),POST /api/domains 后刷新导航。③ 修复 addDomain 原只更新前端 domains 字符串数组、未刷新导航用 domainOptions 导致新增后导航不出现的问题——现改由 reloadDomains 统一刷新两个 state。④ globals.css 新增 .domain-nav-row / .domain-nav-del / .domain-nav-ok / .domain-nav-add / .domain-nav-input 等样式,保持与导航按钮一致的 41px 行高与激活态。Playwright 验证:面板已移除、导航显示 6 领域行(5 个 × + 1 个 +)、新增/删除临时领域均生效、「其他」无删除按钮、0 控制台报错。后续微调:领域名按钮与 ×/+ 按钮原共用 `.domain-nav-row>button` 的 `flex:1` 导致 × 也等分给到约 95px、过滤按钮被压窄至 95px、4 字以内领域名在用户浏览器中换行;改为 `flex:1` 仅作用于过滤按钮(`:not(.domain-nav-del):not(.domain-nav-ok)`),×/✓ 固定 30px,领域名加 `white-space:nowrap` 并包入 `.domain-nav-name` 超出省略号,4 字以内领域名稳定单行显示。</td></tr>
<tr><td>v0.26</td><td>2026-08-21</td><td>wang.yu</td><td>导出修复(设计方案 Word / PDF):① PDF 由 html2pdf(jsPDF+html2canvas,中文乱码且长文档卡死)改为调用浏览器原生打印(window.print),输出真实文字、微软雅黑、纯黑、1.5 倍行距,无乱码;打印时临时注入静态目录(buildStaticToc,含 h2/h3/h4)于文首,打印后移除、不污染屏幕 UI;屏幕 UI 截图尺寸不变(仍 360px)。② Word 页脚页码修复:footer 元素补 id 并改用规范 Word 域标记(PAGE / NUMPAGES 字段,含 field-begin/separator/end),由「仅首页顶部显示 1」修正为每页底部「第 X 页 / 共 Y 页」。③ Word 目录由 TOC 域(打开时常为空)改为默认插入可见的静态目录(buildStaticToc,含 h2/h3/h4 三级)。④ 截图限宽 300px(等比例):Word 设 width=300 属性 + 行内样式;PDF 原 @media print 用 `.figure img` 类选择器未命中正文 `<figure>` 元素导致约束不生效、图片按原始像素溢出页面(即此前「240 显示不下」的真相),改为 `figure img` 元素选择器 + max-width:300px 后正常限宽并完整显示;图后插入回车(<br>)使图注文字换行到下一行。⑤ 补充 @page 打印页码(A4 页脚居中「第 X 页 / 共 Y 页」;Chrome 另存为 PDF 若未显示,在打印对话框勾选「页眉和页脚」兜底)。</td></tr>