Merge remote-tracking branch 'origin/main' into wsh_dev
# Conflicts: # backend/api/server.py
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
accountSessionOutputRoot,
|
||||
accountSessionRoot,
|
||||
accountSessionScratchpadRoot,
|
||||
chownAccountPath,
|
||||
getCurrentAccount,
|
||||
} from "@/lib/claw-auth";
|
||||
|
||||
@@ -334,14 +335,16 @@ async function getLastUserText(
|
||||
}
|
||||
|
||||
async function ensureSessionDirectories(accountId: string, sessionId: string) {
|
||||
const sessionRoot = accountSessionRoot(accountId, sessionId);
|
||||
await Promise.all([
|
||||
mkdir(accountSessionRoot(accountId, sessionId), { recursive: true }),
|
||||
mkdir(sessionRoot, { recursive: true }),
|
||||
mkdir(accountSessionInputRoot(accountId, sessionId), { recursive: true }),
|
||||
mkdir(accountSessionOutputRoot(accountId, sessionId), { recursive: true }),
|
||||
mkdir(accountSessionScratchpadRoot(accountId, sessionId), {
|
||||
recursive: true,
|
||||
}),
|
||||
]);
|
||||
await chownAccountPath(accountId, sessionRoot, true);
|
||||
}
|
||||
|
||||
function renderSessionRuntimeContext(accountId: string, sessionId: string) {
|
||||
@@ -394,6 +397,7 @@ async function saveFilePart(
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
const filePath = path.join(uploadDir, `${Date.now()}-${filename}`);
|
||||
await writeFile(filePath, bytes);
|
||||
await chownAccountPath(accountId, filePath);
|
||||
|
||||
return [
|
||||
`[文件已上传] ${filename}`,
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const technicalDocs = {
|
||||
"01-base-runtime": {
|
||||
title: "基座 Runtime",
|
||||
file: "01-base-runtime.md",
|
||||
image: "/doc-assets/technical/01-base-runtime.png",
|
||||
},
|
||||
"02-agent-loop": {
|
||||
title: "Agent Loop",
|
||||
file: "02-agent-loop.md",
|
||||
image: "/doc-assets/technical/02-agent-loop.png",
|
||||
},
|
||||
"03-tools": {
|
||||
title: "工具体系",
|
||||
file: "03-tools.md",
|
||||
image: "/doc-assets/technical/03-tools.png",
|
||||
},
|
||||
"04-skills": {
|
||||
title: "Skill 体系",
|
||||
file: "04-skills.md",
|
||||
image: "/doc-assets/technical/04-skills.png",
|
||||
},
|
||||
"05-workspace-memory-observability": {
|
||||
title: "工作区、运行态和记忆",
|
||||
file: "05-workspace-memory-observability.md",
|
||||
image: "/doc-assets/technical/05-workspace-memory-observability.png",
|
||||
},
|
||||
"06-product-data": {
|
||||
title: "product-data",
|
||||
file: "06-product-data.md",
|
||||
image: "/doc-assets/technical/06-product-data.png",
|
||||
},
|
||||
"07-online-mining-v2": {
|
||||
title: "online-mining-v2",
|
||||
file: "07-online-mining-v2.md",
|
||||
image: "/doc-assets/technical/07-online-mining-v2.png",
|
||||
},
|
||||
"08-label-master": {
|
||||
title: "标签大师",
|
||||
file: "08-label-master.md",
|
||||
image: "/doc-assets/technical/08-label-master.png",
|
||||
},
|
||||
"09-external-skills": {
|
||||
title: "外部系统 Skill",
|
||||
file: "09-external-skills.md",
|
||||
image: "/doc-assets/technical/09-external-skills.png",
|
||||
},
|
||||
"10-memory-research": {
|
||||
title: "Agent 记忆机制调研与对比",
|
||||
file: "10-memory-research.md",
|
||||
image: null,
|
||||
},
|
||||
} as const;
|
||||
|
||||
type TechnicalDocSlug = keyof typeof technicalDocs;
|
||||
|
||||
function getDoc(slug: string) {
|
||||
if (slug in technicalDocs) {
|
||||
return technicalDocs[slug as TechnicalDocSlug];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function markdownPath(file: string) {
|
||||
return path.join(
|
||||
process.cwd(),
|
||||
"public",
|
||||
"doc-assets",
|
||||
"technical-docs",
|
||||
file,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMarkdown(markdown: string) {
|
||||
return markdown
|
||||
.replace(/^\s*#\s+.+\n+/, "")
|
||||
.replaceAll("](assets/", "](/doc-assets/technical/");
|
||||
}
|
||||
|
||||
function parseInline(text: string): ReactNode[] {
|
||||
const nodes: ReactNode[] = [];
|
||||
const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\[([^\]]+)\]\(([^)]+)\))/g;
|
||||
let cursor = 0;
|
||||
|
||||
while (true) {
|
||||
const match = pattern.exec(text);
|
||||
if (match === null) break;
|
||||
if (match.index > cursor) {
|
||||
nodes.push(text.slice(cursor, match.index));
|
||||
}
|
||||
|
||||
const token = match[0];
|
||||
const key = `${match.index}-${token}`;
|
||||
if (token.startsWith("`")) {
|
||||
nodes.push(<code key={key}>{token.slice(1, -1)}</code>);
|
||||
} else if (token.startsWith("**")) {
|
||||
nodes.push(<strong key={key}>{token.slice(2, -2)}</strong>);
|
||||
} else {
|
||||
const href = match[3] ?? "";
|
||||
const isExternal = /^https?:\/\//.test(href);
|
||||
nodes.push(
|
||||
<a
|
||||
href={href}
|
||||
key={key}
|
||||
rel={isExternal ? "noopener" : undefined}
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
>
|
||||
{match[2]}
|
||||
</a>,
|
||||
);
|
||||
}
|
||||
|
||||
cursor = pattern.lastIndex;
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
nodes.push(text.slice(cursor));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function splitTableRow(line: string) {
|
||||
return line
|
||||
.trim()
|
||||
.replace(/^\|/, "")
|
||||
.replace(/\|$/, "")
|
||||
.split("|")
|
||||
.map((cell) => cell.trim());
|
||||
}
|
||||
|
||||
function isTableDivider(line: string) {
|
||||
return /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line);
|
||||
}
|
||||
|
||||
function startsBlock(line: string, nextLine?: string) {
|
||||
return (
|
||||
line.startsWith("```") ||
|
||||
/^#{1,6}\s+/.test(line) ||
|
||||
/^[-*]\s+/.test(line) ||
|
||||
/^\d+\.\s+/.test(line) ||
|
||||
/^>\s?/.test(line) ||
|
||||
/^!\[[^\]]*]\([^)]+\)\s*$/.test(line) ||
|
||||
(line.includes("|") && nextLine !== undefined && isTableDivider(nextLine))
|
||||
);
|
||||
}
|
||||
|
||||
function renderHeading(depth: number, text: string, key: string) {
|
||||
if (depth === 1) return <h1 key={key}>{parseInline(text)}</h1>;
|
||||
if (depth === 2) return <h2 key={key}>{parseInline(text)}</h2>;
|
||||
if (depth === 3) return <h3 key={key}>{parseInline(text)}</h3>;
|
||||
if (depth === 4) return <h4 key={key}>{parseInline(text)}</h4>;
|
||||
if (depth === 5) return <h5 key={key}>{parseInline(text)}</h5>;
|
||||
return <h6 key={key}>{parseInline(text)}</h6>;
|
||||
}
|
||||
|
||||
function renderMarkdown(markdown: string) {
|
||||
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
||||
const blocks: ReactNode[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("```")) {
|
||||
const language = trimmed.slice(3).trim() || "text";
|
||||
const codeLines: string[] = [];
|
||||
index += 1;
|
||||
while (index < lines.length && !lines[index].trim().startsWith("```")) {
|
||||
codeLines.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
if (index < lines.length) index += 1;
|
||||
blocks.push(
|
||||
<div className="project-doc-code-block" key={`code-${index}`}>
|
||||
<div>{language}</div>
|
||||
<pre>
|
||||
<code>{codeLines.join("\n")}</code>
|
||||
</pre>
|
||||
</div>,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = trimmed.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (heading) {
|
||||
blocks.push(
|
||||
renderHeading(heading[1].length, heading[2], `heading-${index}`),
|
||||
);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const image = trimmed.match(/^!\[([^\]]*)]\(([^)]+)\)\s*$/);
|
||||
if (image) {
|
||||
blocks.push(
|
||||
<Image
|
||||
alt={image[1]}
|
||||
height={900}
|
||||
key={`image-${index}`}
|
||||
src={image[2]}
|
||||
width={1600}
|
||||
/>,
|
||||
);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
trimmed.includes("|") &&
|
||||
index + 1 < lines.length &&
|
||||
isTableDivider(lines[index + 1])
|
||||
) {
|
||||
const headers = splitTableRow(trimmed);
|
||||
index += 2;
|
||||
const rows: string[][] = [];
|
||||
while (index < lines.length && lines[index].trim().includes("|")) {
|
||||
rows.push(splitTableRow(lines[index]));
|
||||
index += 1;
|
||||
}
|
||||
blocks.push(
|
||||
<table key={`table-${index}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
{headers.map((cell) => (
|
||||
<th key={cell}>{parseInline(cell)}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.join("|")}>
|
||||
{row.map((cell) => (
|
||||
<td key={`${row.join("|")}-${cell}`}>{parseInline(cell)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^[-*]\s+/.test(trimmed)) {
|
||||
const items: string[] = [];
|
||||
while (index < lines.length && /^[-*]\s+/.test(lines[index].trim())) {
|
||||
items.push(lines[index].trim().replace(/^[-*]\s+/, ""));
|
||||
index += 1;
|
||||
}
|
||||
blocks.push(
|
||||
<ul key={`ul-${index}`}>
|
||||
{items.map((item) => (
|
||||
<li key={item}>{parseInline(item)}</li>
|
||||
))}
|
||||
</ul>,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\d+\.\s+/.test(trimmed)) {
|
||||
const items: string[] = [];
|
||||
while (index < lines.length && /^\d+\.\s+/.test(lines[index].trim())) {
|
||||
items.push(lines[index].trim().replace(/^\d+\.\s+/, ""));
|
||||
index += 1;
|
||||
}
|
||||
blocks.push(
|
||||
<ol key={`ol-${index}`}>
|
||||
{items.map((item) => (
|
||||
<li key={item}>{parseInline(item)}</li>
|
||||
))}
|
||||
</ol>,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^>\s?/.test(trimmed)) {
|
||||
const quoteLines: string[] = [];
|
||||
while (index < lines.length && /^>\s?/.test(lines[index].trim())) {
|
||||
quoteLines.push(lines[index].trim().replace(/^>\s?/, ""));
|
||||
index += 1;
|
||||
}
|
||||
blocks.push(
|
||||
<blockquote key={`quote-${index}`}>
|
||||
{parseInline(quoteLines.join(" "))}
|
||||
</blockquote>,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const paragraphLines = [trimmed];
|
||||
index += 1;
|
||||
while (
|
||||
index < lines.length &&
|
||||
lines[index].trim() &&
|
||||
!startsBlock(lines[index].trim(), lines[index + 1]?.trim())
|
||||
) {
|
||||
paragraphLines.push(lines[index].trim());
|
||||
index += 1;
|
||||
}
|
||||
blocks.push(
|
||||
<p key={`p-${index}`}>{parseInline(paragraphLines.join(" "))}</p>,
|
||||
);
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return Object.keys(technicalDocs).map((slug) => ({ slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const doc = getDoc(slug);
|
||||
if (!doc) return {};
|
||||
return {
|
||||
title: `${doc.title} - ZK Data Agent`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TechnicalDocPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const doc = getDoc(slug);
|
||||
if (!doc) notFound();
|
||||
|
||||
const rawMarkdown = await readFile(markdownPath(doc.file), "utf8");
|
||||
const markdown = normalizeMarkdown(rawMarkdown);
|
||||
|
||||
return (
|
||||
<main className="project-doc-page project-doc-detail-page">
|
||||
<header className="project-doc-detail-hero">
|
||||
<nav className="project-doc-nav">
|
||||
<a href="/doc">ZK Data Agent</a>
|
||||
<div>
|
||||
<a href="/doc">返回总览</a>
|
||||
</div>
|
||||
</nav>
|
||||
<div className="project-doc-detail-head">
|
||||
<p className="project-doc-kicker">技术文档</p>
|
||||
<h1>{doc.title}</h1>
|
||||
<a className="project-doc-back-link" href="/doc">
|
||||
返回架构总览
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="project-doc-detail-shell">
|
||||
<article className="project-doc-article">
|
||||
{renderMarkdown(markdown)}
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+401
-602
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,566 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type CSSProperties,
|
||||
type PointerEvent,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
type ToolPosition = {
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
type: string;
|
||||
color: string;
|
||||
summary: string;
|
||||
fit: string;
|
||||
limit: string;
|
||||
};
|
||||
|
||||
type Point3D = {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
|
||||
type ProjectionMode = "xy" | "xz" | "yz" | "free";
|
||||
|
||||
const tools: ToolPosition[] = [
|
||||
{
|
||||
id: "manual",
|
||||
name: "纯人工",
|
||||
x: 24,
|
||||
y: 82,
|
||||
z: 22,
|
||||
type: "人主导",
|
||||
color: "#64748b",
|
||||
summary: "最懂业务,但查、写、跑、改、整理都靠人推进。",
|
||||
fit: "复杂边界判断、探索性分析、最终质量把关。",
|
||||
limit: "执行成本高,流程复用依赖个人习惯和文档质量。",
|
||||
},
|
||||
{
|
||||
id: "web-ai",
|
||||
name: "网页版 AI",
|
||||
x: 54,
|
||||
y: 24,
|
||||
z: 18,
|
||||
type: "通用 AI",
|
||||
color: "#38bdf8",
|
||||
summary: "适合问答、总结、生成草稿,启动快。",
|
||||
fit: "通用解释、文本生成、局部判断辅助。",
|
||||
limit: "不天然接入内部日志、标签规则、标准产物和团队流程。",
|
||||
},
|
||||
{
|
||||
id: "copilot",
|
||||
name: "IDE Copilot",
|
||||
x: 62,
|
||||
y: 34,
|
||||
z: 28,
|
||||
type: "编码辅助",
|
||||
color: "#60a5fa",
|
||||
summary: "在编辑器里提升局部编码效率。",
|
||||
fit: "补全、局部函数、单文件修改。",
|
||||
limit: "更偏代码片段,不负责跨系统数据流和业务产物链。",
|
||||
},
|
||||
{
|
||||
id: "cursor",
|
||||
name: "Cursor",
|
||||
x: 76,
|
||||
y: 46,
|
||||
z: 38,
|
||||
type: "IDE Agent",
|
||||
color: "#818cf8",
|
||||
summary: "围绕代码仓库做理解、修改和多文件协作。",
|
||||
fit: "工程代码迭代、仓库内上下文开发。",
|
||||
limit: "团队业务流程、线上数据、标准导出和共享 Skill 需要另行组织。",
|
||||
},
|
||||
{
|
||||
id: "code-agent",
|
||||
name: "Claude Code / Codex",
|
||||
x: 84,
|
||||
y: 58,
|
||||
z: 45,
|
||||
type: "工程 Agent",
|
||||
color: "#a78bfa",
|
||||
summary: "能读代码、调工具、执行多步工程任务。",
|
||||
fit: "本地工程闭环、复杂代码修改、命令执行。",
|
||||
limit: "强在个人工作区;团队级业务流程沉淀和 Web 多用户协作不是默认形态。",
|
||||
},
|
||||
{
|
||||
id: "notebook",
|
||||
name: "个人自动化脚本",
|
||||
x: 66,
|
||||
y: 74,
|
||||
z: 48,
|
||||
type: "个人自动化",
|
||||
color: "#f59e0b",
|
||||
summary: "贴近业务数据,能把部分流程脚本化。",
|
||||
fit: "数据清洗、临时统计、可重复实验。",
|
||||
limit: "入口、权限、review、产物管理和团队复用通常需要人额外维护。",
|
||||
},
|
||||
{
|
||||
id: "prompt-doc",
|
||||
name: "经验文档",
|
||||
x: 38,
|
||||
y: 62,
|
||||
z: 60,
|
||||
type: "经验沉淀",
|
||||
color: "#10b981",
|
||||
summary: "能记录规则和经验,便于传播。",
|
||||
fit: "标签边界、流程说明、操作规范。",
|
||||
limit: "本身不执行任务,仍需要人把文档转成操作。",
|
||||
},
|
||||
{
|
||||
id: "zk",
|
||||
name: "ZK Data Agent",
|
||||
x: 80,
|
||||
y: 88,
|
||||
z: 86,
|
||||
type: "业务 Agent 工作台",
|
||||
color: "#2563eb",
|
||||
summary:
|
||||
"把 Agent Loop、工具执行、业务知识、review 和产物管理组织成团队流程。",
|
||||
fit: "数据生成、线上挖掘、标签判断、外部系统 Skill 接入。",
|
||||
limit: "不替代人的业务判断;重点是把判断节点放进可复用流程。",
|
||||
},
|
||||
];
|
||||
|
||||
const presets: Array<{
|
||||
mode: ProjectionMode;
|
||||
name: string;
|
||||
rotateX: number;
|
||||
rotateY: number;
|
||||
}> = [
|
||||
{ mode: "xy", name: "研发 × 业务", rotateX: 0, rotateY: 0 },
|
||||
{ mode: "xz", name: "研发 × 复用", rotateX: -90, rotateY: 0 },
|
||||
{ mode: "yz", name: "业务 × 复用", rotateX: 0, rotateY: -90 },
|
||||
];
|
||||
|
||||
const dimensionCards = [
|
||||
{
|
||||
title: "研发执行效率",
|
||||
text: "读代码、调工具、跑脚本、生成产物。",
|
||||
},
|
||||
{
|
||||
title: "业务嵌入深度",
|
||||
text: "接住日志、标签体系、数据格式和团队规则。",
|
||||
},
|
||||
{
|
||||
title: "流程可复用度",
|
||||
text: "把一次解决方案沉淀成团队能继续调用的能力。",
|
||||
},
|
||||
];
|
||||
|
||||
const frictionCases = [
|
||||
{ text: "标签边界依赖经验", weight: 5 },
|
||||
{ text: "格式手工补齐", weight: 4 },
|
||||
{ text: "规则散在文档", weight: 4 },
|
||||
{ text: "线上数据难取", weight: 4 },
|
||||
{ text: "经验靠口口相传", weight: 3 },
|
||||
{ text: "脚本只在个人机器", weight: 3 },
|
||||
{ text: "Review 断在中间", weight: 3 },
|
||||
{ text: "样例难复用", weight: 2 },
|
||||
{ text: "产物路径分散", weight: 2 },
|
||||
{ text: "路径和权限反复确认", weight: 2 },
|
||||
];
|
||||
|
||||
const cubeSize = 360;
|
||||
const cubeCenter = cubeSize / 2;
|
||||
const stageCenter = 280;
|
||||
const perspective = 950;
|
||||
const axisOrigin = { x: -cubeCenter, y: cubeCenter, z: -cubeCenter };
|
||||
const axisDefinitions = [
|
||||
{
|
||||
id: "x",
|
||||
name: "研发执行效率",
|
||||
color: "#2563eb",
|
||||
start: axisOrigin,
|
||||
end: { x: cubeCenter, y: cubeCenter, z: -cubeCenter },
|
||||
},
|
||||
{
|
||||
id: "y",
|
||||
name: "业务嵌入深度",
|
||||
color: "#0f766e",
|
||||
start: axisOrigin,
|
||||
end: { x: -cubeCenter, y: -cubeCenter, z: -cubeCenter },
|
||||
},
|
||||
{
|
||||
id: "z",
|
||||
name: "流程可复用度",
|
||||
color: "#9333ea",
|
||||
start: axisOrigin,
|
||||
end: { x: -cubeCenter, y: cubeCenter, z: cubeCenter },
|
||||
},
|
||||
];
|
||||
const visibleAxesByMode: Record<ProjectionMode, string[]> = {
|
||||
xy: ["x", "y"],
|
||||
xz: ["x", "z"],
|
||||
yz: ["z", "y"],
|
||||
free: ["x", "y", "z"],
|
||||
};
|
||||
|
||||
function score(tool: ToolPosition) {
|
||||
return Math.round((tool.x + tool.y + tool.z) / 3);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getPointCoordinates(tool: ToolPosition) {
|
||||
return {
|
||||
x: (tool.x / 100) * cubeSize - cubeCenter,
|
||||
y: cubeCenter - (tool.y / 100) * cubeSize,
|
||||
z: (tool.z / 100) * cubeSize - cubeCenter,
|
||||
};
|
||||
}
|
||||
|
||||
function projectPoint(
|
||||
point: Point3D,
|
||||
rotateX: number,
|
||||
rotateY: number,
|
||||
mode: ProjectionMode,
|
||||
) {
|
||||
if (mode === "xy") {
|
||||
return {
|
||||
x: stageCenter + point.x,
|
||||
y: stageCenter + point.y,
|
||||
z: point.z,
|
||||
scale: 1,
|
||||
};
|
||||
}
|
||||
if (mode === "xz") {
|
||||
return {
|
||||
x: stageCenter + point.x,
|
||||
y: stageCenter - point.z,
|
||||
z: -point.y,
|
||||
scale: 1,
|
||||
};
|
||||
}
|
||||
if (mode === "yz") {
|
||||
return {
|
||||
x: stageCenter + point.z,
|
||||
y: stageCenter + point.y,
|
||||
z: point.x,
|
||||
scale: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const angleX = (rotateX * Math.PI) / 180;
|
||||
const angleY = (rotateY * Math.PI) / 180;
|
||||
const cosX = Math.cos(angleX);
|
||||
const sinX = Math.sin(angleX);
|
||||
const cosY = Math.cos(angleY);
|
||||
const sinY = Math.sin(angleY);
|
||||
const afterY = {
|
||||
x: point.x * cosY + point.z * sinY,
|
||||
y: point.y,
|
||||
z: -point.x * sinY + point.z * cosY,
|
||||
};
|
||||
const rotated = {
|
||||
x: afterY.x,
|
||||
y: afterY.y * cosX - afterY.z * sinX,
|
||||
z: afterY.y * sinX + afterY.z * cosX,
|
||||
};
|
||||
const scale = perspective / (perspective - rotated.z);
|
||||
|
||||
return {
|
||||
x: stageCenter + rotated.x * scale,
|
||||
y: stageCenter + rotated.y * scale,
|
||||
z: rotated.z,
|
||||
scale,
|
||||
};
|
||||
}
|
||||
|
||||
function toolAnchorStyle(
|
||||
tool: ToolPosition,
|
||||
projected: ReturnType<typeof projectPoint>,
|
||||
) {
|
||||
const scale = Math.min(1.08, Math.max(0.88, projected.scale));
|
||||
|
||||
return {
|
||||
"--tool-x": `${projected.x}px`,
|
||||
"--tool-y": `${projected.y}px`,
|
||||
"--label-scale": `${scale}`,
|
||||
"--point-color": tool.color,
|
||||
zIndex: Math.round(500 + projected.z),
|
||||
} as CSSProperties;
|
||||
}
|
||||
|
||||
function getProjectedAxes(
|
||||
rotateX: number,
|
||||
rotateY: number,
|
||||
mode: ProjectionMode,
|
||||
) {
|
||||
const visibleAxes = new Set(visibleAxesByMode[mode]);
|
||||
return axisDefinitions
|
||||
.filter((axis) => visibleAxes.has(axis.id))
|
||||
.map((axis) => {
|
||||
const start = projectPoint(axis.start, rotateX, rotateY, mode);
|
||||
const end = projectPoint(axis.end, rotateX, rotateY, mode);
|
||||
const vectorX = end.x - start.x;
|
||||
const vectorY = end.y - start.y;
|
||||
const length = Math.max(1, Math.hypot(vectorX, vectorY));
|
||||
const labelX = clamp(end.x + (vectorX / length) * 34, 44, 516);
|
||||
const labelY = clamp(end.y + (vectorY / length) * 34, 32, 528);
|
||||
|
||||
return {
|
||||
...axis,
|
||||
start,
|
||||
end,
|
||||
labelStyle: {
|
||||
"--axis-label-x": `${labelX}px`,
|
||||
"--axis-label-y": `${labelY}px`,
|
||||
"--axis-color": axis.color,
|
||||
} as CSSProperties,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function ToolPositionMap() {
|
||||
const [projectionMode, setProjectionMode] = useState<ProjectionMode>("xy");
|
||||
const [rotateX, setRotateX] = useState(0);
|
||||
const [rotateY, setRotateY] = useState(0);
|
||||
const [selectedId, setSelectedId] = useState("zk");
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragRef = useRef<{
|
||||
x: number;
|
||||
y: number;
|
||||
rotateX: number;
|
||||
rotateY: number;
|
||||
} | null>(null);
|
||||
|
||||
const selected = useMemo(
|
||||
() => tools.find((tool) => tool.id === selectedId) ?? tools[0],
|
||||
[selectedId],
|
||||
);
|
||||
const projectedTools = useMemo(
|
||||
() =>
|
||||
tools
|
||||
.map((tool) => ({
|
||||
tool,
|
||||
projected: projectPoint(
|
||||
getPointCoordinates(tool),
|
||||
rotateX,
|
||||
rotateY,
|
||||
projectionMode,
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.projected.z - b.projected.z),
|
||||
[rotateX, rotateY, projectionMode],
|
||||
);
|
||||
const projectedAxes = useMemo(
|
||||
() => getProjectedAxes(rotateX, rotateY, projectionMode),
|
||||
[rotateX, rotateY, projectionMode],
|
||||
);
|
||||
|
||||
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
setProjectionMode("free");
|
||||
dragRef.current = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
rotateX,
|
||||
rotateY,
|
||||
};
|
||||
setIsDragging(true);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragRef.current) return;
|
||||
const deltaX = event.clientX - dragRef.current.x;
|
||||
const deltaY = event.clientY - dragRef.current.y;
|
||||
setRotateX(dragRef.current.rotateX - deltaY * 0.45);
|
||||
setRotateY(dragRef.current.rotateY + deltaX * 0.45);
|
||||
};
|
||||
|
||||
const handlePointerEnd = (event: PointerEvent<HTMLDivElement>) => {
|
||||
dragRef.current = null;
|
||||
setIsDragging(false);
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="project-doc-section project-doc-position" id="position">
|
||||
<div className="position-framing">
|
||||
<div className="position-framing-copy">
|
||||
<span>效率公式</span>
|
||||
<h3 className="position-formula">
|
||||
<span>团队效率</span>
|
||||
<b>≈</b>
|
||||
<span>研发执行效率</span>
|
||||
<b>×</b>
|
||||
<span>业务嵌入深度</span>
|
||||
<b>×</b>
|
||||
<span>流程可复用度</span>
|
||||
</h3>
|
||||
<p>
|
||||
AI
|
||||
能回答,脚本能处理,文档能沉淀。但只要其中一环还要人工来回衔接,效率就会被拉回到原来的流程里。
|
||||
</p>
|
||||
</div>
|
||||
<div className="position-dimension-grid">
|
||||
{dimensionCards.map((card) => (
|
||||
<div key={card.title}>
|
||||
<strong>{card.title}</strong>
|
||||
<p>{card.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="position-friction-cloud">
|
||||
<strong>典型断点</strong>
|
||||
{frictionCases.map((item) => (
|
||||
<span className={`is-weight-${item.weight}`} key={item.text}>
|
||||
{item.text}
|
||||
</span>
|
||||
))}
|
||||
<em>下面这张图,就是把这些工作方式放进同一套坐标里看。</em>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="position-map-layout">
|
||||
<div className="position-stage-card">
|
||||
<div className="position-controls">
|
||||
<div>
|
||||
<span>
|
||||
{projectionMode === "free"
|
||||
? "拖动坐标系自由旋转"
|
||||
: "二维投影视图"}
|
||||
</span>
|
||||
<strong>
|
||||
{projectionMode === "free"
|
||||
? `X ${Math.round(rotateX)} / Y ${Math.round(rotateY)}`
|
||||
: presets.find((preset) => preset.mode === projectionMode)
|
||||
?.name}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="position-preset-row">
|
||||
{presets.map((preset) => (
|
||||
<button
|
||||
className={
|
||||
projectionMode === preset.mode ? "is-selected" : undefined
|
||||
}
|
||||
key={preset.name}
|
||||
onClick={() => {
|
||||
setProjectionMode(preset.mode);
|
||||
setRotateX(preset.rotateX);
|
||||
setRotateY(preset.rotateY);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{preset.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDragging ? "position-stage is-dragging" : "position-stage"
|
||||
}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
>
|
||||
<div className="position-scene">
|
||||
<div className="position-space-grid" />
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="position-axis-overlay"
|
||||
viewBox="0 0 560 560"
|
||||
>
|
||||
{projectedAxes.map((axis) => (
|
||||
<line
|
||||
key={axis.id}
|
||||
stroke={axis.color}
|
||||
x1={axis.start.x}
|
||||
x2={axis.end.x}
|
||||
y1={axis.start.y}
|
||||
y2={axis.end.y}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
<div className="position-axis-label-layer">
|
||||
{projectedAxes.map((axis) => (
|
||||
<span
|
||||
className="position-axis-label"
|
||||
key={axis.id}
|
||||
style={axis.labelStyle}
|
||||
>
|
||||
{axis.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="position-tool-layer">
|
||||
{projectedTools.map(({ tool, projected }) => (
|
||||
<button
|
||||
className={[
|
||||
"position-tool-anchor",
|
||||
tool.id === selectedId ? "is-selected" : "",
|
||||
tool.id === hoveredId ? "is-hovered" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
key={tool.id}
|
||||
onClick={() => setSelectedId(tool.id)}
|
||||
onBlur={() => setHoveredId(null)}
|
||||
onFocus={() => setHoveredId(tool.id)}
|
||||
onMouseEnter={() => setHoveredId(tool.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
style={toolAnchorStyle(tool, projected)}
|
||||
type="button"
|
||||
>
|
||||
<span className="position-ball" />
|
||||
<span className="position-tool-label">{tool.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="position-detail-card">
|
||||
<div className="position-score">
|
||||
<span>{selected.type}</span>
|
||||
<strong>{score(selected)}</strong>
|
||||
</div>
|
||||
<h3>{selected.name}</h3>
|
||||
<p>{selected.summary}</p>
|
||||
<div className="position-meter-list">
|
||||
<div>
|
||||
<span>研发执行效率</span>
|
||||
<i style={{ width: `${selected.x}%` }} />
|
||||
</div>
|
||||
<div>
|
||||
<span>业务嵌入深度</span>
|
||||
<i style={{ width: `${selected.y}%` }} />
|
||||
</div>
|
||||
<div>
|
||||
<span>流程可复用度</span>
|
||||
<i style={{ width: `${selected.z}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>适合</dt>
|
||||
<dd>{selected.fit}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>限制</dt>
|
||||
<dd>{selected.limit}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+1094
-1620
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user