]> acesimba.cloud Git - StationBuilder.git/commitdiff
feat: 站点级页面样式集成(功能与菜单中单选样式并组合到生成提示词)
authorwang.yu <wang.yu@advantech.com>
Mon, 17 Aug 2026 04:11:15 +0000 (12:11 +0800)
committerwang.yu <wang.yu@advantech.com>
Mon, 17 Aug 2026 04:11:15 +0000 (12:11 +0800)
app/globals.css
app/page.tsx
db-sidecar.mjs

index b138d0a91f1c912dd697f7c32e4552b001755b44..e90c8582c8e72a6fa5c76d581dc055546a8fc53f 100644 (file)
 .style-json-row textarea{flex:1;min-height:150px;resize:vertical;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11px;line-height:1.5}
 .style-json-row .upload{width:120px;height:64px;flex:0 0 auto;margin-top:2px}
 .modal>footer .spacer{flex:1}
+.style-picker{margin-top:18px;border-top:1px dashed var(--line);padding-top:14px}
+.style-picker h4{margin:0 0 4px;font-size:14px}
+.style-picker h4 small{color:#999;font-weight:normal;font-size:10px;margin-left:6px}
+.style-pick-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;margin-top:8px}
+.style-pick{border:1px solid var(--line);background:white;padding:7px;display:flex;flex-direction:column;gap:6px;cursor:pointer;text-align:center;color:#555}
+.style-pick:hover{border-color:var(--blue)}
+.style-pick.active{border:2px solid var(--blue);background:#f4fbff;color:#007cc3}
+.style-pick-thumb{height:78px;background:#f2f5f7;display:grid;place-items:center;overflow:hidden}
+.style-pick-thumb img{width:100%;height:100%;object-fit:contain}
+.style-pick-thumb.none{color:#aaa;font-size:13px}
+.style-pick span{font-size:11px;line-height:1.3}
 
index 6f98155a8948779199da5b7616654340bf8d7719..5775052193a548198107a92e70a7bf4e7e4fa83d 100644 (file)
@@ -41,6 +41,7 @@ type MenuItem = { id?:number; parent_id:number|null; label:string; page_type:"mu
 type ReferenceFile = { id:number; filename:string; mime:string; size:number; created_at:string; };
 type SiteDetail = {
   id:number; name:string; domain:string; description:string; supplement:string; status:string;
+  style_id?: number | null;
   created_at:string; updated_at:string;
   assets: Asset[]; cubes: Cube[]; mu: string[]; menu: MenuItem[]; files: ReferenceFile[];
 };
@@ -395,7 +396,7 @@ export default function Home(){
       {page==="styleLibrary"&&<PageStyleLibrary pageStyles={pageStyles} loadPageStyles={loadPageStyles} notify={notify}/>}
       {page==="basic"&&<Basic site={site} domains={domains} onChange={saveSiteBasic} onAddDomain={addDomain} onUpload={uploadFile} saving={saving}/>}
       {page==="assets"&&<Assets site={site} onChange={saveAssets} saving={saving}/>}
-      {page==="functions"&&<Functions site={site} muTree={muTree} onMuChange={saveMu} onSiteChange={setSite} notify={notify}/>}
+      {page==="functions"&&<Functions site={site} muTree={muTree} onMuChange={saveMu} onSiteChange={setSite} pageStyles={pageStyles} notify={notify}/>}
       {page==="generate"&&<Generate site={site} onSiteChange={setSite} notify={notify} viewPrompt={viewPrompt} viewSitePrompt={viewSitePrompt}/>}
       {page==="result"&&<Result site={site} go={go} setModal={setModal} viewPrompt={viewPrompt} notify={notify} openFeedback={openFeedback} viewSitePrompt={viewSitePrompt}/>}
       {page==="preview"&&<Preview site={site} tab={previewTab} setTab={setPreviewTab} setModal={setModal} notify={notify}/>}
@@ -515,7 +516,7 @@ function Assets({site,onChange,saving}:{site:SiteDetail|null;onChange:(assets:As
   </>;
 }
 
-function Functions({site,muTree,onMuChange,onSiteChange,notify}:{site:SiteDetail|null;muTree:MuNode[];onMuChange:(s:string[])=>void;onSiteChange:(s:SiteDetail)=>void;notify:(m:string)=>void}){
+function Functions({site,muTree,onMuChange,onSiteChange,pageStyles,notify}:{site:SiteDetail|null;muTree:MuNode[];onMuChange:(s:string[])=>void;onSiteChange:(s:SiteDetail)=>void;pageStyles:PageStyle[];notify:(m:string)=>void}){
   const [tab,setTab]=useState<"mu"|"cube"|"menu">("mu");
   const selectedMu = site?.mu || [];
   const leaves = useMemo(()=>buildMuLeaves(muTree),[muTree]);
@@ -553,6 +554,15 @@ function Functions({site,muTree,onMuChange,onSiteChange,notify}:{site:SiteDetail
     if(cube.id) try{ await fetchDb(`/sites/${site.id}/cubes/${cube.id}`, {method:"DELETE"}); }catch(e:any){ notify("删除失败: "+e.message); return; }
     onSiteChange({...site, cubes:site.cubes.filter((_,i)=>i!==idx)});
   };
+  const selectStyle=async(id:number|null)=>{
+    try{
+      const refreshed:SiteDetail = await fetchDb(`/sites/${site.id}`, {method:"PUT", headers:{"Content-Type":"application/json"}, body:JSON.stringify({
+        name:site.name, domain:site.domain, description:site.description, supplement:site.supplement, style_id: id
+      })}) as SiteDetail;
+      onSiteChange(refreshed);
+      notify(id ? "已应用页面样式" : "已清除页面样式");
+    }catch(e:any){ notify("应用样式失败: "+e.message); }
+  };
   const generateCube=async(idx:number)=>{
     const cube = site.cubes[idx];
     if(!cube.id) await saveCube(idx);
@@ -591,6 +601,19 @@ function Functions({site,muTree,onMuChange,onSiteChange,notify}:{site:SiteDetail
       <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>
     </tr>)}</tbody></table><Button onClick={addCube}>+ 添加 Cube 页面</Button></div>}
+    {tab==="cube"&&<div className="style-picker">
+      <h4>站点页面样式 <small>站点级 · 全部 Cube 页面共用</small></h4>
+      <p className="hint">选择一个样式库方案作为本站点所有 Cube 页面的视觉风格(生成时参考其 JSON 主题参数)。</p>
+      <div className="style-pick-list">
+        <button className={"style-pick"+(!site.style_id?" active":"")} onClick={()=>selectStyle(null)}>
+          <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>
+          <span>{s.name}</span>
+        </button>)}
+      </div>
+    </div>}
     {tab==="menu"&&<div className="menu-editor"><p className="hint">配置站点菜单结构,可选择引用 MU 标准页或 Cube 扩展页。</p>{menu.map((m,i)=><div key={i} className="menu-row"><input value={m.label} placeholder="菜单名称" onChange={e=>{const next=[...menu]; next[i]={...next[i],label:e.target.value}; setMenu(next);}}/>
       <select value={m.page_type||""} onChange={e=>{const next=[...menu]; next[i]={...next[i],page_type:e.target.value as any}; setMenu(next);}}><option value="">分组</option><option value="mu">MU 页面</option><option value="cube">Cube 页面</option></select>
       {m.page_type==="mu"&&<select value={String(m.ref_id)} onChange={e=>{const next=[...menu]; next[i]={...next[i],ref_id:e.target.value}; setMenu(next);}}><option value="">选择 MU 页面</option>{leaves.filter(l=>!l.disabled).map(l=><option key={l.key} value={l.key}>{l.parentMenu}/{l.label}</option>)}</select>}
index fd7f7c903951450282e17c06d1ed8585632ba90b..ac8fbc50ed12497edec27b1667e6e85a521e3a41 100644 (file)
@@ -237,6 +237,7 @@ function ensureColumn(table, column, def) {
   if (!cols.includes(column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${def}`);
 }
 ensureColumn("cubes", "prompt", "TEXT");
+ensureColumn("sites", "style_id", "INTEGER");
 
 
 function respondJson(res, status, obj) {
@@ -420,7 +421,7 @@ Site context (所有页面通用):
 请生成对应的 JSON 文件。`;
 }
 function buildSiteContextPrompt(site) {
-  return siteContextText(site);
+  return `${siteContextText(site)}${buildStylePrompt(site)}`;
 }
 function buildCubePrompt(site, cube) {
   return `${siteContextText(site)}
@@ -430,7 +431,53 @@ function buildCubePrompt(site, cube) {
 - 页面内容/要求: ${cube.content || "(未填写,请按总览型页面设计)"}
 
 请针对以上页面,生成对应的 Cube 仪表盘 JSON 文件(只输出该页面)。模拟数据需与上方站点资产配置保持一致;若属于同一站点的多个 Cube 页面,请保持各页面之间的数据关系合理。该 JSON 必须符合 dashboard-json-generator Skill 中 Cube 工具的规则,结构如下:
-${DASHBOARD_SPEC}`;
+${DASHBOARD_SPEC}${buildStylePrompt(site)}`;
+}
+
+// 站点级视觉风格范本:从所选样式库方案中提取可写入 grid 的主题参数(背景大图以占位描述代替,避免上下文膨胀),
+// 并在 buildCubePrompt 中复用,保证生成 / 重新生成 / 查看提示词 等路径都带同一段范本。
+function buildStylePrompt(site) {
+  if (!site || !site.style_id) return "";
+  const row = db.prepare("SELECT id,name,json_text FROM page_styles WHERE id=?").get(site.style_id);
+  if (!row) return "";
+  let style;
+  try { style = JSON.parse(row.json_text); } catch { return ""; }
+  const g = style.grid || {};
+  const bgImgLen = typeof g.bgImg === "string" ? g.bgImg.length : 0;
+  const theme = {
+    theme: g.theme,
+    bgType: g.bgType,
+    bgColor: g.bgColor,
+    bgImg: bgImgLen ? `<原样式背景底图,base64 长度约 ${bgImgLen} 字符,此处已省略;请沿用该图的明暗、色调与渐变质感>` : undefined,
+    bgImgType: g.bgImgType,
+    radialGradientParam1: g.radialGradientParam1,
+    radialGradientParam2: g.radialGradientParam2,
+    radialGradientParam3: g.radialGradientParam3,
+    linearGradientParamDeg: g.linearGradientParamDeg,
+    linearGradientParam1: g.linearGradientParam1,
+    linearGradientParam2: g.linearGradientParam2,
+    linearGradientParam3: g.linearGradientParam3,
+    borderColor: g.borderColor,
+    borderWidth: g.borderWidth,
+    showBorder: g.showBorder,
+    borderStyle: g.borderStyle,
+    borderRadius: g.borderRadius,
+    pageHeight: g.pageHeight,
+    pageWidth: g.pageWidth,
+    panelBgType: g.panelBgType,
+    panelBgColor: g.panelBgColor,
+    panel: g.panel,
+    gap: g.gap,
+    pageType: g.pageType,
+  };
+  Object.keys(theme).forEach(k => theme[k] === undefined && delete theme[k]);
+  return `
+
+视觉风格要求(站点级,适用本站点全部 Cube 页面):
+所选页面样式库方案:「${row.name}」。请严格参考以下视觉主题参数生成 dashboard JSON(仅输出符合该风格的外观,结构仍遵循上方 Cube 规范):
+${JSON.stringify(theme, null, 2)}
+
+说明:bgImg 为原样式的背景底图(已省略其 base64 内容),请保持与该图一致的明暗、色调与渐变质感;其余参数均为可直接写入 grid 的样式值。`;
 }
 async function generateCubeJson(site, cube, attempt = 1, previousErrors = "") {
   const promptBase = buildCubePrompt(site, cube);
@@ -883,8 +930,9 @@ async function route(req, res) {
       }
       if (req.method === "PUT") {
         const body = await parseBody(req);
-        db.prepare("UPDATE sites SET name=?, domain=?, description=?, supplement=?, updated_at=CURRENT_TIMESTAMP WHERE id=?").run(
-          body.name ?? "", body.domain ?? "", body.description ?? "", body.supplement ?? "", siteId
+        db.prepare("UPDATE sites SET name=?, domain=?, description=?, supplement=?, style_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?").run(
+          body.name ?? "", body.domain ?? "", body.description ?? "", body.supplement ?? "",
+          body.style_id != null ? Number(body.style_id) : null, siteId
         );
         return respondJson(res, 200, hydrateSite(siteId));
       }