]> acesimba.cloud Git - StationBuilder.git/commitdiff
fix: 右下角 AI 图标改为可拖动并更换为机器人图标
authorwang.yu <wang.yu@advantech.com>
Sun, 16 Aug 2026 02:34:49 +0000 (10:34 +0800)
committerwang.yu <wang.yu@advantech.com>
Sun, 16 Aug 2026 02:34:49 +0000 (10:34 +0800)
问题:右下角固定的 AI 悬浮按钮有时会遮挡「下一步」按钮,且原 ✦ 图标不够突出。

改动:
- 悬浮按钮支持拖拽移动:基于 Pointer 事件 + setPointerCapture 实现,
  拖动实时更新位置并持久化到 localStorage(刷新后保持),超出视口自动夹紧;
  拖动阈值 4px 内仍视为点击(打开对话框),双击复位到默认右下角。
- CSS 增加 cursor:grab/grabbing、touch-action:none、user-select:none,
  触屏也可拖动。
- 图标由 ✦ 替换为机器人 SVG(屏幕+天线+双眼+嘴),在紫蓝渐变圆底上
  更醒目,明确体现 AI 助手。

验证:tsc 对本项目改动文件无报错(db/vite/worker 的环境类型报错为既有问题,
与本改动无关)。

app/globals.css
app/page.tsx

index 394705d4c01ab9f6e9154bb72b01531e3124203e..5bcf868ec664db16409aa2bc3b9a53bff0eea63d 100644 (file)
@@ -25,7 +25,8 @@
 @media(max-width:1100px){.app-shell{padding-left:42px}.side-nav{display:none}.workspace{min-width:900px}.sticky-actions{left:42px}.top-actions small{display:none}.result-layout{grid-template-columns:1fr}.preview-main{grid-template-columns:1fr 260px}.mu-grid{grid-template-columns:repeat(2,1fr)}}
 
 /* 右下角浮动 AI 会话 */
