Archived
样式整文件搬(README 平移规则 1):app.css 2767 行 + proto.vendor.css + theme.css + console.css 原样搬,设计令牌与全部 jc-* / g-* 类名不动。 前端(按 §9 重排,渲染输出 1:1): - shared/:Ico / ScrollArea / useChatWidth 自原型搬,去掉 "use client" 等 Next 专有件; 失效的 react-hooks disable 指令按 §17 清掉(不改渲染) - shared/typed-fetch.ts:服务端交互唯一通道,路由契约来自 @contracts/api; 不引 Hono RPC 客户端(那要求前端 import 后端类型,违反 §5.1) - app/query.ts:TanStack Query;缓存失效由订阅层驱动,组件不轮询 - features/desk-task:任务列表(一条 = 一个活动的一次运行,不是一整条工作流)/ 标题区 + 工作区四页签 / 对话区(气泡二元制 + 卡族 + **composer 锁**) - @ui:ViewContainer 用**沙箱 iframe(sandbox 不含 allow-scripts)** —— 结构上无脚本执行权, 不依赖净化算法的完备性;ViewBlocks 渲染十一种视图块 feature 不得自行 dangerouslySetInnerHTML,eslint 有规则守着 后端:工作台路由 /api/desk/*(任务清单 / 任务详情 / 工单回传 / 自由输入 / 消息); 回传与后端校验**同 schema**;任务详情是服务端投影 —— 前端零过程状态,刷新即重建 已验证(真机):任务卡「A1 RFQ需求分析 · 序位 1/7 · awaiting-event」、 预算 2/40 步、1 张工单(LLM 步失败后转人兜底那张)、7 行带归侧标签的任务日志 358 个测试通过,门禁五道全绿;web 构建通过 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
101 lines
4.2 KiB
TypeScript
101 lines
4.2 KiB
TypeScript
// 自绘滚动条容器:原生滚动条彻底隐藏(零占位——出现/消失永不挤版面,也绕开 Blink
|
|
// 对滚动条伪元素动态切换不重绘的 quirk),自绘 thumb 悬浮在内衬/列间隔上。
|
|
// 行为契约:鼠标移入滚动区立即现身;移出 / 停止滚动 800ms 后淡出;thumb 可拖拽。
|
|
// 用法:外层 .jc-sa 接管原容器的布局角色(className 传 jc-sa--fill / jc-sa--full),
|
|
// 原滚动容器的类名整体挪到 vpClassName(视口 = 真正滚动的元素,样式不变);
|
|
// inset = thumb 距右缘 px(负值 = 伸出容器、悬进列间 gutter)。
|
|
import { useCallback, useEffect, useRef } from "react";
|
|
|
|
const HIDE_MS = 800;
|
|
|
|
export default function ScrollArea({ className = "", vpClassName = "", inset = 3, scrollRef, children }: {
|
|
className?: string;
|
|
vpClassName?: string;
|
|
inset?: number;
|
|
scrollRef?: React.Ref<HTMLElement | null>; // 上层需要直接操作滚动位置时(如会话流自动滚底)
|
|
children: React.ReactNode;
|
|
}) {
|
|
const vpRef = useRef<HTMLDivElement | null>(null);
|
|
const thRef = useRef<HTMLDivElement | null>(null);
|
|
const hideT = useRef(0);
|
|
const st = useRef({ inside: false, dragging: false, startY: 0, startTop: 0 });
|
|
|
|
// 按滚动几何同步 thumb 高度与位置;不可滚时整体隐藏
|
|
const sync = useCallback(() => {
|
|
const vp = vpRef.current, th = thRef.current;
|
|
if (!vp || !th) return;
|
|
const { scrollTop, scrollHeight, clientHeight } = vp;
|
|
if (scrollHeight <= clientHeight + 1) { th.style.display = "none"; return; }
|
|
th.style.display = "";
|
|
const h = Math.max(28, Math.round((clientHeight / scrollHeight) * clientHeight));
|
|
const y = Math.round((scrollTop / (scrollHeight - clientHeight)) * (clientHeight - h));
|
|
th.style.height = `${h}px`;
|
|
th.style.transform = `translateY(${y}px)`;
|
|
}, []);
|
|
|
|
// 移出/停止滚动后延时淡出(悬停内 / 拖拽中不隐藏)
|
|
const hideSoon = useCallback(() => {
|
|
window.clearTimeout(hideT.current);
|
|
hideT.current = window.setTimeout(() => {
|
|
if (!st.current.inside && !st.current.dragging) thRef.current?.classList.remove("on");
|
|
}, HIDE_MS);
|
|
}, []);
|
|
|
|
const show = useCallback(() => {
|
|
sync();
|
|
thRef.current?.classList.add("on");
|
|
hideSoon();
|
|
}, [sync, hideSoon]);
|
|
|
|
useEffect(() => {
|
|
const vp = vpRef.current;
|
|
if (!vp) return;
|
|
const ro = new ResizeObserver(sync); // 面板拖宽/视口变化时校准
|
|
ro.observe(vp);
|
|
sync();
|
|
return () => { ro.disconnect(); window.clearTimeout(hideT.current); };
|
|
}, [sync]);
|
|
|
|
// 标准 ref 转发(写入外部 ref.current 是 ref 的本职),compiler 规则误报
|
|
const setVp = useCallback((el: HTMLDivElement | null) => {
|
|
vpRef.current = el;
|
|
if (typeof scrollRef === "function") scrollRef(el);
|
|
else if (scrollRef) (scrollRef as React.MutableRefObject<HTMLElement | null>).current = el;
|
|
}, [scrollRef]);
|
|
|
|
// thumb 拖拽滚动
|
|
const onThumbDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
const vp = vpRef.current;
|
|
if (!vp) return;
|
|
e.preventDefault();
|
|
st.current = { ...st.current, dragging: true, startY: e.clientY, startTop: vp.scrollTop };
|
|
thRef.current?.classList.add("on");
|
|
const move = (ev: PointerEvent) => {
|
|
const { scrollHeight, clientHeight } = vp;
|
|
const h = thRef.current?.offsetHeight ?? 28;
|
|
vp.scrollTop = st.current.startTop + (ev.clientY - st.current.startY) * ((scrollHeight - clientHeight) / Math.max(1, clientHeight - h));
|
|
};
|
|
const up = () => {
|
|
st.current.dragging = false;
|
|
window.removeEventListener("pointermove", move);
|
|
window.removeEventListener("pointerup", up);
|
|
hideSoon();
|
|
};
|
|
window.addEventListener("pointermove", move);
|
|
window.addEventListener("pointerup", up);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={"jc-sa " + className}
|
|
onPointerEnter={() => { st.current.inside = true; show(); }}
|
|
onPointerLeave={() => { st.current.inside = false; hideSoon(); }}
|
|
>
|
|
<div ref={setVp} className={"jc-sa-vp " + vpClassName} onScroll={show}>
|
|
{children}
|
|
</div>
|
|
<div ref={thRef} className="jc-sa-thumb" style={{ right: inset, display: "none" }} onPointerDown={onThumbDown} />
|
|
</div>
|
|
);
|
|
}
|