docs(app): explain common API session workspace and geometry helpers

This commit is contained in:
2026-11-12 01:36:26 +08:00
parent c488bc86b6
commit aacafa7a9b
8 changed files with 103 additions and 0 deletions
+20
View File
@@ -1,3 +1,9 @@
/**
* 应用通用JSON/FormData请求与登录跳转辅助。请求默认经过同源Foundry代理,错误封装为ApiError,
* 界面通过errorText映射中文说明;业务schema和账户代次等更强校验由各领域适配器处理。
* 本函数本身不设置超时或自动重试,取消依赖传入signal,代理传输时限在对应路由/响应封装中管理。
*/
/** 账户资料响应的静态类型,不能用它代替登录会话有效性或权限校验。 */
export type Profile = {
id: string;
username: string;
@@ -5,20 +11,25 @@ export type Profile = {
email: string | null;
avatar_url?: string | null;
};
/** 保留HTTP状态、服务details和原Retry-After头,message使用稳定错误码,供上层决定显示/重试。 */
export class ApiError extends Error {
readonly status: number;
readonly details?: unknown;
readonly retryAfter?: string | null;
/** 以Error兼容方式封装服务错误,不把details自动拼进面向用户文案。 */
constructor(code: string, status: number, details?: unknown, retryAfter?: string | null) {
super(code); this.name = "ApiError"; this.status = status; this.details = details; this.retryAfter = retryAfter;
}
}
/** 泛型T只是调用方的响应形状声明,不执行运行时schema检查。
* 204返回null;其它响应必须可解析JSON,非2xx统一抛ApiError,成功body直接交业务层验证。 */
export async function api<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const res = await fetch("/api/foundry/" + path, {
...options,
// FormData让浏览器生成boundary,其它请求默认JSON;调用方headers可覆盖默认Content-Type。
headers: { ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...options.headers },
});
let body: unknown;
@@ -26,21 +37,27 @@ export async function api<T>(
else {
try { body = await res.json(); }
catch (error) {
// 消费body失败时优先传播明确取消原因;语法坏JSON与传输中断使用不同错误码。
if (options.signal?.aborted) throw options.signal.reason;
const malformed = error instanceof SyntaxError;
throw new ApiError(malformed ? "invalid_api_response" : "response_interrupted", res.ok ? 502 : res.status);
}
}
const payload = body as { error?: string | { code?: string }; details?: unknown } | null;
// 服务error兼容字符串或{code},无可用码则request_failed,并保留Retry-After原值供上层解释。
if (!res.ok) {
const code = typeof payload?.error === "object" ? payload.error?.code : payload?.error;
throw new ApiError(typeof code === "string" ? code : "request_failed", res.status, payload?.details, res.headers.get("retry-after"));
}
return body as T;
}
/** 公开项目标识,来自构建时NEXT_PUBLIC配置;不是密码或用户账户身份。 */
export const PROJECT = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
/** 创建PKCE verifier和SHA-256 challenge:verifier留在当前标签页sessionStorage,
* 只把challenge带到同源OAuth入口,后续交换流程负责取回verifier。此处不直接取得令牌。 */
export async function startOAuth(provider: "google" | "slack") {
const bytes = crypto.getRandomValues(new Uint8Array(32));
// 32随机字节编码为无填充base64url,避免普通base64字符影响URL参数。
const verifier = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
@@ -53,11 +70,14 @@ export async function startOAuth(provider: "google" | "slack") {
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
// 页面跳转前保存原verifier;使用单一固定键,同标签页后一次发起会替换上一次值。
sessionStorage.setItem("foundry_verifier", verifier);
window.location.assign("/api/oauth/" + provider + "?challenge=" + challenge);
}
/** 应用错误码→用户文案适配,不改变任务状态或执行恢复;未知错误给通用提示,不直接泄漏内部异常文本。 */
export const errorText = (error: unknown) => {
const code = error instanceof Error ? error.message : "";
// 文案只解释既有领域状态;“保留/暂停”等实际行为须由触发错误的模块保证,不能由映射表证明。
const messages: Record<string, string> = {
invalid_model_stream: "画布返回的数据无法解析,请重新加载。",
incomplete_model_stream: "画布数据未加载完整,请重新加载。",
+16
View File
@@ -1,15 +1,28 @@
/**
* 旧贝塞尔连线绘制与插入控制结点。基础分段复用core几何实现,插入用de Casteljau细分保持原曲线形状,
* 最近位置用有限采样近似,不把编辑辅助采样当作严格命中查询或精确最近点解算。
*/
import {bezierChainSegments} from './canvas-core/geometry.ts';
import type { XYPosition } from "@xyflow/react";
import type { Connector } from "./canvas-geometry";
import type { EdgeData } from "./canvas-v2";
/** 一段三次贝塞尔的起点、两控制点和终点,全部处在同一显示坐标空间。 */
export type Cubic = [XYPosition, XYPosition, XYPosition, XYPosition];
/** 点/向量相加,用于把相对控制向量还原成绝对控制点。 */
export const plus = (a: XYPosition, b: XYPosition) => ({ x: a.x + b.x, y: a.y + b.y });
/** 差向量,用于把细分后的控制点转换回结点相对切线。 */
export const minus = (a: XYPosition, b: XYPosition) => ({ x: a.x - b.x, y: a.y - b.y });
/** 线性插值,供de Casteljau逐层构造等价三次子曲线。 */
const lerp = (a: XYPosition, b: XYPosition, t: number) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
/** 从端点法线和可选贝塞尔链获得统一三次段列表,不单独复制另一套路径解释规则。 */
export function curveSegments(from:Connector,to:Connector,path?:EdgeData["path"]):Cubic[]{return bezierChainSegments(from,to,path) as Cubic[];}
/** 在参数t处用三层线性插值求曲线上一点,不修改原控制点。 */
export function cubicPoint([a, b, c, d]: Cubic, t: number) { const ab = lerp(a, b, t), bc = lerp(b, c, t), cd = lerp(c, d, t); return lerp(lerp(ab, bc, t), lerp(bc, cd, t), t); }
/** 将非空段列表拼成一个M及多个C命令;调用方负责提供有效几何和至少一段。 */
export function curvePath(segments: Cubic[]) { return `M ${segments[0][0].x},${segments[0][0].y} ` + segments.map(([, b, c, d]) => `C ${b.x},${b.y} ${c.x},${c.y} ${d.x},${d.y}`).join(" "); }
/** Split the closest segment without altering its shape. */
/** 给定指针时对每段内部39个采样点选最近者,无指针默认中间段t=.5。
* 选点是近似,按所选t进行的曲线细分本身保持形状;返回新path与新UUID结点,不直接改history。 */
export function insertCurveKnot(from: Connector, to: Connector, path: EdgeData["path"], point?: XYPosition) {
const segments = curveSegments(from, to, path);
let selected = Math.floor(segments.length / 2), t = .5, distance = Infinity;
@@ -17,13 +30,16 @@ export function insertCurveKnot(from: Connector, to: Connector, path: EdgeData["
const p = cubicPoint(segment, step / 40), d = Math.hypot(p.x - point.x, p.y - point.y);
if (d < distance) { distance = d; selected = index; t = step / 40; }
} });
// 逐层插值求左右子曲线,anchor成为新结点,abc/bcd决定其两侧相对切线。
const [a, b, c, d] = segments[selected];
const ab = lerp(a, b, t), bc = lerp(b, c, t), cd = lerp(c, d, t), abc = lerp(ab, bc, t), bcd = lerp(bc, cd, t), anchor = lerp(abc, bcd, t);
const next = structuredClone(path || { type: "bezier-chain" as const, knots: [] });
const knot = { id: crypto.randomUUID(), position: anchor, in: minus(abc, anchor), out: minus(bcd, anchor), mode: "smooth" as const };
// 同时调整相邻旧结点或首尾控制向量,否则插入后无法保留原曲线。
if (selected) next.knots[selected - 1].out = minus(ab, a); else next.startControl = minus(ab, a);
if (selected < next.knots.length) next.knots[selected].in = minus(cd, d); else next.endControl = minus(cd, d);
// Splitting one side of an existing symmetric knot can make its handle lengths differ.
// 仅细分一侧会破坏旧对称结点等长约束,降为smooth保留共线而不强行改变曲线。
for (const index of [selected - 1, selected]) if (next.knots[index]?.mode === "symmetric") next.knots[index].mode = "smooth";
next.knots.splice(selected, 0, knot); return { path: next, knot };
}
+23
View File
@@ -1,23 +1,36 @@
/**
* 旧卡片/分区的二维交互几何。输入是显示节点及其起始几何,纯函数返回位置/尺寸/角度或锚点,
* 不修改节点、history或DOM;新版registry几何协议与旧布局约定分开管理。
*/
import type {CanvasGeometryNode as CanvasNode} from "./canvas-bindings/compat-types.ts";
/** 旧几何计算的二维点/向量,具体坐标空间由调用者保证一致。 */
type XYPosition={x:number;y:number};
/** 八向缩放手柄,边手柄只影响一轴,角手柄同时影响两轴。 */
export type ResizeDirection = "top" | "right" | "bottom" | "left" | "top-left" | "top-right" | "bottom-left" | "bottom-right";
/** 返回的几何修改提案,可包含位置、尺寸、旋转或字号,不直接应用到文档。 */
export type NodeTransform = { position?: XYPosition; width?: number; height?: number; rotation?: number; fontSize?: number };
/** 旧尺寸优先级:显式width/height→可转数字style尺寸→measured→按kind默认值。
* 使用||回退,零值视作未就绪;不是完整的几何有效性验证器。 */
export function nodeSize(node: CanvasNode) {
return {
width: node.width || Number(node.style?.width) || node.measured?.width || (node.data.kind === "frame" ? 640 : node.data.kind === "plain-image" ? 320 : node.data.kind === "icon" ? 80 : node.data.kind === "plain-text" ? 240 : 260),
height: node.height || Number(node.style?.height) || node.measured?.height || (node.data.kind === "frame" ? 400 : node.data.kind === "plain-image" ? 240 : node.data.kind === "icon" ? 80 : node.data.kind === "plain-text" ? 72 : 200),
};
}
/** 把有限角度按360度周期规约到[0,360),负数也归入正向区间;输入合法性由调用方负责。 */
export function normalizeRotation(angle: number) { return ((angle % 360) + 360) % 360; }
/** 围绕原点旋转向量,角度为度,公式内换弧度;平移量不能混进此函数。 */
export function rotateVector(point: XYPosition, angle: number): XYPosition {
const radians = angle * Math.PI / 180;
return { x: point.x * Math.cos(radians) - point.y * Math.sin(radians), y: point.x * Math.sin(radians) + point.y * Math.cos(radians) };
}
/** 用未旋转布局矩形求中心,旋转仍围绕这个中心,不改其位置。 */
export function nodeCenter(node: CanvasNode): XYPosition {
const { width, height } = nodeSize(node);
return { x: node.position.x + width / 2, y: node.position.y + height / 2 };
}
/** 比较起始/当前指针相对中心的极角并叠加原旋转;snap时吸附到15度,最后归一化。 */
export function rotationFromPointer(node: CanvasNode, start: XYPosition, current: XYPosition, snap = false) {
const center = nodeCenter(node);
const startAngle = Math.atan2(start.y - center.y, start.x - center.x);
@@ -26,20 +39,25 @@ export function rotationFromPointer(node: CanvasNode, start: XYPosition, current
if (snap) rotation = Math.round(rotation / 15) * 15;
return normalizeRotation(rotation);
}
/** 把与节点同坐标空间的delta反旋到节点局部轴,按手柄方向缩放,再旋回中心位移保持对侧锚定。
* 原节点应是手势起点快照,不能每次用上一帧结果继续累计同一个总delta。 */
export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection, delta: XYPosition, lockAspect?: boolean): NodeTransform {
const { width, height } = nodeSize(node);
const rotation = node.data.rotation || 0;
// 指针位移先消除节点旋转,才能区分拖动的是本地宽还是高。
const local = rotateVector(delta, -rotation);
const horizontal = direction.includes("left") ? -1 : direction.includes("right") ? 1 : 0;
const vertical = direction.includes("top") ? -1 : direction.includes("bottom") ? 1 : 0;
const plain = node.data.kind === "plain-text";
const icon = node.data.kind === "icon" || node.data.kind === "plain-image";
const viewScale = node.data.viewScale || 1;
// 约束随显示viewScale缩放;frame可更大,普通卡片/文字/图标最小尺寸各有不同。
const maximum = node.data.kind === "frame" ? 100000 * viewScale : 3000 * viewScale;
const minWidth = (node.data.kind === "frame" ? 32 : icon ? 24 : plain ? 60 : 140) * viewScale;
const minHeight = (node.data.kind === "frame" ? 32 : icon ? 24 : plain ? 24 : 100) * viewScale;
let nextWidth = Math.max(minWidth, Math.min(maximum, width + horizontal * local.x));
let nextHeight = Math.max(minHeight, Math.min(maximum, height + vertical * local.y));
// icon和纯图片默认锁比例,调用方可显式开关;取偏离1更大的缩放轴,结合双边范围算候选,最终尺寸仍经命令/格式校验。
if (lockAspect ?? icon) {
const sx = 1 + horizontal * local.x / width;
const sy = 1 + vertical * local.y / height;
@@ -49,6 +67,7 @@ export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection,
nextHeight = height * scale;
}
const center = nodeCenter(node);
// 半尺寸差在局部轴上移动中心,再旋回场景,避免只改宽高导致固定对边漂移。
const shift = rotateVector({ x: horizontal * (nextWidth - width) / 2, y: vertical * (nextHeight - height) / 2 }, rotation);
return {
position: { x: center.x + shift.x - nextWidth / 2, y: center.y + shift.y - nextHeight / 2 },
@@ -56,9 +75,12 @@ export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection,
};
}
/** 卡片四边连接位置名,与模型中可扩展端口ID不是同一概念。 */
export type ConnectorSide = "top" | "right" | "bottom" | "left";
/** 锚点位置与向外单位法线,供贝塞尔控制点计算。 */
export type Connector = { point: XYPosition; normal: XYPosition };
/** Attach to the card border itself, independent of the handle's hit area. */
/** 在卡片真实边框中点取点并随节点旋转法线/偏移,忽略DOM手柄较大的点击热区。 */
export function nodeConnector(node: CanvasNode, side: ConnectorSide): Connector {
const { width, height } = nodeSize(node);
const vectors = { top: { x: 0, y: -1 }, right: { x: 1, y: 0 }, bottom: { x: 0, y: 1 }, left: { x: -1, y: 0 } };
@@ -69,6 +91,7 @@ export function nodeConnector(node: CanvasNode, side: ConnectorSide): Connector
const center = nodeCenter(node);
return { point: { x: center.x + offset.x, y: center.y + offset.y }, normal };
}
/** 用两端朝外法线构造单段SVG三次曲线,控制伸展按端距40%并夹到40~240显示单位。 */
export function connectorBezier(from: Connector, to: Connector) {
const reach = Math.min(240, Math.max(40, Math.hypot(to.point.x - from.point.x, to.point.y - from.point.y) * 0.4));
return `M ${from.point.x},${from.point.y} C ${from.point.x + from.normal.x * reach},${from.point.y + from.normal.y * reach} ${to.point.x + to.normal.x * reach},${to.point.y + to.normal.y * reach} ${to.point.x},${to.point.y}`;
+6
View File
@@ -1,5 +1,11 @@
/**
* 本地演示模式的画布目录读取,与真实账户工作区服务分开。
* 这里只为可用演示数据补目录外壳,不进行真实board/schema/权限验证,也不把示例写入服务端。
*/
import type { Board } from "./workspace";
/** 优先读workspace对应localStorage数组,补工作区/kind和默认version;解析或读取失败回退三个样例页。
* 仅判定顶层数组,条目本身不做完整Board校验,不能复用于真实服务响应解码。 */
export function readDemoBoards(workspace: string): Board[] {
try {
const stored = JSON.parse(localStorage.getItem("endoleap_demo_boards_" + workspace) || "null");
+7
View File
@@ -1,10 +1,17 @@
/**
* 应用层Sonner通知适配。领域/editor包不依赖具体toast库,宿主在收到状态后选择调用此模块。
* use client界定通知只在客户端使用,函数不修改画布任务事实。
*/
"use client";
import { toast } from "sonner";
/** Application adapter. Domain/editor packages must not import Sonner. */
/** 稳定id用于更新/关闭同一通知,level决定显示样式,description是可选补充文案。 */
export type Notification = { id: string; title: string; description?: string; level?: "info" | "success" | "warning" | "error" };
/** 按指定level调用Sonner,默认info;相同id的合并/更新行为交给通知库。 */
export function notify({ id, title, description, level = "info" }: Notification) {
return toast[level](title, { id, description });
}
/** 按id关闭通知,不表示对应业务任务已取消或错误已恢复。 */
export function dismissNotification(id: string) { toast.dismiss(id); }
+9
View File
@@ -1,9 +1,18 @@
/**
* 服务端会话Cookie的纯字符串与寿命辅助,不读取或写入浏览器Cookie。
* 调用方决定何时采用上游过期提示、是否Secure,并把返回字符串放进响应Set-Cookie。
*/
/** 本应用默认空闲期上限30天,单位秒。 */
export const SESSION_IDLE_SECONDS = 30 * 24 * 60 * 60;
/** 上游向代理传递剩余会话寿命的响应头名,不是Cookie名。 */
export const SESSION_EXPIRY_HEADER = "X-Foundry-Session-Expires-In";
/** 数字或纯数字字符串可采用,非负安全整数封顶30天,0可用于清除。
* 不合法输入原样退回fallback;自定义fallback由调用方提供有效值,不在此再次封顶。 */
export function sessionMaxAge(value: unknown, fallback = SESSION_IDLE_SECONDS) {
const seconds = typeof value === "number" ? value : typeof value === "string" && /^\d+$/.test(value) ? Number(value) : NaN;
return Number.isSafeInteger(seconds) && seconds >= 0 ? Math.min(seconds, SESSION_IDLE_SECONDS) : fallback;
}
/** URL编码会话值,固定HttpOnly/SameSite=Lax/根路径并规范Max-Age;secure参数为true才添加Secure。 */
export function sessionCookie(value: string, maxAge: number, secure: boolean) {
return `foundry_session=${encodeURIComponent(value)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${sessionMaxAge(maxAge)}${secure ? "; Secure" : ""}`;
}
+4
View File
@@ -1,5 +1,9 @@
/**
* 应用样式类名组合的单一辅助入口,先将条件/数组形式归成字符串,再解决Tailwind类冲突。
*/
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** clsx负责布尔条件和嵌套类名,twMerge按其规则处理同类覆盖;不解析业务状态或生成CSS。 */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+18
View File
@@ -1,6 +1,13 @@
/**
* 工作区和画布目录的应用模型与CRUD辅助。这里保存路由/目录元数据约定,
* 不拥有画布内容、ProjectEngine或本地history;服务响应更强schema校验由相应调用路径负责。
*/
import type { Profile } from "./api";
/** 工作区目录项,slug可用于路由,role供UI显示/能力提示,最终权限仍由服务端决定。 */
export type Workspace = { id: string; slug?: string; name: string; role?: "admin" | "editor" | "viewer" };
/** 画布目录记录:id与workspace是真实身份,parent_board_id/frame_id表示旧子图入口关系。
* version是material元数据版本,不能自动当作canonical canvas版本或本地rootRevision。 */
export type Board = {
id: string;
workspace_id: string;
@@ -13,14 +20,19 @@ export type Board = {
parent_board_id?: string;
frame_id?: string;
};
/** 路由可用id或slug,空item/ref返回false;匹配本身不证明账户有访问权限。 */
export function matchesRef(item: { id: string; slug?: string } | null | undefined, ref: string | undefined) { return Boolean(item && ref && (item.id === ref || item.slug === ref)); }
/** 创建时空白标题回退值,与重命名必须非空的校验不同。 */
export const DEFAULT_BOARD_TITLE = "未命名图谱";
/** 分别编码workspace和board路径段,demo只追加明确查询开关。 */
export function boardHref(workspace: string, board: string, demo = false) {
return `/dashboard/${encodeURIComponent(workspace)}/${encodeURIComponent(board)}${demo ? "?demo=1" : ""}`;
}
/** 注入通用API能力,不在该库固定fetch、凭证或错误展示。 */
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
/** 并行读profile和工作区目录,兼容直接数组与{items}外壳;仅返回数据,不选择当前工作区。 */
export async function loadWorkspaceHome(
request: Request,
signal?: AbortSignal,
@@ -35,6 +47,8 @@ export async function loadWorkspaceHome(
};
}
/** 无workspaceId时先创建个人空间并回调宿主,再创建schema1旧画布目录项。
* 两个POST不是一笔原子事务,后一步失败不会自动删除已创建空间;标题trim后允许默认名。 */
export async function createBoard(
request: Request,
title: string,
@@ -51,6 +65,7 @@ export async function createBoard(
onWorkspaceCreated(workspace);
}
const name = title.trim() || DEFAULT_BOARD_TITLE;
// 创建响应可缺workspace_id时补用本次目标id,返回已确定的标题供界面更新目录。
const result = await request<{ id: string; workspace_id: string; kind: "board"; version: number; slug?: string }>("workspaces/" + id + "/materials", {
method: "POST",
body: JSON.stringify({
@@ -63,10 +78,13 @@ export async function createBoard(
}
/** trim并限制标题UTF-8字节≤300,先读取匹配的board,再用If-Match条件PATCH元数据。
* PATCH保留现有description/content;返回version按接口+1约定推导,不解析PATCH回执验证该版本。 */
export async function renameBoard(request: Request, workspaceId: string, boardId: string, title: string): Promise<Board> {
const name = title.trim();
if (!name || new TextEncoder().encode(name).length > 300) throw new Error("invalid_title");
const path = `workspaces/${encodeURIComponent(workspaceId)}/materials/${encodeURIComponent(boardId)}`;
// 校验读取的kind/id或slug/workspace,避免路由引用指向另一空间记录时误写。
const current = await request<Board>(path);
if (current.kind !== "board" || !matchesRef(current, boardId) || current.workspace_id !== workspaceId) throw new Error("invalid_reference");
await request(path, {