-.ai-fab{position:fixed;right:22px;bottom:22px;z-index:80;width:54px;height:54px;border-radius:50%;border:0;background:linear-gradient(135deg,#6d5ee9,#0aa5e8);color:#fff;font-size:22px;box-shadow:0 8px 22px #0a85c355;display:grid;place-items:center}
+.ai-fab{position:fixed;right:22px;bottom:22px;z-index:80;width:54px;height:54px;border-radius:50%;border:0;background:linear-gradient(135deg,#6d5ee9,#0aa5e8);color:#fff;font-size:22px;box-shadow:0 8px 22px #0a85c355;display:grid;place-items:center;cursor:grab;touch-action:none;user-select:none}
+.ai-fab:active{cursor:grabbing}
 .ai-fab:hover{transform:translateY(-1px);box-shadow:0 12px 26px #0a85c366}
 .ai-dialog{position:fixed;right:22px;bottom:88px;z-index:81;width:25vw;min-width:320px;height:70vh;min-height:420px;background:#fff;border:1px solid #d2d6d9;border-radius:10px;box-shadow:0 18px 48px #0003;display:flex;flex-direction:column;overflow:hidden}
 .ai-dialog header{display:flex;align-items:center;gap:9px;padding:10px 12px;border-bottom:1px solid #eceef0;background:linear-gradient(135deg,#6d5ee9,#0aa5e8);color:#fff}
index cb322d6bcc6af262558aa2cb02444729b466e569..e91b0afa4069fd66e655761f0e50e85e959b1c3f 100644 (file)
@@ -1,6 +1,6 @@
 "use client";
 
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
 
 type PageKey = "login" | "projects" | "basic" | "assets" | "functions" | "generate" | "result" | "preview" | "domains" | "domainDetail";
 type ModalKey = "" | "feedback" | "download" | "history" | "prompt";
@@ -110,6 +110,37 @@ export default function Home(){
   const [model,setModel]=useState("Hy3");
   const [modelOpen,setModelOpen]=useState(false);
   const [aiOpen,setAiOpen]=useState(false);
+  const [fabPos,setFabPos]=useState<{x:number;y:number}|null>(()=>{ try{ const s=localStorage.getItem("ai-fab-pos"); return s?JSON.parse(s):null; }catch{ return null; } });
+  const fabRef=useRef<HTMLButtonElement|null>(null);
+  const dragState=useRef<{active:boolean;startX:number;startY:number;origX:number;origY:number;moved:boolean}|null>(null);
+  const lastPosRef=useRef<{x:number;y:number}|null>(null);
+  const onFabPointerDown=(e:ReactPointerEvent)=>{
+    const btn=fabRef.current; if(!btn) return;
+    const r=btn.getBoundingClientRect();
+    dragState.current={active:true,startX:e.clientX,startY:e.clientY,origX:r.left,origY:r.top,moved:false};
+    try{ btn.setPointerCapture(e.pointerId); }catch{}
+  };
+  const onFabPointerMove=(e:ReactPointerEvent)=>{
+    const ds=dragState.current; if(!ds||!ds.active) return;
+    const dx=e.clientX-ds.startX, dy=e.clientY-ds.startY;
+    if(Math.abs(dx)>4||Math.abs(dy)>4) ds.moved=true;
+    if(!ds.moved) return;
+    const btn=fabRef.current!; const w=btn.offsetWidth, h=btn.offsetHeight;
+    let x=Math.max(0,Math.min(ds.origX+dx, window.innerWidth-w));
+    let y=Math.max(0,Math.min(ds.origY+dy, window.innerHeight-h));
+    const p={x,y}; lastPosRef.current=p; setFabPos(p);
+  };
+  const onFabPointerUp=(e:ReactPointerEvent)=>{
+    const ds=dragState.current; dragState.current=null;
+    try{ fabRef.current?.releasePointerCapture(e.pointerId); }catch{}
+    if(!ds) return;
+    if(ds.moved){
+      if(lastPosRef.current) localStorage.setItem("ai-fab-pos", JSON.stringify(lastPosRef.current));
+    } else {
+      setAiOpen(true);
+    }
+  };
+  const resetFab=()=>{ setFabPos(null); lastPosRef.current=null; try{ localStorage.removeItem("ai-fab-pos"); }catch{} };
   const [aiMessages,setAiMessages]=useState<{role:"user"|"assistant";content:string}[]>([]);
   const [aiDraft,setAiDraft]=useState("");
   const [aiBusy,setAiBusy]=useState(false);
@@ -315,7 +346,18 @@ export default function Home(){
       {page==="preview"&&<Preview site={site} tab={previewTab} setTab={setPreviewTab} setModal={setModal} notify={notify}/>}
     </div>{(["basic","assets","functions"] as PageKey[]).includes(page)&&<div className="sticky-actions"><Button secondary onClick={()=>go(page==="basic"?"projects":page==="assets"?"basic":"assets")}>上一步</Button><div><Button secondary onClick={()=>notify("草稿已保存")}>保存草稿</Button><Button onClick={()=>go(page==="basic"?"assets":page==="assets"?"functions":"generate")}>下一步</Button></div></div>}</main>
     {toast&&<div className="toast"><b>✓</b>{toast}</div>}{modal&&<Modal type={modal} close={()=>setModal("")} notify={notify} promptData={promptData}/>}
-    {!aiOpen&&<button className="ai-fab" title="AI 会话" onClick={()=>setAiOpen(true)}>✦</button>}
+    {!aiOpen&&<button ref={fabRef} className="ai-fab" title="AI 助手 · 拖动可移动位置,双击复位" aria-label="AI 助手"
+      style={fabPos?{left:fabPos.x,top:fabPos.y,right:"auto",bottom:"auto"}:undefined}
+      onPointerDown={onFabPointerDown} onPointerMove={onFabPointerMove} onPointerUp={onFabPointerUp} onDoubleClick={resetFab}>
+      <svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
+        <rect x="4" y="8" width="16" height="11" rx="3"/>
+        <path d="M12 8V4"/>
+        <circle cx="12" cy="3" r="1.4" fill="currentColor" stroke="none"/>
+        <circle cx="9" cy="13" r="1.3" fill="currentColor" stroke="none"/>
+        <circle cx="15" cy="13" r="1.3" fill="currentColor" stroke="none"/>
+        <path d="M9.5 16h5"/>
+      </svg>
+    </button>}
     {aiOpen&&<div className="ai-dialog">
       <header><span className="ai-dialog-logo">✦</span><div className="ai-dialog-title"><b>StationBuilder AI</b><small>模型 · {model}</small></div><button className="ai-close" onClick={()=>setAiOpen(false)}>×</button></header>
       <div className="ai-messages">