.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}
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[];
};
{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}/>}
</>;
}
-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]);
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);
<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>}
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) {
请生成对应的 JSON 文件。`;
}
function buildSiteContextPrompt(site) {
- return siteContextText(site);
+ return `${siteContextText(site)}${buildStylePrompt(site)}`;
}
function buildCubePrompt(site, cube) {
return `${siteContextText(site)}
- 页面内容/要求: ${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);
}
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));
}