.text-button.danger{color:#d92d20}
.text-button.danger:hover{text-decoration:underline}
+/* 领域方案库 · 左侧导航栏「领域分类」行内编辑(删除× / 新增+) */
+.domain-nav-row{display:flex;align-items:stretch;flex-wrap:nowrap}
+.domain-nav-row>button:not(.domain-nav-del):not(.domain-nav-ok){flex:1;min-width:0;text-align:left;height:41px;border:0;background:transparent;padding:0 14px;display:flex;align-items:center;color:#333;font-size:13px;white-space:nowrap}
+.domain-nav-name{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.domain-nav-row>button b{width:18px;color:#8a9298;margin-right:6px;font-weight:normal;flex:0 0 auto}
+.domain-nav-row>button:hover{background:#e9ecee}
+.domain-nav-row.active{background:#e9f6fc}
+.domain-nav-row>button.active{background:#e9f6fc;color:#007fbd;font-weight:bold}
+.domain-nav-row.active>button b,.domain-nav-row>button.active b{color:#009fe3}
+.domain-nav-del{border:0;background:transparent;width:30px;color:#b0b6ba;font-size:15px;line-height:1;flex:0 0 auto}
+.domain-nav-del:hover{color:var(--red);background:#fdecea}
+.domain-nav-ok{border:0;background:transparent;width:30px;color:#1f9d5f;font-size:14px;flex:0 0 auto}
+.domain-nav-ok:hover{background:#eafaef}
+.domain-nav-add-cta{display:block}
+.domain-nav-add{height:34px;border:0;background:transparent;text-align:left;padding:0 14px;display:flex;align-items:center;color:#009fe3;font-size:12px;width:100%}
+.domain-nav-add:hover{background:#e9f6fc}
+.domain-nav-add b{margin-right:6px}
+.domain-nav-row.add{align-items:center;padding:4px 6px}
+.domain-nav-input{flex:1;min-width:0;margin:0 4px;height:28px;border:1px solid #cfd4d9;border-radius:3px;padding:0 8px;font-size:12px;background:#fff}
+.domain-nav-input:focus{outline:1px solid var(--blue);border-color:var(--blue)}
+
const [openDomainTemplateId,setOpenDomainTemplateId]=useState<number|"create"|null>(null);
const [domainFilter,setDomainFilter]=useState<number|null>(null);
const [domainOptions,setDomainOptions]=useState<{id:number;name:string}[]>([]);
+ const [addingDomain,setAddingDomain]=useState(false);
+ const [newDomainName,setNewDomainName]=useState("");
const [view,setView]=useState<"table"|"card">("table");
const [functionTab,setFunctionTab]=useState<"mu"|"cube"|"menu">("mu");
};
const addDomain=async(name:string)=>{
- if(!name || name.length>6) return;
+ const n=(name||"").trim();
+ if(!n) return;
+ if(n.length>6){ notify("领域名称不超过 6 个字"); return; }
try{
- await fetchDb("/domains", {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({name})});
- setDomains(prev=>[...new Set([...prev, name])]);
+ await fetchDb("/domains", {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({name:n})});
+ await reloadDomains();
}catch(e:any){ notify("添加领域失败: "+e.message); }
};
+ const doAddDomain=async()=>{
+ if(!newDomainName.trim()) return;
+ await addDomain(newDomainName);
+ setAddingDomain(false); setNewDomainName("");
+ };
const uploadFile=async(file:File)=>{
if(!site) return;
return <div className="app-shell">
<header className="topbar"><button className="hamburger">☰</button><div className="brand-mark">⬡</div><strong>iStation</strong><span className="divider"/><span>iStation Team</span><span className="divider"/><span>StationBuilder⌄</span><div className="top-actions"><a className="design-link" href="/design-doc.html" target="_blank" rel="noopener">设计方案</a><button title="查看内部登录页" className="avatar avatar-button" onClick={()=>go("login")}>W</button><small>wang.yu@advantech.com<br/>iStation Team</small></div></header>
- <aside className="side-nav"><div className="module-switch"><button className={(["projects","basic","assets","functions","generate","result","preview"] as PageKey[]).includes(page)?"active":""} onClick={()=>go("projects")}><b>▦</b><span>StationBuilder<small>站点生成工具</small></span></button><button className={(["domains","domainDetail","deviceTemplates"] as PageKey[]).includes(page)?"active":""} onClick={()=>{setDomainFilter(null); go("domains");}}><b>◆</b><span>领域方案库<small>行业标准方案</small></span></button><button className={page==="styleLibrary"?"active":""} onClick={()=>go("styleLibrary")}><b>❖</b><span>页面样式库<small>页面视觉样式</small></span></button></div>{page==="styleLibrary"?<><small>样式库导航</small><button className={page==="styleLibrary"?"active":""} onClick={()=>go("styleLibrary")}><b>❖</b>全部页面样式</button></>:(["domains","domainDetail","deviceTemplates"] as PageKey[]).includes(page)?<><small>方案库导航</small><button className={page==="domains"?"active":""} onClick={()=>{setDomainFilter(null); go("domains");}}><b>▦</b>领域方案</button><button className={page==="deviceTemplates"?"active":""} onClick={()=>go("deviceTemplates")}><b>⬡</b>设备模版</button><small>领域分类</small>{domainOptions.map((d:any)=><button key={d.id} className={(page==="domains"&&domainFilter===d.id)?"active":""} onClick={()=>{setDomainFilter(d.id); go("domains");}}><b>◆</b>{d.name}</button>)}</>:<><small>站点列表</small><button className={page==="projects"?"active":""} onClick={()=>go("projects")}><b>▦</b>站点项目</button><small>站点详情</small><select className="site-switcher" value={siteId ?? ""} onChange={e=>{const v=e.target.value; if(v){ const t=(page==="projects"||["domains","domainDetail","styleLibrary","deviceTemplates"].includes(page))?"basic":page; go(t, Number(v)); }}}><option value="">选择站点…</option>{sites.map(s=><option key={s.id} value={s.id}>{s.name||"未命名站点"}</option>)}</select><small>生成向导</small>{(["basic","assets","functions","generate"] as PageKey[]).map((item,index)=><button key={item} className={page===item?"active":""} onClick={()=>go(item)}><em>{index+1}</em>{meta[item].label}</button>)}<small>方案交付</small><button className={page==="result"?"active":""} onClick={()=>go("result")}><b>◫</b>站点结果</button><button className={page==="preview"?"active":""} onClick={()=>go("preview")}><b>▣</b>页面预览</button></>}<div className="side-help">
+ <aside className="side-nav"><div className="module-switch"><button className={(["projects","basic","assets","functions","generate","result","preview"] as PageKey[]).includes(page)?"active":""} onClick={()=>go("projects")}><b>▦</b><span>StationBuilder<small>站点生成工具</small></span></button><button className={(["domains","domainDetail","deviceTemplates"] as PageKey[]).includes(page)?"active":""} onClick={()=>{setDomainFilter(null); go("domains");}}><b>◆</b><span>领域方案库<small>行业标准方案</small></span></button><button className={page==="styleLibrary"?"active":""} onClick={()=>go("styleLibrary")}><b>❖</b><span>页面样式库<small>页面视觉样式</small></span></button></div>{page==="styleLibrary"?<><small>样式库导航</small><button className={page==="styleLibrary"?"active":""} onClick={()=>go("styleLibrary")}><b>❖</b>全部页面样式</button></>:(["domains","domainDetail","deviceTemplates"] as PageKey[]).includes(page)?<><small>方案库导航</small><button className={page==="domains"?"active":""} onClick={()=>{setDomainFilter(null); go("domains");}}><b>▦</b>领域方案</button><button className={page==="deviceTemplates"?"active":""} onClick={()=>go("deviceTemplates")}><b>⬡</b>设备模版</button><small>领域分类</small>
+{domainOptions.map((d:any)=>(
+ <div key={d.id} className={"domain-nav-row"+(page==="domains"&&domainFilter===d.id?" active":"")}>
+ <button className={(page==="domains"&&domainFilter===d.id)?"active":""} onClick={()=>{setDomainFilter(d.id); go("domains");}}><b>◆</b><span className="domain-nav-name">{d.name}</span></button>
+ {d.name!=="其他" && <button className="domain-nav-del" title="删除领域" onClick={async()=>{ if(!window.confirm(`确定删除领域「${d.name}」?其下的方案将移入「其他」领域。`)) return; try{ await fetchDb(`/domains/${d.id}`,{method:"DELETE"}); notify("已删除领域,相关方案已归入「其他」"); await handleDomainDeleted(d.id); }catch(e:any){ notify("删除失败:"+e.message); } }}>×</button>}
+ </div>
+))}
+{addingDomain ? (
+ <div className="domain-nav-row add">
+ <input className="domain-nav-input" autoFocus value={newDomainName} placeholder="领域名称" onChange={e=>setNewDomainName(e.target.value)} onKeyDown={async e=>{ if(e.key==="Enter"){ await doAddDomain(); } else if(e.key==="Escape"){ setAddingDomain(false); setNewDomainName(""); } }}/>
+ <button className="domain-nav-ok" title="确认" onClick={doAddDomain}>✓</button>
+ <button className="domain-nav-del" title="取消" onClick={()=>{setAddingDomain(false);setNewDomainName("");}}>×</button>
+ </div>
+) : (
+ <div className="domain-nav-add-cta"><button className="domain-nav-add" onClick={()=>{setAddingDomain(true);setNewDomainName("");}}><b>+</b>新增领域</button></div>
+)}</>:<><small>站点列表</small><button className={page==="projects"?"active":""} onClick={()=>go("projects")}><b>▦</b>站点项目</button><small>站点详情</small><select className="site-switcher" value={siteId ?? ""} onChange={e=>{const v=e.target.value; if(v){ const t=(page==="projects"||["domains","domainDetail","styleLibrary","deviceTemplates"].includes(page))?"basic":page; go(t, Number(v)); }}}><option value="">选择站点…</option>{sites.map(s=><option key={s.id} value={s.id}>{s.name||"未命名站点"}</option>)}</select><small>生成向导</small>{(["basic","assets","functions","generate"] as PageKey[]).map((item,index)=><button key={item} className={page===item?"active":""} onClick={()=>go(item)}><em>{index+1}</em>{meta[item].label}</button>)}<small>方案交付</small><button className={page==="result"?"active":""} onClick={()=>go("result")}><b>◫</b>站点结果</button><button className={page==="preview"?"active":""} onClick={()=>go("preview")}><b>▣</b>页面预览</button></>}<div className="side-help">
<b>内部工具 · v0.3</b>
<span className="model-trigger" onClick={() => setModelOpen(o => !o)}>
Wordbuddy AI · {model}<span className="caret">▾</span>
{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} pageStyles={pageStyles}/>
- : <DomainLibrary go={go} notify={notify} onOpen={(id:number)=>setOpenDomainTemplateId(id)} domainFilter={domainFilter} domainOptions={domainOptions} onDomainDeleted={handleDomainDeleted}/>)}
+ : <DomainLibrary go={go} notify={notify} onOpen={(id:number)=>setOpenDomainTemplateId(id)} domainFilter={domainFilter} domainOptions={domainOptions}/>)}
{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,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}){
+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}[]}){
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>
<nav class="doc-nav"><div class="doc-nav-inner" id="toc"></div></nav>
<main id="doc">
- <p class="muted">文档版本:v0.25 最后更新:2026-08-20 修改人:wang.yu 状态:开发中(演示数据已标注)</p>
+ <p class="muted">文档版本:v0.27 最后更新:2026-08-21 修改人:wang.yu 状态:开发中(演示数据已标注)</p>
<h1>StationBuilder 产品与交互设计方案</h1>
<h2>1. 文档说明</h2>
│ │ └─ 设备模版 → deviceTemplates(独立页面,编辑设备模版与采集点,供方案选用)
│ └─ 领域分类(动态二级菜单:来自 GET /api/domains;点击即按该领域筛选方案列表)
│ ├─ (领域 A) → domains + domainFilter=领域A(自动列出该领域全部方案)
-│ ├─ (领域 B) → domains + domainFilter=领域B
-│ └─ …(按数据库 domains 表动态渲染)
+│ │ └─ ×(删除,仅非「其他」领域;确认后其方案归入「其他」)
+│ ├─ (领域 B) → domains + domainFilter=领域B(同上,含 × 删除)
+│ ├─ …(按数据库 domains 表动态渲染)
+│ └─ + 新增领域 → 行内输入框(最多 6 字,Enter 确认 / Esc 取消)POST /api/domains 并刷新
│
└─ 【页面样式库模块时】(styleLibrary)
└─ 样式库导航
<tr><td>站点结果 / 页面预览</td><td>StationBuilder / 方案交付</td><td>查看生成结果与预览</td><td>—</td><td>点击 go("result" / "preview");当前页高亮</td></tr>
<tr><td>领域方案</td><td>领域方案库 / 方案库导航</td><td>方案库列表(列表/卡片切换)与详情</td><td>GET /api/db/domain-templates、GET /api/domains</td><td>点击 go("domains");列表页右上角可在「列表 / 卡片」间切换;点击某方案(列表「编辑」或卡片本身)以同页内嵌编辑器打开详情(<code>openDomainTemplateId</code> state 控制)。「+ 新增领域方案」切到新增表单;「清除领域筛选」返回全部</td></tr>
<tr><td>设备模版</td><td>领域方案库 / 方案库导航</td><td>独立页面,编辑设备模版与采集点</td><td>GET /api/db/device-templates</td><td>点击 go("deviceTemplates");默认展开<strong>列表视图</strong>(缩略图 / 名称 / 说明 / 采集点数 / 操作),支持按名称或说明搜索;点击行进入该设备模版的<strong>详情编辑</strong>(名称 / 说明 / 图片 / 采集点),可返回列表。列表上方「+ 新增设备模版」进入新建表单。设备模版在此独立维护,方案详情中通过「选用设备模版」勾选引用</td></tr>
- <tr><td>领域分类(动态)</td><td>领域方案库 / 领域分类</td><td>按领域筛选方案列表(二级菜单)</td><td>GET /api/domains(动态渲染)</td><td>点击某领域 → go("domains") 且 <code>domainFilter</code>=该领域 id,列表自动只显示该领域方案;当前领域高亮;「清除领域筛选」返回全部</td></tr>
+ <tr><td>领域分类(动态)</td><td>领域方案库 / 领域分类</td><td>按领域筛选方案列表(二级菜单);行内删除 / 新增领域</td><td>GET /api/domains(动态渲染)</td><td>点击某领域 → go("domains") 且 <code>domainFilter</code>=该领域 id,列表自动只显示该领域方案;当前领域高亮;「清除领域筛选」返回全部。每个非「其他」领域行尾有 × 删除按钮(确认后 DELETE /api/domains/:id,其下方案归入「其他」并刷新导航);行末「+ 新增领域」展开行内输入框(最多 6 字,Enter 确认 / Esc 取消),POST /api/domains 后刷新导航。领域编辑已从「领域方案库」首页的「管理领域」面板迁移至此,首页方案列表相应上移。</td></tr>
<tr><td>全部页面样式</td><td>页面样式库 / 样式库导航</td><td>样式库列表</td><td>GET /api/db/page-styles</td><td>点击 go("styleLibrary");当前页高亮</td></tr>
</table>
<h3>10.1 版本变更记录(Changelog)</h3>
<table class="changelog">
<tr><th>版本</th><th>日期</th><th>修改人</th><th>说明</th></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>
<tr><td>v0.24</td><td>2026-08-20</td><td>wang.yu</td><td>AI 并发调用统一限流:新增全局信号量 <code>AI_CONCURRENCY = 2</code>,包裹 <code>runCodebuddy</code>(db-sidecar.mjs),使站点生成(含批内并行)、各项补全(complete / complete-assets / complete-cubes)、领域方案补全(dt-complete 系列)等全部 AI 功能任意时刻最多 2 个 codebuddy 子进程并发,避免无脑全并行抢占资源导致超时。站点「生成全部」批内并行数 <code>GEN_CONCURRENCY</code> 由硬编码 3 改为复用 <code>AI_CONCURRENCY</code>,对应日志文案同步为「最多 2 个并行调用」。AI 聊天侧(ai-sidecar.mjs /api/chat)本身以 <code>running</code> 布尔量串行(同一时刻仅 1 个),已满足 ≤2。</td></tr>
<tr><td>v0.25</td><td>2026-08-20</td><td>wang.yu</td><td>领域方案「已完成态」步进导航增强:判定「已完成」= 方案存在至少一个已生成(非空 <code>json_text</code>)的 Cube 页面(<code>DomainWizard</code> 内 <code>pageGenerated</code>)。完成后:① 步进器每个「已配置」的 Step 前置圆圈显示绿色对号(复用 <code>.steps button.done</code> 绿色样式,<code>configured</code> 由各步数据驱动——基本属性 name+description、领域知识 docs、资产设置 有 device_template_id、功能与菜单 有页面或已选 MU、运维流程 flows、生成保存 已生成页面、专家确认 status=available);② 取消「仅可点已访问步骤」限制,任意 Step 直接点击即可跳转对应页(不再需连点「下一步」);③ 未完成(草稿)方案保持原逐步引导行为(仅已访问步骤可点、后续步骤加 <code>.locked</code> 不可点)。<code>DomainSteps</code> 新增 <code>completed</code> / <code>configured</code> 入参,<code>globals.css</code> 增补 <code>.steps button.locked</code> 光标态。Playwright 验证:已完成方案绿对号渲染正确、点击直达生效、0 控制台报错;草稿方案锁定与不可跳转保持。</td></tr>