.dlg-box input:focus{outline:1px solid var(--blue)}
.dlg-actions{display:flex;justify-content:flex-end;gap:10px}
.dlg-actions .button{margin-left:0}
+/* 领域方案库 · 管理领域 */
+.domain-manage-list{display:flex;flex-direction:column;gap:8px}
+.domain-manage-row{display:flex;align-items:center;gap:12px;padding:9px 12px;border:1px solid var(--line);border-radius:8px;background:#fff}
+.domain-manage-name{font-weight:600;color:#333}
+.domain-manage-row .muted{font-size:11px}
+.text-button.danger{color:#d92d20}
+.text-button.danger:hover{text-decoration:underline}
}).catch(e=>notify("加载失败: "+e.message)).finally(()=>setLoading(false));
},[]);
+ const reloadDomains = async () => {
+ try {
+ const d = await fetchDb("/domains") as {id:number;name:string}[];
+ const arr = d || [];
+ setDomainOptions(arr.map(x=>({id:x.id,name:x.name})));
+ setDomains(arr.map(x=>x.name));
+ } catch(e:any){ notify("加载领域失败: "+e.message); }
+ };
+ const handleDomainDeleted = async (id:number) => {
+ await reloadDomains();
+ if (domainFilter === id) setDomainFilter(null);
+ };
+
const loadSite=async(id:number)=>{
const data:SiteDetail = await fetchDb(`/sites/${id}`);
setSite(data);
{page==="projects"&&<Projects view={view} setView={setView} sites={sites} loading={loading} go={go} loadSite={loadSite} notify={notify} onDelete={deleteSites}/>}
{(page==="domains"||page==="domainDetail")&&(openDomainTemplateId!=null
? <DomainDetail id={openDomainTemplateId==="create"?null:openDomainTemplateId} go={go} notify={notify} onBack={()=>setOpenDomainTemplateId(null)} onCreated={setOpenDomainTemplateId} viewCompletePrompt={viewDtCompletePrompt} viewCompleteAssetsPrompt={viewDtCompleteAssetsPrompt} viewCompleteCubesPrompt={viewDtCompleteCubesPrompt}/>
- : <DomainLibrary go={go} notify={notify} onOpen={(id:number)=>setOpenDomainTemplateId(id)} domainFilter={domainFilter} domainOptions={domainOptions}/>)}
+ : <DomainLibrary go={go} notify={notify} onOpen={(id:number)=>setOpenDomainTemplateId(id)} domainFilter={domainFilter} domainOptions={domainOptions} onDomainDeleted={handleDomainDeleted}/>)}
{page==="styleLibrary"&&<PageStyleLibrary pageStyles={pageStyles} loadPageStyles={loadPageStyles} notify={notify}/>}
{page==="deviceTemplates"&&<DeviceTemplates notify={notify}/>}
{page==="basic"&&<Basic site={site} domains={domains} onChange={saveSiteBasic} onAddDomain={addDomain} onUpload={uploadFile} saving={saving} notify={notify} onViewCompletePrompt={viewCompletePrompt}/>}
return { prompt, el };
}
-function DomainLibrary({go,notify,onOpen,domainFilter,domainOptions}:{go:(p:PageKey)=>void;notify:(m:string)=>void;onOpen:(id:number)=>void;domainFilter:number|null;domainOptions:{id:number;name:string}[]}){
+function DomainLibrary({go,notify,onOpen,domainFilter,domainOptions,onDomainDeleted}:{go:(p:PageKey)=>void;notify:(m:string)=>void;onOpen:(id:number)=>void;domainFilter:number|null;domainOptions:{id:number;name:string}[];onDomainDeleted:(id:number)=>void}){
const [list,setList]=useState<any[]>([]);
const [loading,setLoading]=useState(true);
const [view,setView]=useState<"list"|"card">("list");
useEffect(()=>{ load(); },[]);
const {confirm, el:confirmEl}=useConfirm();
const del=async(id:number)=>{ if(!(await confirm("确定删除该领域方案?此操作不可恢复"))) return; try{ await fetchDb(`/domain-templates/${id}`,{method:"DELETE"}); notify("已删除"); load(); }catch(e:any){ notify("删除失败:"+e.message); } };
+ const manageDel=async(id:number,name:string)=>{ if(name==="其他") return; if(!(await confirm(`确定删除领域「${name}」?其下的方案将移入「其他」领域。`))) return; try{ await fetchDb(`/domains/${id}`,{method:"DELETE"}); notify("已删除领域,相关方案已归入「其他」"); await onDomainDeleted(id); }catch(e:any){ notify("删除失败:"+e.message); } };
const statusLabel=(s:string)=>s==="available"?"可用":"不可用";
const toggleSort=(key:"domain"|"name"|"status"|"creator")=>setSort(s=>s.key===key?{key,dir:(s.dir*-1) as 1|-1}:{key,dir:1});
const sortInd=(key:"domain"|"name"|"status"|"creator")=>sort.key===key?(<span className="sort-ind">{sort.dir===1?"▲":"▼"}</span>):null;
<div className="domain-hero"><div><span className="eyebrow">DOMAIN SOLUTION LIBRARY</span><h2>领域方案库</h2><p>沉淀各领域标准方案,供客户站点直接使用或快速微调。</p></div>
<div className="domain-hero-stats">{domainOptions.map(d=><div key={d.id}><b>{list.filter(t=>t.domain_id===d.id).length}</b><span>{d.name}</span></div>)}</div>
</div>
+ <Panel title="管理领域" tools={<span className="muted">删除领域后,其方案将归入「其他」</span>}>
+ <div className="domain-manage-list">
+ {domainOptions.map(d=>(
+ <div key={d.id} className="domain-manage-row">
+ <span className="domain-manage-name">{d.name}</span>
+ <span className="muted">{list.filter((t:any)=>t.domain_id===d.id).length} 个方案</span>
+ <span style={{marginLeft:"auto"}}>
+ {d.name==="其他"
+ ? <span className="muted">(默认保留)</span>
+ : <button className="text-button danger" onClick={()=>manageDel(d.id,d.name)}>删除</button>}
+ </span>
+ </div>
+ ))}
+ </div>
+ </Panel>
<Panel title="领域方案列表" tools={<div className="view-toggle">
<button className={view==="list"?"active":""} onClick={()=>setView("list")}>▤ 列表</button>
<button className={view==="card"?"active":""} onClick={()=>setView("card")}>▦ 卡片</button>
];
const DEFAULT_DOMAINS = [
- "工业园区", "数据中心", "智慧楼宇", "智慧水务", "工业制造",
- "燃气", "水务 · 自来水厂", "新能源",
+ "数据中心", "智慧水务", "燃气", "水务 · 自来水厂",
+ "新能源", "其他",
];
const MU_TREE = [
const path = parsed.pathname;
const mSites = path.match(/^\/api\/sites(?:\/(\d+))?$/);
const mDomains = path === "/api/domains";
+ const mDomainOne = path.match(/^\/api\/domains\/(\d+)$/);
const mHealth = path === "/api/health";
const mMuTree = path === "/api/mu-tree";
const mSiteAssets = path.match(/^\/api\/sites\/(\d+)\/assets$/);
try {
if (mHealth && req.method === "GET") return respondJson(res, 200, { ok: true });
if (mMuTree && req.method === "GET") return respondJson(res, 200, MU_TREE);
+ if (mDomainOne && req.method === "DELETE") {
+ const id = Number(mDomainOne[1]);
+ const dom = db.prepare("SELECT * FROM domains WHERE id=?").get(id);
+ if (!dom) return respondError(res, 404, "领域不存在");
+ if (dom.name === "其他") return respondError(res, 400, "不能删除「其他」领域");
+ // 确保「其他」领域存在,作为被删除领域方案的归宿
+ let other = db.prepare("SELECT * FROM domains WHERE name='其他'").get();
+ if (!other) { const info = db.prepare("INSERT INTO domains (name) VALUES ('其他')").run(); other = { id: Number(info.lastInsertRowid) }; }
+ // 先将关联的方案 / 设备-领域关联迁移到「其他」,再删除领域(避免 ON DELETE CASCADE 误删方案)
+ db.prepare("UPDATE domain_templates SET domain_id=? WHERE domain_id=?").run(other.id, id);
+ db.prepare("UPDATE device_template_domains SET domain_id=? WHERE domain_id=?").run(other.id, id);
+ db.prepare("DELETE FROM domains WHERE id=?").run(id);
+ return respondJson(res, 200, { ok: true, movedTo: "其他" });
+ }
if (mDomains) {
if (req.method === "GET") return respondJson(res, 200, db.prepare("SELECT * FROM domains ORDER BY name").all());
if (req.method === "POST") {
<nav class="doc-nav"><div class="doc-nav-inner" id="toc"></div></nav>
<main id="doc">
- <p class="muted">文档版本:v0.19 最后更新:2026-08-20 修改人:wang.yu 状态:开发中(演示数据已标注)</p>
+ <p class="muted">文档版本:v0.20 最后更新:2026-08-20 修改人:wang.yu 状态:开发中(演示数据已标注)</p>
<h1>StationBuilder 产品与交互设计方案</h1>
<h2>1. 文档说明</h2>
<tr><th>元素</th><th>功能</th><th>数据来源</th><th>交互效果</th></tr>
<tr><td>筛选栏(查找 / 所属领域 / 状态 / 条数)</td><td>列表实时筛选与计数</td><td>本地 state(<code>q</code> / <code>domainSel</code> / <code>statusSel</code>),作用于 GET /api/db/domain-templates 结果</td><td>查找框按 方案名称/创建人 模糊匹配;所属领域、状态下拉精确过滤;右上角显示「共 N 条」</td></tr>
<tr><td>领域方案列表表格</td><td>展示全部领域方案(所属领域 / 方案名称 / 版本 / 状态 / 创建人 / 设备·页面·文档数 / 操作)</td><td>GET /api/db/domain-templates(含 domain_name、device_count、page_count、doc_count)</td><td>每行「编辑」→ 打开该方案详情编辑器;「删除」→ 确认后 DELETE /api/db/domain-templates/:id(级联删除 device_template_domain_templates)并刷新;空列表提示「点击右上角新增」</td></tr>
+ <tr><td>管理领域面板</td><td>领域分类管理:列出全部领域及方案数,支持删除领域</td><td>GET /api/domains(领域列表);删除调用 DELETE /api/domains/:id</td><td>「其他」标注「(默认保留)」且无删除按钮;其余领域显示「删除」按钮 → 自定义确认弹窗二次确认 → 调用 DELETE /api/domains/:id(后端先将其下 domain_templates / device_template_domains 迁移至「其他」,再删除领域)→ 前端刷新领域列表、若当前筛选命中该领域则清除筛选</td></tr>
<tr><td>「+ 新增领域方案」</td><td>新建领域方案,进入新增表单</td><td>—</td><td>同页切到 CreateTemplate 表单:所属领域(select 来自 GET /api/domains)、模板名称、版本、状态(可用/不可用)、创建人、领域描述;POST /api/db/domain-templates 成功后自动打开该方案详情编辑器</td></tr>
<tr><td>搜索 / 筛选 / 同步方案库</td><td>(历史功能)</td><td>—</td><td>已按计划 Phase 0 移除整行(含「同步方案库」占位按钮) <span class="todo">已移除</span></td></tr>
</table>
<h3>11.1 版本变更记录(Changelog)</h3>
<table class="changelog">
<tr><th>版本</th><th>日期</th><th>修改人</th><th>说明</th></tr>
+ <tr><td>v0.20</td><td>2026-08-20</td><td>wang.yu</td><td>领域分类管理增强:① 默认分类调整——移除「工业制造 / 工业园区 / 智慧楼宇」,新增「其他」(<code>DEFAULT_DOMAINS</code> 同步更新,重启不再复活被删分类);② 领域方案库新增「管理领域」面板,列出全部领域及其方案数,「其他」标注「默认保留」且不可删除,其余领域提供「删除」按钮,经自定义确认弹窗二次确认后调用 <code>DELETE /api/domains/:id</code>;③ 后端新增 <code>DELETE /api/domains/:id</code>:禁止删除「其他」,删除前将待删领域的 <code>domain_templates</code> 与 <code>device_template_domains</code> 关联迁移至「其他」(避免 <code>ON DELETE CASCADE</code> 误删方案),再删除领域;④ 删除后前端刷新领域列表,并在当前筛选命中该领域时自动清除筛选。</td></tr>
<tr><td>v0.19</td><td>2026-08-20</td><td>wang.yu</td><td>修复领域方案「功能与菜单 → Cube 扩展页面」两处问题:①「生成」按钮改为流式请求(消费 NDJSON 响应体以保持连接),生成期间按钮显示「生成中…」并禁用、底部「AI 生成页面」控制台实时输出进度并据服务端 <code>{status:failed}</code> 事件弹失败提示;此前前端用普通 <code>fetch</code> 不读取响应体,连接被提前关闭导致服务端 AI 生成中断(<code>status='failed'</code>)、Json 未写入。② AI 智能补全(complete-cubes)由「逐条静默 <code>.catch(()=>{})</code> 插入」改为「收集结果后逐个 <code>await</code> 插入、失败显式 <code>notify</code>」,并捕获服务端 <code>{status:failed}</code> 事件弹失败提示,避免补全内容静默丢失、列表无变化。</td></tr>
<tr><td>v0.17</td><td>2026-08-19</td><td>wang.yu</td><td>修复左侧目录(TOC)点击章节后消失:根因为顶部版本说明段落含全角空格不换行,在 flex 布局下把 <code>main#doc</code> 撑到约 2238px 宽(flex item 默认 <code>min-width:auto</code> 不收缩),点击锚点时浏览器水平滚动将 sticky 目录推出左边界。给 <code>main#doc</code> 增加 <code>min-width:0</code> 使其可收缩、内容正常折行,消除水平滚动,目录任意宽度常驻可见。</td></tr>
<tr><td>v0.18</td><td>2026-08-20</td><td>wang.yu</td><td>领域方案「功能与菜单 → Cube 扩展页面」表格增强:① 新增「来源」列,以徽标区分 上传 / AI生成 / 手动;② 原「生成文件」列更名为「Json文件」,对上传页面提供原始 .json 文件名下载、对 AI 生成页面提供生成的 Cube .json 下载(未生成显示"未生成");③ domain_pages 新增 source 字段,上传/生成时分别写入 source=upload/ai 与 gen_filename;④ 生成确认页状态徽标区分「AI已生成 / 已上传 / 未生成 · 来源」。同步更新 5.10 章节。</td></tr>