This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-MVP/apps/web/src/shared/ScrollArea.tsx
T
JCAI Main AgentandClaude Opus 5 3cfaa04098 M6(1/n): 工作台任务要素 —— 原型 1:1 平移的第一屏
样式整文件搬(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
2026-07-27 02:45:42 +08:00

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>
);
}