Commit 4a58cd0c by luoqi

feat(web): 详情 TopBar 加退出登录 + 助手悬浮钮可拖移

- 退出:TopBar 最右(头像后)LogOut 钮,与原列表页同口径(clear token → AuthGate 弹回登录)。
- 助手钮:pointer 拖动,4px 位移阈值区分点击/拖动(拖完不误开窗);
  位置 localStorage 记忆(pac-assistant-fab-pos),clamp 视口内;默认右下角。

web tsc 0 + Next 生产构建过。仅本地,未部署。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 0ebda9bc
'use client'; 'use client';
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Bot } from 'lucide-react'; import { Bot } from 'lucide-react';
import { Permission } from '@pac/types'; import { Permission } from '@pac/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
...@@ -44,17 +44,81 @@ export function AssistantWidget() { ...@@ -44,17 +44,81 @@ export function AssistantWidget() {
> >
<AssistantChat variant="widget" onClose={() => setOpen(false)} examples={examples} /> <AssistantChat variant="widget" onClose={() => setOpen(false)} examples={examples} />
</div> </div>
{/* 悬浮开关钮 */} {/* 悬浮开关钮(可拖移:位移 >4px 判定为拖,松手即记忆位置;否则当点击打开) */}
{!open && ( {!open && <DraggableFab onOpen={() => setOpen(true)} />}
<button
type="button"
onClick={() => setOpen(true)}
title="打开助手"
className="fixed bottom-4 right-4 z-40 inline-flex h-12 w-12 items-center justify-center rounded-full bg-teal-600 text-white shadow-lg transition-transform hover:scale-105 hover:bg-teal-700"
>
<Bot className="h-5 w-5" />
</button>
)}
</> </>
); );
} }
const FAB_POS_KEY = 'pac-assistant-fab-pos';
/** 可拖移悬浮钮:pointer 事件拖动(阈值 4px 区分点击),位置 localStorage 记忆并随窗口缩放夹紧。 */
function DraggableFab({ onOpen }: { onOpen: () => void }) {
const [pos, setPos] = useState<{ x: number; y: number } | null>(null); // null = 默认右下角
const drag = useRef<{ startX: number; startY: number; baseX: number; baseY: number; moved: boolean } | null>(null);
const btnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
try {
const raw = localStorage.getItem(FAB_POS_KEY);
if (raw) setPos(clampPos(JSON.parse(raw) as { x: number; y: number }));
} catch {
/* 忽略坏数据 */
}
}, []);
const onPointerDown = (e: React.PointerEvent) => {
const r = btnRef.current!.getBoundingClientRect();
drag.current = { startX: e.clientX, startY: e.clientY, baseX: r.left, baseY: r.top, moved: false };
btnRef.current!.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: React.PointerEvent) => {
const d = drag.current;
if (!d) return;
const dx = e.clientX - d.startX;
const dy = e.clientY - d.startY;
if (!d.moved && Math.hypot(dx, dy) < 4) return;
d.moved = true;
setPos(clampPos({ x: d.baseX + dx, y: d.baseY + dy }));
};
const onPointerUp = () => {
const d = drag.current;
drag.current = null;
if (!d) return;
if (d.moved) {
setPos((p) => {
if (p) localStorage.setItem(FAB_POS_KEY, JSON.stringify(p));
return p;
});
} else {
onOpen();
}
};
return (
<button
ref={btnRef}
type="button"
title="打开助手(可拖动)"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
style={pos ? { left: pos.x, top: pos.y } : undefined}
className={cn(
'fixed z-40 inline-flex h-12 w-12 touch-none items-center justify-center rounded-full bg-teal-600 text-white shadow-lg hover:bg-teal-700',
!pos && 'bottom-4 right-4',
)}
>
<Bot className="h-5 w-5" />
</button>
);
}
function clampPos(p: { x: number; y: number }): { x: number; y: number } {
const size = 48; // h-12 w-12
return {
x: Math.min(Math.max(p.x, 4), window.innerWidth - size - 4),
y: Math.min(Math.max(p.y, 4), window.innerHeight - size - 4),
};
}
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { RefreshCw, ChevronDown, ThumbsUp, ThumbsDown } from 'lucide-react'; import { RefreshCw, ChevronDown, ThumbsUp, ThumbsDown, LogOut } from 'lucide-react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
...@@ -829,6 +829,16 @@ function TopBar({ ...@@ -829,6 +829,16 @@ function TopBar({
<span className="inline-flex h-8 w-8 flex-none items-center justify-center rounded-full bg-gradient-to-br from-teal-400 to-teal-600 text-[12px] font-bold text-white"> <span className="inline-flex h-8 w-8 flex-none items-center justify-center rounded-full bg-gradient-to-br from-teal-400 to-teal-600 text-[12px] font-bold text-white">
{userDisplayName(user).charAt(0).toUpperCase()} {userDisplayName(user).charAt(0).toUpperCase()}
</span> </span>
{/* 退出登录(自原列表页迁来;清 token → AuthGate 自动弹回登录) */}
<button
type="button"
title="退出登录"
aria-label="退出登录"
onClick={() => useAuthStore.getState().clear()}
className="inline-flex h-8 w-8 flex-none items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
>
<LogOut className="h-4 w-4" />
</button>
</div> </div>
</header> </header>
); );
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment