Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 327828ea56 | |||
| ffe6dc7c86 | |||
| ab39fdead3 | |||
| 06185b67b4 | |||
| 832671e308 | |||
| 92cedd235b | |||
| 4ae452336c | |||
| 4c63c00a18 | |||
| 22baa715fd |
@@ -0,0 +1,21 @@
|
||||
# my-resume Agent Skills
|
||||
|
||||
本目录存放 `my-resume` 的项目级 Agent skill。规则参考 SeaCloud 项目的 agent skill 组织方式,并按当前 Vite、Three.js 和作品集页面场景保留最小必要规范。
|
||||
|
||||
## 使用入口
|
||||
|
||||
1. 进入仓库后先读 `AGENTS.md`。
|
||||
2. 创建或修改代码文件时,使用 `header-comment-sync` 保持中文文件头、导出声明和复杂逻辑注释同步。
|
||||
3. 提交或推送代码时,使用 `chinese-commit-message` 生成英文 type 前缀加中文摘要的 commit message。
|
||||
4. 所有 skill 的触达文件补齐规则只处理本次任务相关文件,不要求为了单次任务全仓扫描。
|
||||
|
||||
## 当前项目事实
|
||||
|
||||
- 应用类型:独立 Vite + Three.js 作品集站点。
|
||||
- 技术栈:Vite、Three.js、GSAP、Lenis、原生 JavaScript。
|
||||
- 关键边界:交互与动效逻辑需要关注移动端性能、 reduced-motion 降级和首屏可读性。
|
||||
|
||||
## Skill 索引
|
||||
|
||||
- `header-comment-sync`:中文文件头、导出声明、复杂逻辑和风险边界注释。
|
||||
- `chinese-commit-message`:中文提交信息格式。
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: chinese-commit-message
|
||||
description: 在本仓库执行 git commit、整理提交说明或准备推送时使用,保持提交信息使用英文类型前缀加中文描述。
|
||||
---
|
||||
|
||||
# 中文提交信息
|
||||
|
||||
## 何时使用
|
||||
|
||||
- 用户要求提交、推送或整理 commit message。
|
||||
- 你准备执行 `git commit`。
|
||||
- 需要总结当前 diff 的提交说明。
|
||||
|
||||
## 核心要求
|
||||
|
||||
1. 提交信息必须使用英文 type 加中文摘要,例如 `feat: 补齐作品集 agent skill 规范`。
|
||||
2. 常用 type 优先使用 `feat`、`fix`、`refactor`、`docs`、`chore`。
|
||||
3. 不强制 scope;只有模块边界非常清楚时才使用 `feat(scene): ...`。
|
||||
4. 标题部分使用简洁中文,直接说明改动,不写“修改一下”“更新代码”这类空泛描述。
|
||||
5. 非 trivial 改动建议补中文正文,用扁平 bullet 按文件或内容块说明关键动作。
|
||||
6. 正文与标题之间空一行;正文每条 bullet 对应一个独立文件或明确内容块。
|
||||
7. 如果用户指定提交信息,优先尊重用户原意,只做必要格式整理。
|
||||
|
||||
## 触达文件补齐
|
||||
|
||||
- 不要求为了提交信息单独全仓扫描。
|
||||
- 提交前如果 diff 中的触达文件明显违反对应 skill 的触达补齐要求,应先修正当前相关链路,再整理 commit message。
|
||||
- commit 正文应概括本次触达补齐,例如“补齐触达文件注释”“同步 README 目录说明”。
|
||||
|
||||
## 推荐结构
|
||||
|
||||
```text
|
||||
feat: 补齐作品集 agent skill 规范
|
||||
|
||||
- .agents/README.md: 新增项目级 skill 索引和使用入口
|
||||
- .agents/skills: 补充中文提交与注释同步规则
|
||||
- AGENTS.md: 增加本地 skill 入口
|
||||
```
|
||||
|
||||
## 不推荐写法
|
||||
|
||||
- `新增规则`
|
||||
- `feat: add skills`
|
||||
- `fix bug`
|
||||
- `update`
|
||||
- 复杂改动只有标题没有正文。
|
||||
- 正文写成长段流水账或嵌套列表。
|
||||
|
||||
## 落地检查
|
||||
|
||||
- type 是否合理。
|
||||
- 中文摘要是否覆盖主改动。
|
||||
- 是否需要正文。
|
||||
- 正文是否按文件或内容块归纳。
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: header-comment-sync
|
||||
description: 在本仓库创建或修改 ts、tsx、js、jsx、mjs、cjs、vue、astro 文件时使用,保持中文文件头、导出声明、复杂逻辑和风险边界注释准确。
|
||||
---
|
||||
|
||||
# 注释规范与同步
|
||||
|
||||
## 目标
|
||||
|
||||
让下一次进入文件的维护者能快速理解当前职责、关键约束和风险边界。注释解释“为什么”和“边界”,不复述代码表面行为。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 新增或修改页面入口、Three.js 场景、动效编排、渲染、数据、工具或脚本文件。
|
||||
- 修改滚动时间线、WebGL shader、响应式布局、性能降级、 reduced-motion 或首屏渲染逻辑。
|
||||
- 发现旧注释与当前代码行为不一致。
|
||||
|
||||
## 核心原则
|
||||
|
||||
- 注释使用中文,描述当前事实。
|
||||
- 简单局部变量不强行注释。
|
||||
- 文件头说明文件当前职责,1 到 2 行即可。
|
||||
- 导出的函数、类、类型、常量、配置对象和复杂模块方法应有用途说明。
|
||||
- 复杂动画相位、shader uniform、滚动联动、性能降级和浏览器兼容边界需要短注释。
|
||||
- TODO/FIXME 必须说明触发条件、剩余动作和可删除条件。
|
||||
|
||||
## 文件头规则
|
||||
|
||||
- 每个适用文件都要有准确文件头。
|
||||
- 如果文件必须以 `"use client"` 或 `"use server"` 开头,文件头注释放在指令之后、导入之前。
|
||||
- Vue 文件不为了补头注释重排模板结构;在 `<script>` 或 `<script setup>` 顶部补当前职责说明。
|
||||
- 文件头不要写“本文件用于...”这类空话,直接说明业务角色。
|
||||
|
||||
```js
|
||||
/**
|
||||
* 装配作品集滚动叙事、WebGL 背景和移动端性能降级策略。
|
||||
*/
|
||||
```
|
||||
|
||||
## JSDoc 与局部注释
|
||||
|
||||
- 导出的函数、类、类型、常量和配置对象优先使用 JSDoc。
|
||||
- 非导出但复杂的解析、格式化、请求构造、动画相位派生、回调工厂也要补。
|
||||
- 只在局部逻辑确实有约束时使用行内注释,例如性能降级、兼容字段、shader 参数和临时迁移。
|
||||
- 不要在每一行、简单 DOM 拼装、短生命周期变量上堆注释。
|
||||
|
||||
## 触达文件补齐
|
||||
|
||||
- 不要求为了注释规范单独全仓扫描。
|
||||
- 只要本次任务修改了适用文件,就顺手补齐明显缺失或过时的文件头、导出声明、共享类型、复杂回调和协议注释。
|
||||
- 大文件可按触达区域优先,但同文件内裸露的顶层导出和共享类型应一起补。
|
||||
- 如果一次补齐整个大文件会明显超出需求范围,至少补齐当前需求链路和顶层声明,并在交付说明里说明剩余范围。
|
||||
|
||||
## 不推荐的写法
|
||||
|
||||
- 注释只写“处理数据”“点击事件”这种无信息内容。
|
||||
- 注释描述旧方案,和当前代码矛盾。
|
||||
- 为简单 DOM 拼装、简单赋值、短生命周期变量写注释。
|
||||
- 用英文口号、emoji 或情绪化标记替代项目内中文说明。
|
||||
|
||||
## 落地检查
|
||||
|
||||
- 修改后的文件头是否准确。
|
||||
- 新增/修改的导出声明是否有必要说明。
|
||||
- 复杂逻辑是否解释了约束而不是复述代码。
|
||||
- 旧注释是否仍然可信。
|
||||
@@ -0,0 +1,17 @@
|
||||
# my-resume Agent 入口
|
||||
|
||||
## 项目规则
|
||||
|
||||
- 使用当前 Vite + Three.js + 原生 JavaScript 结构,不引入无关框架。
|
||||
- 页面内容、计划文档和交付说明优先使用中文。
|
||||
- 改动可运行入口、目录或重要文件时,同步更新 `README.md`。
|
||||
|
||||
## 必用 Skill
|
||||
|
||||
- 创建或修改 `ts`、`tsx`、`js`、`jsx`、`mjs`、`cjs`、`vue`、`astro` 文件时,使用 `./.agents/skills/header-comment-sync/SKILL.md`。
|
||||
- 执行 `git commit`、整理提交说明或准备推送时,使用 `./.agents/skills/chinese-commit-message/SKILL.md`。
|
||||
|
||||
## 本地规则
|
||||
|
||||
- 先读 `.agents/README.md`,再按任务读取匹配的 skill。
|
||||
- skill 的触达文件补齐只处理本次修改相关文件,不为单次任务全仓扫描。
|
||||
@@ -11,3 +11,10 @@ npm run build
|
||||
```
|
||||
|
||||
The page uses a restrained scroll-aware WebGL backdrop, critical first-paint styling, grounded resume summaries, responsive layout, subtle scroll reveals, project cards, skills, experience, and contact sections.
|
||||
|
||||
## Agent Notes
|
||||
|
||||
- `AGENTS.md`: Codex/Agent 入口规则。
|
||||
- `.agents/README.md`: 本项目 Agent skill 索引和使用入口。
|
||||
- `.agents/skills/chinese-commit-message/SKILL.md`: 中文提交信息规范。
|
||||
- `.agents/skills/header-comment-sync/SKILL.md`: 中文文件头和注释同步规范。
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# 设计文档:「与湛兮(花名)的数字思维体对话」作品集重设计
|
||||
|
||||
日期:2026-06-11
|
||||
状态:已批准
|
||||
备份:`../wang-yuanyou-fluid-portfolio-backup-20260611-110154/`
|
||||
|
||||
## 概念
|
||||
|
||||
整站是访客与「湛兮(花名)的 AI 分身」的一场对话记录。简历核心(最近一年做 SSE 流式对话、打字机渲染、思考态动画)即设计语言来源——网站本身就是其工作成果的活演示。3D 流体 =「思维体」,是画面中唯一的角色。
|
||||
|
||||
决策记录:
|
||||
|
||||
- 设计隐喻:AI 对话流(vs 职业河流 / 跨端粒子宇宙)
|
||||
- 推进方式:滚动驱动的对话(vs 真·对话界面 / 滚动+问答入口)
|
||||
- 流体形态:单一思维流体 + 三状态状态机(vs raymarched metaballs / GPGPU 粒子流)
|
||||
- 视觉基调:深色 AI 实验室(vs 浅色编辑风 / 双主题)
|
||||
- 技术路线:原生 Three.js 自写 shader + GSAP ScrollTrigger + Lenis(vs 纯手写 / R3F)
|
||||
|
||||
## 信息架构:7 轮问答
|
||||
|
||||
| # | 访客提问 | 回答内容 | 数据源 |
|
||||
|---|---|---|---|
|
||||
| 0 | —(开机自检) | 「你好,我是湛兮(花名)。」+ 身份一行 + 6 指标 token 式吐出 | metrics |
|
||||
| 1 | 先介绍一下你自己? | 4 条概述逐行流式输出 | resumeSignals |
|
||||
| 2 | 最近一年具体在做什么? | 4 张焦点卡依次「生成」 | focusAreas |
|
||||
| 3 | 有实际项目证明吗? | 4 个项目,各为一轮子对话:项目名打字机 → 模块逐条吐出 | projects |
|
||||
| 4 | 技能栈展开讲讲? | 7 组技能标签 token 流喷发归位 | skills |
|
||||
| 5 | 之前的团队经历? | 3 段经历沿垂直对话流时间线生成 | experiences |
|
||||
| 6 | 怎么联系你? | 联系方式 + 流体归于平静,「对话已保存」收尾 | — |
|
||||
|
||||
页面右侧(移动端顶部)设「会话进度轨」:7 节点对应 7 轮,可点击跳转,保证 HR 快速扫读。
|
||||
|
||||
## 思维流体(Three.js 自写 shader)
|
||||
|
||||
- 几何:高细分 IcosahedronGeometry;顶点着色器 3 层 FBM simplex 噪声液态位移
|
||||
- 片元:Fresnel 边缘辉光 + 双色渐变(青 #22d3ee ↔ 紫 #a78bfa)+ 加性内核光晕
|
||||
- 伴生粒子:约 2000 GPU 粒子,curl noise 流场
|
||||
- 状态机(uniform 插值过渡):
|
||||
- `idle`:低频低幅呼吸,粒子懒散环绕
|
||||
- `thinking`:收缩 0.85x,噪声频率 ×3 搅动,色温升高,粒子吸入
|
||||
- `answering`:回弹 1.1x 归位,粒子向内容区喷发消散,辉光脉冲与文字生成同步
|
||||
- 流体位置随轮次左右缓移(lerp)与内容互让;鼠标轻微视差
|
||||
|
||||
## 非线性动画编排(GSAP ScrollTrigger + Lenis)
|
||||
|
||||
每轮问答为一个 pin 区段,滚动量映射轮内时间轴:
|
||||
|
||||
1. 提问(0→15%):访客气泡逐字打出,steps() 离散节奏
|
||||
2. 思考(15→35%):流体 thinking,内容区仅 shimmer 占位——刻意停顿即非线性核心
|
||||
3. 爆发生成(35→75%):expo.out 爆发;标题打字机、卡片不等间隔 stagger(模拟 token 不均匀到达)、数字滚动跳变
|
||||
4. 余韵(75→100%):流体回 idle,内容微视差上浮,解除 pin
|
||||
|
||||
每轮思考时长 / 爆发曲线 / stagger 间隔均不同(场景配置驱动),避免节奏雷同。向上滚动时时间轴反播,内容「被收回」。
|
||||
|
||||
降级:`prefers-reduced-motion` → 关 pin 与打字机、内容直出、流体仅呼吸;移动端粒子减半、DPR ≤ 2、细分降档。
|
||||
|
||||
## 视觉系统
|
||||
|
||||
- 背景 #070b14 近黑蓝 + 极淡网格点阵 + 流体环境光溢出
|
||||
- 强调色:青 #22d3ee(访客/交互)、紫 #a78bfa(思维体/回答)、琥珀 #fbbf24(仅指标数字)
|
||||
- 气泡语言:提问 = 右对齐细边框气泡;回答 = 无框流式文本块 + 左侧渐变「生成光标」竖线
|
||||
- 字体:等宽(JetBrains Mono / 思源等宽回退)用于指标、token、技能标签;正文 Inter + 思源黑体
|
||||
- 细节:回答块尾闪烁光标 ▋;项目 logo 复用 public/logos/
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/
|
||||
main.js # 入口:装配 + Lenis + 进度轨
|
||||
resume-data.js # 不动(数据即简历)
|
||||
dialogue.js # 7 轮问答文案与节奏配置
|
||||
render.js # 由 resume-data 生成各轮 DOM
|
||||
choreography.js # GSAP ScrollTrigger 时间轴编排
|
||||
mind/
|
||||
mind.js # 流体场景、状态机、粒子
|
||||
shaders.js # GLSL
|
||||
styles.css # 重写
|
||||
index.html # 重写骨架
|
||||
```
|
||||
|
||||
新依赖:gsap、lenis。重写 main.js / styles.css / index.html(旧版已整目录备份)。
|
||||
|
||||
## 性能与验收
|
||||
|
||||
- 单 canvas 固定底层;rAF 与 GSAP ticker 合并;目标桌面 60fps、移动 30fps+
|
||||
- 文字全部真实 DOM(可选中/可索引),打字机仅控制 reveal;进度轨 + 锚点保证可跳转
|
||||
- 验收:7 轮完整走查(含回滚反播)、375px 移动端、reduced-motion 降级、Lighthouse 不低于现版本
|
||||
@@ -0,0 +1,56 @@
|
||||
# 「数字思维体对话」作品集重设计 实现计划
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 将现有作品集重写为「与湛兮(花名)的 AI 分身对话」式单页站点:滚动驱动的 7 轮问答 + shader 思维流体状态机。
|
||||
|
||||
**Architecture:** Vite 原生 JS。`resume-data.js` 不动;`dialogue.js` 定义 7 轮问答节奏配置;`render.js` 生成 DOM;`mind/` 为 Three.js shader 流体(idle/thinking/answering 状态机 + GPU 粒子);`choreography.js` 用 GSAP ScrollTrigger pin+scrub 编排每轮「提问→思考→爆发生成→余韵」;Lenis 平滑滚动。
|
||||
|
||||
**Tech Stack:** Vite 7, Three.js 0.184, GSAP ScrollTrigger, Lenis, 自写 GLSL(FBM simplex + Fresnel)。
|
||||
|
||||
**设计文档:** `docs/plans/2026-06-11-ai-dialogue-redesign-design.md`(已批准)
|
||||
**备份:** `../wang-yuanyou-fluid-portfolio-backup-20260611-110154/`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 依赖安装
|
||||
- `pnpm add gsap lenis`
|
||||
- 验证: package.json 出现两依赖,`pnpm dev` 可启动。
|
||||
|
||||
### Task 2: `src/dialogue.js` — 轮次配置
|
||||
- 7 轮:boot / intro(resumeSignals) / focus(focusAreas) / projects / skills / experience / contact。
|
||||
- 每轮字段:id、label(进度轨)、question、type、side(流体停靠 -1/1)、palette(双色)、pinLength、qEnd/thinkEnd(相位)、staggerEach——逐轮不同以保证节奏差异。
|
||||
- 联系信息常量:邮箱 419021733@qq.com / wmagmgema521@gmail.com、电话 19980439383、GitHub zhanBoss、男·29岁·电子科技大学 信息管理与信息系统 本科。
|
||||
|
||||
### Task 3: `index.html` — 骨架重写
|
||||
- canvas#mind-canvas(fixed 底层)、.grid-overlay、header.hud(左:姓名/DIGITAL MIND;右:● ONLINE 状态灯)、nav#session-rail、main#dialogue(空,由 render.js 填充)、Google Fonts(JetBrains Mono + Inter, display=swap)。
|
||||
|
||||
### Task 4: `src/styles.css` — 深色 AI 实验室
|
||||
- 背景 #070b14、点阵网格 overlay、青 #22d3ee / 紫 #a78bfa / 琥珀 #fbbf24。
|
||||
- 气泡语言:.q-bubble 右对齐细边框;.answer 左侧渐变生成竖线 + 尾部 ▋ 闪烁光标;token/指标/技能用等宽字体。
|
||||
- 各轮内容样式:metrics token、focus 卡、project 块、skill 标签云、timeline、contact。
|
||||
- 进度轨右侧固定(移动端转顶部水平);≤768px 移动端布局;prefers-reduced-motion 关闭闪烁。
|
||||
|
||||
### Task 5: `src/render.js` — DOM 生成
|
||||
- 每轮 section.round 结构:q-bubble(空文本,编排时打字)→ .thinking(三点 shimmer)→ .answer(.gen 子项供 stagger)。
|
||||
- 按 type 分发渲染器,全部读 resume-data.js;boot 轮含问候打字行 + 身份行 + metrics token。
|
||||
- 文案全部真实 DOM(初始由 CSS/GSAP 隐藏,reduced-motion 时直接可见)。
|
||||
|
||||
### Task 6: `src/mind/shaders.js` + `src/mind/mind.js` — 思维流体
|
||||
- 顶点:Ashima simplex 3D + 3 octave FBM 位移;片元:Fresnel 辉光 + uColorA/uColorB 渐变 + uHeat 色温 + AdditiveBlending。
|
||||
- 粒子:~2000(移动端 900),属性 seed/radius/speed/phase,轨道半径受 uAttract(吸入)与 uBurst(喷发,setState('answering') 时置 1 后衰减)控制,全 GPU。
|
||||
- 状态机 STATES{idle, thinking, answering},每帧 lerp uniforms;setSide(dir) 左右停靠(移动端居中缩小);setPalette 双色渐变;指针视差;DPR≤2;resize。
|
||||
|
||||
### Task 7: `src/choreography.js` — 滚动编排
|
||||
- Lenis + ScrollTrigger 集成(gsap.ticker 驱动)。
|
||||
- boot 轮:页面加载后时间驱动的开场时间轴(非滚动)。
|
||||
- 轮 1-6:pin + scrub 时间轴,相位 0→qEnd 打字(steps 离散)→thinkEnd 思考 shimmer→0.78 爆发 stagger(expo.out,不等间隔)→1 余韵视差;onUpdate 按相位切 mind 状态;enter/enterBack 切 side+palette。
|
||||
- 进度轨高亮与 lenis.scrollTo 跳转。
|
||||
- reduced-motion:跳过 pin/打字机,内容直出。
|
||||
|
||||
### Task 8: `src/main.js` — 装配
|
||||
- 渲染 DOM → 建 rail → createMind → choreography → boot 开场。
|
||||
|
||||
### Task 9: 验证
|
||||
- `pnpm dev` + Playwright:桌面 1440px 走查 7 轮(截图 hero/中段/尾段)、向上回滚反播、375px 移动端、console 无错误。
|
||||
- `pnpm build` 成功。
|
||||
+22
-176
@@ -1,195 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>湛兮(花名) · 数字思维体 / Digital Mind</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="王元有个人网站,前端工程师,7 年 Web 与跨端经验,近一年参与 SeaBuzz、SeaCloud 等 AI 产品前端,覆盖对话、内容流、计费、权限和国际化。"
|
||||
content="湛兮(花名) — 7 年 Web 与跨端前端工程师。一场与他的数字思维体的对话:AI 对话链路、控制台业务、跨端内容流与工程实践。"
|
||||
/>
|
||||
<script>
|
||||
(() => {
|
||||
const saved = localStorage.getItem("wy-fluid-theme");
|
||||
if (saved) document.documentElement.dataset.theme = saved;
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
:root {
|
||||
background: #07080d;
|
||||
color: #f5f7fb;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html[data-theme="light"] {
|
||||
background: #f2f5f7;
|
||||
color: #11131a;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: inherit;
|
||||
color: inherit;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
"Microsoft YaHei", sans-serif;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/src/styles.css" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>王元有 - 前端工程师</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/src/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="progress" id="scroll-progress"></div>
|
||||
<canvas id="fluid-canvas" aria-hidden="true"></canvas>
|
||||
<div class="scene-backdrop" id="scene-backdrop" aria-hidden="true"></div>
|
||||
<div class="grain" aria-hidden="true"></div>
|
||||
<canvas id="mind-canvas" aria-hidden="true"></canvas>
|
||||
<div class="grid-overlay" aria-hidden="true"></div>
|
||||
|
||||
<header class="site-header">
|
||||
<a class="brand-mark" href="#top" aria-label="王元有个人网站首页">
|
||||
<span>WY</span>
|
||||
<strong>王元有</strong>
|
||||
</a>
|
||||
<nav class="site-nav" aria-label="页面导航">
|
||||
<a href="#ai-fit">方向</a>
|
||||
<a href="#proof">证据</a>
|
||||
<a href="#projects">项目</a>
|
||||
<a href="#skills">能力</a>
|
||||
<a href="#experience">经历</a>
|
||||
<a href="#contact">联系</a>
|
||||
</nav>
|
||||
<div class="header-actions">
|
||||
<a class="icon-link" href="https://github.com/zhanBoss" target="_blank" rel="noreferrer" aria-label="GitHub">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 2C6.48 2 2 6.58 2 12.23c0 4.52 2.87 8.35 6.84 9.7.5.1.68-.22.68-.5v-1.75c-2.78.62-3.37-1.37-3.37-1.37-.45-1.19-1.11-1.5-1.11-1.5-.91-.64.07-.63.07-.63 1 .07 1.53 1.06 1.53 1.06.9 1.56 2.35 1.11 2.92.85.09-.66.35-1.11.63-1.37-2.22-.26-4.56-1.14-4.56-5.07 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.71 0 0 .84-.28 2.75 1.05A9.35 9.35 0 0 1 12 6.9c.85 0 1.71.12 2.51.34 1.91-1.33 2.75-1.05 2.75-1.05.55 1.41.2 2.45.1 2.71.64.72 1.03 1.63 1.03 2.75 0 3.94-2.34 4.81-4.57 5.06.36.32.68.94.68 1.9v2.82c0 .28.18.6.69.5A10.08 10.08 0 0 0 22 12.23C22 6.58 17.52 2 12 2Z"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
<button class="icon-link" id="theme-toggle" type="button" aria-label="切换明暗主题">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12 2a10 10 0 1 0 0 20V2Zm2 2.29A8 8 0 0 1 14 19.71V4.29Z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<header class="hud">
|
||||
<div class="hud-id">
|
||||
<span class="hud-name">ZHAN XI</span>
|
||||
<span class="hud-sub">DIGITAL MIND · v7.0</span>
|
||||
</div>
|
||||
<div class="hud-status">
|
||||
<span class="status-dot" aria-hidden="true"></span>
|
||||
<span class="status-text">ONLINE</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="top">
|
||||
<section class="hero" aria-labelledby="hero-title">
|
||||
<div class="hero-meta reveal" data-drift="-24">
|
||||
<p class="eyebrow">Frontend Engineer / Product UI / Cross-platform</p>
|
||||
<h1 id="hero-title">王元有</h1>
|
||||
<p class="hero-subtitle">前端工程师,近一年在 AI 产品团队做前端</p>
|
||||
<p class="hero-lede">
|
||||
做过 B 端供应链系统、小程序、微前端平台,也参与过 SeaBuzz 和 SeaCloud 这类 AI 产品。现在更擅长把对话流、
|
||||
内容流、计费、权限、国际化这些复杂链路整理成稳定的前端界面。
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a class="button primary" href="#projects">看项目经历</a>
|
||||
<a class="button" href="mailto:419021733@qq.com">419021733@qq.com</a>
|
||||
</div>
|
||||
</div>
|
||||
<nav id="session-rail" aria-label="会话进度"></nav>
|
||||
|
||||
<div class="signal-board reveal" data-drift="28" aria-label="候选人摘要">
|
||||
<div>
|
||||
<span>前端经验</span>
|
||||
<strong>7 年</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>近一年方向</span>
|
||||
<strong>对话与控制台</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>主要场景</span>
|
||||
<strong>对话 / 内容 / 控制台</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>协作方式</span>
|
||||
<strong>能独立推进模块</strong>
|
||||
</div>
|
||||
</div>
|
||||
<main id="dialogue"></main>
|
||||
|
||||
<div class="profile-panel reveal" data-drift="20" aria-label="工作方式摘要">
|
||||
<div>
|
||||
<span>最近职责</span>
|
||||
<strong>SeaBuzz 跨端应用、SeaCloud 控制台</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>更擅长</span>
|
||||
<strong>把复杂状态、权限和数据边界梳理清楚</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>常用技术</span>
|
||||
<strong>React / Next.js / React Native / TypeScript</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>工作习惯</span>
|
||||
<strong>先对齐业务边界,再拆组件和状态模型</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="ai-fit" class="ai-fit-section reveal" data-drift="36" aria-labelledby="ai-fit-title">
|
||||
<div class="section-heading">
|
||||
<p>Current Direction</p>
|
||||
<h2 id="ai-fit-title">AI 是最近一年的工作场景,不是拿来堆关键词的标签。</h2>
|
||||
</div>
|
||||
<div class="ai-focus-grid" id="ai-focus-grid"></div>
|
||||
<div class="resume-signal-panel reveal" data-drift="-30">
|
||||
<div>
|
||||
<p>Resume Summary</p>
|
||||
<h3>前端工程师|AI 产品方向|Web / React Native</h3>
|
||||
</div>
|
||||
<ul id="resume-signal-list"></ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="proof" class="proof-strip reveal" data-drift="-40" aria-labelledby="proof-title">
|
||||
<div class="section-kicker">Evidence</div>
|
||||
<h2 id="proof-title">一些可以核对的经历,先放在前面。</h2>
|
||||
<div class="metric-row" id="metric-row"></div>
|
||||
</section>
|
||||
|
||||
<section id="projects" class="work-section" aria-labelledby="projects-title">
|
||||
<div class="section-heading reveal" data-drift="42">
|
||||
<p>Selected Work</p>
|
||||
<h2 id="projects-title">最近两段经历和 AI 产品相关,但履历底子仍然是前端工程交付。</h2>
|
||||
</div>
|
||||
<div class="project-stack" id="project-stack"></div>
|
||||
</section>
|
||||
|
||||
<section id="skills" class="skills-section" aria-labelledby="skills-title">
|
||||
<div class="section-heading reveal" data-drift="-42">
|
||||
<p>Capabilities</p>
|
||||
<h2 id="skills-title">按实际做过的工作来组织技能,而不是按热门词来分类。</h2>
|
||||
</div>
|
||||
<div class="skill-matrix" id="skill-matrix"></div>
|
||||
</section>
|
||||
|
||||
<section id="experience" class="experience-section" aria-labelledby="experience-title">
|
||||
<div class="section-heading reveal" data-drift="34">
|
||||
<p>Experience</p>
|
||||
<h2 id="experience-title">职责从核心交付演进到架构设计和团队规范建设。</h2>
|
||||
</div>
|
||||
<div class="timeline" id="timeline"></div>
|
||||
</section>
|
||||
|
||||
<section id="contact" class="contact-section reveal" data-drift="-26" aria-labelledby="contact-title">
|
||||
<div>
|
||||
<p class="section-kicker">Contact</p>
|
||||
<h2 id="contact-title">正在看前端、跨端、AI 产品前端相关机会。</h2>
|
||||
<span>男 · 29 岁 · 19980439383 · 电子科技大学 信息管理与信息系统 本科</span>
|
||||
</div>
|
||||
<div class="contact-actions">
|
||||
<a class="button primary" href="mailto:419021733@qq.com">发送邮件</a>
|
||||
<a class="button" href="mailto:wmagmgema521@gmail.com">wmagmgema521@gmail.com</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>© 2026 王元有. Built as an independent fluid portfolio.</footer>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+40
-2
@@ -8,10 +8,11 @@
|
||||
"name": "wang-yuanyou-fluid-portfolio",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"gsap": "^3.15.0",
|
||||
"lenis": "^1.3.23",
|
||||
"three": "^0.184.0",
|
||||
"vite": "^7.2.7"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.7",
|
||||
@@ -832,6 +833,43 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gsap": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
|
||||
"integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
|
||||
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
|
||||
},
|
||||
"node_modules/lenis": {
|
||||
"version": "1.3.23",
|
||||
"resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.23.tgz",
|
||||
"integrity": "sha512-YxYq3TJqj9sJNv0V9SkyQHejt14xwyIwgDaaMK89Uf9SxQfIszu+gTQSSphh6BWlLTNVKvvXAGkg+Zf+oFIevg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"playground",
|
||||
"playground/*"
|
||||
],
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/darkroomengineering"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nuxt/kit": ">=3.0.0",
|
||||
"react": ">=17.0.0",
|
||||
"vue": ">=3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nuxt/kit": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"vue": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
|
||||
+5
-2
@@ -6,11 +6,14 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"build:test": "PUBLIC_APP_ENV_LABEL=测试环境 vite build",
|
||||
"build:prod": "PUBLIC_APP_ENV_LABEL=生产环境 vite build",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"gsap": "^3.15.0",
|
||||
"lenis": "^1.3.23",
|
||||
"three": "^0.184.0",
|
||||
"vite": "^7.2.7"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+27
@@ -8,6 +8,12 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
gsap:
|
||||
specifier: ^3.15.0
|
||||
version: 3.15.0
|
||||
lenis:
|
||||
specifier: ^1.3.23
|
||||
version: 1.3.23
|
||||
three:
|
||||
specifier: ^0.184.0
|
||||
version: 0.184.0
|
||||
@@ -320,6 +326,23 @@ packages:
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
gsap@3.15.0:
|
||||
resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
|
||||
|
||||
lenis@1.3.23:
|
||||
resolution: {integrity: sha512-YxYq3TJqj9sJNv0V9SkyQHejt14xwyIwgDaaMK89Uf9SxQfIszu+gTQSSphh6BWlLTNVKvvXAGkg+Zf+oFIevg==}
|
||||
peerDependencies:
|
||||
'@nuxt/kit': '>=3.0.0'
|
||||
react: '>=17.0.0'
|
||||
vue: '>=3.0.0'
|
||||
peerDependenciesMeta:
|
||||
'@nuxt/kit':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
nanoid@3.3.12:
|
||||
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
@@ -585,6 +608,10 @@ snapshots:
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
gsap@3.15.0: {}
|
||||
|
||||
lenis@1.3.23: {}
|
||||
|
||||
nanoid@3.3.12: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" role="img" aria-labelledby="title">
|
||||
<title id="title">DevOps 运维平台</title>
|
||||
<defs>
|
||||
<linearGradient id="panel" x1="18" x2="78" y1="14" y2="82" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22d3ee" />
|
||||
<stop offset="1" stop-color="#8b5cf6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="96" height="96" rx="22" fill="#08111f" />
|
||||
<rect x="16" y="20" width="64" height="48" rx="10" fill="url(#panel)" opacity="0.18" />
|
||||
<rect x="20" y="24" width="56" height="40" rx="7" fill="#0f172a" stroke="#38bdf8" stroke-opacity="0.65" />
|
||||
<path d="M29 37h19M29 48h11M53 48h14" stroke="#e0f2fe" stroke-width="4" stroke-linecap="round" />
|
||||
<circle cx="62" cy="37" r="5" fill="#34d399" />
|
||||
<path d="M31 74h34M39 64v10M57 64v10" stroke="#a5b4fc" stroke-width="4" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 876 B |
@@ -0,0 +1,301 @@
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import Lenis from "lenis";
|
||||
import { rounds } from "./dialogue";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
/* Lenis 平滑滚动 + ScrollTrigger 集成 */
|
||||
export function initSmoothScroll() {
|
||||
const lenis = new Lenis({ lerp: 0.1 });
|
||||
lenis.on("scroll", ScrollTrigger.update);
|
||||
gsap.ticker.add((time) => lenis.raf(time * 1000));
|
||||
gsap.ticker.lagSmoothing(0);
|
||||
return lenis;
|
||||
}
|
||||
|
||||
/* 打字机:时间轴上的离散字符 reveal(时间驱动,进入视口后完整播放) */
|
||||
function addTypewriter(tl, textEl, caretEl, fullText, start, duration) {
|
||||
const proxy = { p: 0 };
|
||||
tl.to(
|
||||
proxy,
|
||||
{
|
||||
p: 1,
|
||||
duration,
|
||||
ease: `steps(${fullText.length})`,
|
||||
onUpdate() {
|
||||
const n = Math.round(proxy.p * fullText.length);
|
||||
textEl.textContent = fullText.slice(0, n);
|
||||
if (caretEl) caretEl.style.opacity = proxy.p >= 1 ? "0" : "1";
|
||||
},
|
||||
},
|
||||
start
|
||||
);
|
||||
}
|
||||
|
||||
/* 根据文案长度推导打字时长,保持各轮节奏差异 */
|
||||
const typingDuration = (text) => Math.min(1.5, 0.34 + text.length * 0.07);
|
||||
|
||||
/* 开场(时间驱动,非滚动)*/
|
||||
export function playBoot(mind) {
|
||||
const boot = document.querySelector("#r-boot");
|
||||
const typedLine = boot.querySelector(".typed-line");
|
||||
const items = boot.querySelectorAll(".gen");
|
||||
gsap.set(boot.querySelector(".answer"), { opacity: 1 });
|
||||
gsap.set(items, { y: 26, autoAlpha: 0 });
|
||||
|
||||
const text = "这是我的数字思维体,向它提问即可。";
|
||||
const proxy = { p: 0 };
|
||||
const tl = gsap.timeline({ delay: 0.3 });
|
||||
|
||||
tl.call(() => mind.setState("thinking"))
|
||||
.to(proxy, {
|
||||
p: 1,
|
||||
duration: 1.4,
|
||||
ease: `steps(${text.length})`,
|
||||
onUpdate: () => {
|
||||
typedLine.textContent = text.slice(0, Math.round(proxy.p * text.length));
|
||||
},
|
||||
}, 0.5)
|
||||
.call(() => mind.setState("answering"), null, 1.9)
|
||||
.to(items, {
|
||||
y: 0,
|
||||
autoAlpha: 1,
|
||||
duration: 0.7,
|
||||
ease: "expo.out",
|
||||
stagger: { each: 0.09, from: "start" },
|
||||
}, 2.0)
|
||||
.call(() => mind.setState("idle"), null, 3.4);
|
||||
return tl;
|
||||
}
|
||||
|
||||
/* 单轮问答时间轴:提问打字 → 思考 → 回答爆发生成(一次性播放) */
|
||||
function buildRoundTimeline({ round, qText, qCaret, thinking, answer, items, mind }) {
|
||||
const typeDur = typingDuration(round.question);
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// 1. 提问打字
|
||||
addTypewriter(tl, qText, qCaret, round.question, 0, typeDur);
|
||||
// 2. 思考 shimmer(刻意停顿 = 非线性蓄力)
|
||||
tl.call(() => mind.setState("thinking"), null, typeDur)
|
||||
.to(thinking, { opacity: 1, duration: 0.22 }, typeDur)
|
||||
.to({}, { duration: 0.5 })
|
||||
.to(thinking, { opacity: 0, duration: 0.18 })
|
||||
// 3. 爆发生成:不等间隔 stagger 模拟 token 到达
|
||||
.call(() => mind.setState("answering"))
|
||||
.to(answer, { opacity: 1, duration: 0.2 }, "<")
|
||||
.to(
|
||||
items,
|
||||
{
|
||||
y: 0,
|
||||
autoAlpha: 1,
|
||||
duration: 0.7,
|
||||
ease: "expo.out",
|
||||
stagger: { each: Math.max(0.05, round.staggerEach), from: "start" },
|
||||
},
|
||||
"<0.05"
|
||||
)
|
||||
.call(() => mind.setState("idle"), null, "+=0.4");
|
||||
return tl;
|
||||
}
|
||||
|
||||
/* 轮次编排:进入视口即播放(不再依赖滚动 scrub,修复"不滚动则不加载") */
|
||||
export function initChoreography(mind, setActive = () => {}) {
|
||||
rounds
|
||||
.filter((r) => r.question)
|
||||
.forEach((round) => {
|
||||
const section = document.getElementById(`r-${round.id}`);
|
||||
const qText = section.querySelector(".q-text");
|
||||
const qCaret = section.querySelector(".q-caret");
|
||||
const thinking = section.querySelector(".thinking");
|
||||
const answer = section.querySelector(".answer");
|
||||
const items = section.querySelectorAll(".gen");
|
||||
|
||||
gsap.set(items, { y: 34, autoAlpha: 0 });
|
||||
|
||||
if (round.flow) {
|
||||
buildFlowRound({ round, section, qText, qCaret, thinking, answer, items, mind, setActive });
|
||||
return;
|
||||
}
|
||||
|
||||
const tl = buildRoundTimeline({ round, qText, qCaret, thinking, answer, items, mind });
|
||||
|
||||
ScrollTrigger.create({
|
||||
trigger: section,
|
||||
start: "top 60%",
|
||||
end: "bottom 40%",
|
||||
onToggle(self) {
|
||||
if (self.isActive) setActive(round.id);
|
||||
},
|
||||
onEnter: () => {
|
||||
mind.setSide(round.side);
|
||||
mind.setPalette(round.palette);
|
||||
tl.play();
|
||||
},
|
||||
onEnterBack: () => {
|
||||
mind.setSide(round.side);
|
||||
mind.setPalette(round.palette);
|
||||
},
|
||||
onLeave: () => mind.setState("idle"),
|
||||
onLeaveBack: () => mind.setState("idle"),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* 长内容轮:提问/思考时间驱动一次播放,内容块各自进入视口时「生成」 */
|
||||
function buildFlowRound({ round, section, qText, qCaret, thinking, answer, items, mind, setActive }) {
|
||||
gsap.set(answer, { opacity: 1 });
|
||||
|
||||
const typeDur = typingDuration(round.question);
|
||||
const qTl = gsap.timeline({ paused: true });
|
||||
addTypewriter(qTl, qText, qCaret, round.question, 0, typeDur);
|
||||
qTl.to(thinking, { opacity: 1, duration: 0.2 }, typeDur)
|
||||
.to(thinking, { opacity: 0, duration: 0.2 }, "+=0.5");
|
||||
|
||||
ScrollTrigger.create({
|
||||
trigger: section,
|
||||
start: "top 70%",
|
||||
once: true,
|
||||
onEnter: () => qTl.play(),
|
||||
});
|
||||
|
||||
items.forEach((item, i) => {
|
||||
gsap.to(item, {
|
||||
y: 0,
|
||||
autoAlpha: 1,
|
||||
duration: 0.8,
|
||||
delay: (i % 3) * 0.09, // 不等间隔,模拟 token 到达
|
||||
ease: "expo.out",
|
||||
scrollTrigger: { trigger: item, start: "top 88%" },
|
||||
});
|
||||
});
|
||||
|
||||
ScrollTrigger.create({
|
||||
trigger: section,
|
||||
start: "top 60%",
|
||||
end: "bottom 55%",
|
||||
onToggle(self) {
|
||||
if (self.isActive) {
|
||||
setActive(round.id);
|
||||
mind.setSide(round.side);
|
||||
mind.setPalette(round.palette);
|
||||
} else {
|
||||
mind.setState("idle");
|
||||
}
|
||||
},
|
||||
onUpdate(self) {
|
||||
mind.setState(self.progress < 0.1 ? "thinking" : "answering");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* 进度轨:高亮由编排回调驱动,这里只负责节点与跳转 */
|
||||
export function initRail(lenis) {
|
||||
const setActive = (id) => {
|
||||
document.querySelectorAll(".rail-node").forEach((n) => {
|
||||
n.classList.toggle("active", n.dataset.round === id);
|
||||
});
|
||||
};
|
||||
|
||||
ScrollTrigger.create({
|
||||
trigger: "#r-boot",
|
||||
start: "top top",
|
||||
end: "bottom 40%",
|
||||
onToggle(self) {
|
||||
if (self.isActive) setActive("boot");
|
||||
},
|
||||
});
|
||||
|
||||
const jump = (id) => {
|
||||
const target = document.getElementById(`r-${id}`);
|
||||
if (!target) return;
|
||||
if (lenis) lenis.scrollTo(target, { offset: 2, duration: 1.2 });
|
||||
else target.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
return { jump, setActive };
|
||||
}
|
||||
|
||||
/* 模块吸附:滚动停止后,若停在两轮之间的中间态,自动滚动到目标轮顶部,
|
||||
再由 onEnter 正常触发打字机动画(修复"滚不到位则动画错位") */
|
||||
export function initRoundSnap(lenis) {
|
||||
const sections = rounds
|
||||
.map((round) => document.getElementById(`r-${round.id}`))
|
||||
.filter(Boolean);
|
||||
if (!sections.length) return () => {};
|
||||
|
||||
let timer = 0;
|
||||
let lockUntil = 0;
|
||||
|
||||
const readY = () => (typeof lenis?.scroll === "number" ? lenis.scroll : window.scrollY);
|
||||
|
||||
const snapTo = (section) => {
|
||||
lockUntil = performance.now() + 700;
|
||||
const targetY = section.getBoundingClientRect().top + readY();
|
||||
if (lenis) {
|
||||
lenis.scrollTo(targetY, {
|
||||
duration: 0.55,
|
||||
easing: (t) => 1 - Math.pow(1 - t, 3),
|
||||
});
|
||||
} else {
|
||||
window.scrollTo({ top: targetY, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
const settle = () => {
|
||||
if (performance.now() < lockUntil) return;
|
||||
const vh = window.innerHeight || document.documentElement.clientHeight;
|
||||
|
||||
for (const section of sections) {
|
||||
const top = section.getBoundingClientRect().top;
|
||||
// 已有模块对齐视口顶部 → 状态正常,无需吸附
|
||||
if (Math.abs(top) < 8) return;
|
||||
// 模块露出一半(未到打字机区域)→ 吸附进入该模块
|
||||
const halfEntered = top > vh * 0.12 && top < vh * 0.72;
|
||||
// 模块占据主屏但未对齐 → 吸附回正
|
||||
const nearlyAligned = top > -vh * 0.45 && top <= vh * 0.12;
|
||||
if (halfEntered || nearlyAligned) {
|
||||
snapTo(section);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 长内容轮(flow)内部深处不匹配任何区间 → 自由滚动,不干预
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
window.clearTimeout(timer);
|
||||
// lenis 惯性收尾阶段速度趋零:立即判定吸附,不等事件完全静止
|
||||
const velocity =
|
||||
lenis && typeof lenis.velocity === "number" ? Math.abs(lenis.velocity) : null;
|
||||
if (velocity !== null && velocity < 0.3) {
|
||||
settle();
|
||||
return;
|
||||
}
|
||||
timer = window.setTimeout(settle, 90);
|
||||
};
|
||||
|
||||
if (lenis) lenis.on("scroll", onScroll);
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
window.addEventListener("resize", onScroll);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
window.removeEventListener("resize", onScroll);
|
||||
};
|
||||
}
|
||||
|
||||
/* 降级:reduced motion 直接展示全部内容 */
|
||||
export function applyReducedMotion() {
|
||||
rounds
|
||||
.filter((r) => r.question)
|
||||
.forEach((round) => {
|
||||
const section = document.getElementById(`r-${round.id}`);
|
||||
section.querySelector(".q-text").textContent = round.question;
|
||||
const answer = section.querySelector(".answer");
|
||||
answer.style.opacity = "1";
|
||||
});
|
||||
const boot = document.querySelector("#r-boot");
|
||||
boot.querySelector(".typed-line").textContent = "这是我的数字思维体。";
|
||||
document.querySelectorAll(".gen").forEach((n) => (n.style.opacity = "1"));
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// 7 轮问答的文案与节奏配置 —— 每轮节奏参数刻意不同,避免线性重复
|
||||
export const contact = {
|
||||
emails: ["419021733@qq.com", "wmagmgema521@gmail.com"],
|
||||
phone: "19980439383",
|
||||
github: "https://github.com/zhanBoss",
|
||||
identity: "男 · 29 岁 · 电子科技大学 信息管理与信息系统 本科",
|
||||
};
|
||||
|
||||
export const rounds = [
|
||||
{
|
||||
id: "boot",
|
||||
label: "BOOT",
|
||||
type: "boot",
|
||||
question: null,
|
||||
side: 1,
|
||||
palette: ["#22d3ee", "#a78bfa"],
|
||||
},
|
||||
{
|
||||
id: "intro",
|
||||
label: "WHO",
|
||||
type: "signals",
|
||||
question: "先介绍一下你自己?",
|
||||
side: -1,
|
||||
palette: ["#2dd4bf", "#a78bfa"],
|
||||
staggerEach: 0.055,
|
||||
},
|
||||
{
|
||||
id: "focus",
|
||||
label: "NOW",
|
||||
type: "focus",
|
||||
question: "最近一年具体在做什么?",
|
||||
side: 1,
|
||||
palette: ["#22d3ee", "#c084fc"],
|
||||
staggerEach: 0.085,
|
||||
},
|
||||
{
|
||||
id: "projects",
|
||||
label: "PROOF",
|
||||
type: "projects",
|
||||
question: "有实际项目证明吗?",
|
||||
side: -1,
|
||||
palette: ["#38bdf8", "#a78bfa"],
|
||||
flow: true, // 内容超过一屏:逐块流式生成
|
||||
staggerEach: 0.045,
|
||||
},
|
||||
{
|
||||
id: "skills",
|
||||
label: "STACK",
|
||||
type: "skills",
|
||||
question: "技能栈展开讲讲?",
|
||||
side: 1,
|
||||
palette: ["#67e8f9", "#8b5cf6"],
|
||||
staggerEach: 0.02,
|
||||
},
|
||||
{
|
||||
id: "experience",
|
||||
label: "PATH",
|
||||
type: "experience",
|
||||
question: "之前的团队经历?",
|
||||
side: -1,
|
||||
palette: ["#5eead4", "#a78bfa"],
|
||||
staggerEach: 0.1,
|
||||
},
|
||||
{
|
||||
id: "contact",
|
||||
label: "PING",
|
||||
type: "contact",
|
||||
question: "怎么联系你?",
|
||||
side: 0,
|
||||
palette: ["#22d3ee", "#f0abfc"],
|
||||
staggerEach: 0.09,
|
||||
},
|
||||
];
|
||||
+30
-663
@@ -1,669 +1,36 @@
|
||||
import * as THREE from "three";
|
||||
import { experiences, focusAreas, metrics, projects, resumeSignals, skills } from "./resume-data";
|
||||
import gsap from "gsap";
|
||||
import { renderDialogue, renderRail } from "./render";
|
||||
import { createMind } from "./mind/mind";
|
||||
import {
|
||||
applyReducedMotion,
|
||||
initChoreography,
|
||||
initRail,
|
||||
initRoundSnap,
|
||||
initSmoothScroll,
|
||||
playBoot,
|
||||
} from "./choreography";
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const $$ = (selector) => [...document.querySelectorAll(selector)];
|
||||
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
const metricRow = $("#metric-row");
|
||||
const aiFocusGrid = $("#ai-focus-grid");
|
||||
const resumeSignalList = $("#resume-signal-list");
|
||||
const projectStack = $("#project-stack");
|
||||
const skillMatrix = $("#skill-matrix");
|
||||
const timeline = $("#timeline");
|
||||
const progress = $("#scroll-progress");
|
||||
const themeToggle = $("#theme-toggle");
|
||||
const root = document.documentElement;
|
||||
/* 1. 由简历数据生成对话 DOM */
|
||||
renderDialogue(document.getElementById("dialogue"));
|
||||
|
||||
const clamp = (value, min = 0, max = 1) => Math.min(max, Math.max(min, value));
|
||||
const lerp = (from, to, amount) => from + (to - from) * amount;
|
||||
const smoothstep = (edge0, edge1, value) => {
|
||||
const t = clamp((value - edge0) / (edge1 - edge0));
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
/* 2. 思维流体 */
|
||||
const mind = createMind(document.getElementById("mind-canvas"), { reduced });
|
||||
window.__mind = mind;
|
||||
gsap.ticker.add(() => mind.tick());
|
||||
|
||||
const fluidScenes = [
|
||||
{
|
||||
id: "top",
|
||||
name: "hero",
|
||||
colors: ["#64cbb8", "#8aa4d6", "#d77b91"],
|
||||
desktop: { x: 2.05, y: 0.22, z: -0.78, scale: 1.08 },
|
||||
compact: { x: 1.34, y: 1.14, z: -1.08, scale: 0.38 },
|
||||
flow: 0.62,
|
||||
twist: 0.48,
|
||||
ring: 0.82,
|
||||
particle: 0.78,
|
||||
angle: 128,
|
||||
},
|
||||
{
|
||||
id: "ai-fit",
|
||||
name: "ai-fit",
|
||||
colors: ["#74c8bd", "#8097d4", "#d4b56c"],
|
||||
desktop: { x: 1.84, y: 0.08, z: -0.88, scale: 0.96 },
|
||||
compact: { x: 1.18, y: 1.05, z: -1.14, scale: 0.34 },
|
||||
flow: 0.48,
|
||||
twist: 0.72,
|
||||
ring: 0.62,
|
||||
particle: 0.58,
|
||||
angle: 154,
|
||||
},
|
||||
{
|
||||
id: "proof",
|
||||
name: "proof",
|
||||
colors: ["#d4b56c", "#6dc4b1", "#c97a8d"],
|
||||
desktop: { x: 2.22, y: -0.04, z: -0.84, scale: 0.9 },
|
||||
compact: { x: 1.48, y: 1.0, z: -1.18, scale: 0.31 },
|
||||
flow: 0.36,
|
||||
twist: 0.34,
|
||||
ring: 0.92,
|
||||
particle: 0.52,
|
||||
angle: 96,
|
||||
},
|
||||
{
|
||||
id: "projects",
|
||||
name: "projects",
|
||||
colors: ["#889fd4", "#c97991", "#68c7b2"],
|
||||
desktop: { x: 1.68, y: 0.26, z: -0.7, scale: 1.18 },
|
||||
compact: { x: 1.22, y: 1.18, z: -1.02, scale: 0.4 },
|
||||
flow: 0.82,
|
||||
twist: 0.92,
|
||||
ring: 0.78,
|
||||
particle: 0.9,
|
||||
angle: 202,
|
||||
},
|
||||
{
|
||||
id: "skills",
|
||||
name: "skills",
|
||||
colors: ["#75bd9a", "#d1b46d", "#879dd0"],
|
||||
desktop: { x: 2.3, y: 0.04, z: -0.96, scale: 0.86 },
|
||||
compact: { x: 1.52, y: 1.08, z: -1.22, scale: 0.31 },
|
||||
flow: 0.42,
|
||||
twist: 0.56,
|
||||
ring: 1.08,
|
||||
particle: 0.46,
|
||||
angle: 72,
|
||||
},
|
||||
{
|
||||
id: "experience",
|
||||
name: "experience",
|
||||
colors: ["#9a8fcb", "#70beb0", "#849bd0"],
|
||||
desktop: { x: 1.96, y: -0.1, z: -0.92, scale: 1.02 },
|
||||
compact: { x: 1.36, y: 1.0, z: -1.14, scale: 0.36 },
|
||||
flow: 0.3,
|
||||
twist: 0.82,
|
||||
ring: 0.72,
|
||||
particle: 0.64,
|
||||
angle: 232,
|
||||
},
|
||||
{
|
||||
id: "contact",
|
||||
name: "contact",
|
||||
colors: ["#c97991", "#d0b56f", "#70bda8"],
|
||||
desktop: { x: 1.46, y: 0.02, z: -0.66, scale: 1.24 },
|
||||
compact: { x: 1.16, y: 1.14, z: -1.02, scale: 0.42 },
|
||||
flow: 0.24,
|
||||
twist: 0.28,
|
||||
ring: 0.5,
|
||||
particle: 0.42,
|
||||
angle: 312,
|
||||
},
|
||||
].map((scene) => ({
|
||||
...scene,
|
||||
colorObjects: scene.colors.map((color) => new THREE.Color(color)),
|
||||
}));
|
||||
/* 3. 进度轨 + 滚动编排 */
|
||||
let lenis = null;
|
||||
if (!reduced) lenis = initSmoothScroll();
|
||||
|
||||
function getSceneFrame() {
|
||||
const center = window.scrollY + window.innerHeight * 0.28;
|
||||
const points = fluidScenes.map((scene) => ({
|
||||
scene,
|
||||
top: scene.id === "top" ? 0 : document.getElementById(scene.id)?.offsetTop ?? document.documentElement.scrollHeight,
|
||||
}));
|
||||
let index = 0;
|
||||
for (let i = 0; i < points.length - 1; i += 1) {
|
||||
if (center >= points[i + 1].top) index = i + 1;
|
||||
}
|
||||
const current = points[index];
|
||||
const next = points[Math.min(points.length - 1, index + 1)];
|
||||
const span = Math.max(1, next.top - current.top);
|
||||
const blend = current === next ? 0 : smoothstep(0.18, 0.86, (center - current.top) / span);
|
||||
return { current: current.scene, next: next.scene, blend };
|
||||
const rail = initRail(lenis);
|
||||
renderRail(document.getElementById("session-rail"), rail.jump);
|
||||
|
||||
if (!reduced) {
|
||||
initChoreography(mind, rail.setActive);
|
||||
initRoundSnap(lenis);
|
||||
playBoot(mind);
|
||||
} else {
|
||||
applyReducedMotion();
|
||||
}
|
||||
|
||||
function mixScene(frame, compact) {
|
||||
const layoutA = compact ? frame.current.compact : frame.current.desktop;
|
||||
const layoutB = compact ? frame.next.compact : frame.next.desktop;
|
||||
return {
|
||||
name: frame.blend > 0.58 ? frame.next.name : frame.current.name,
|
||||
x: lerp(layoutA.x, layoutB.x, frame.blend),
|
||||
y: lerp(layoutA.y, layoutB.y, frame.blend),
|
||||
z: lerp(layoutA.z, layoutB.z, frame.blend),
|
||||
scale: lerp(layoutA.scale, layoutB.scale, frame.blend),
|
||||
flow: lerp(frame.current.flow, frame.next.flow, frame.blend),
|
||||
twist: lerp(frame.current.twist, frame.next.twist, frame.blend),
|
||||
ring: lerp(frame.current.ring, frame.next.ring, frame.blend),
|
||||
particle: lerp(frame.current.particle, frame.next.particle, frame.blend),
|
||||
angle: lerp(frame.current.angle, frame.next.angle, frame.blend),
|
||||
colors: frame.current.colorObjects.map((color, index) => color.clone().lerp(frame.next.colorObjects[index], frame.blend)),
|
||||
};
|
||||
}
|
||||
|
||||
function writeSceneCss(scene, scroll) {
|
||||
root.dataset.scene = scene.name;
|
||||
root.style.setProperty("--scene-a", scene.colors[0].getStyle());
|
||||
root.style.setProperty("--scene-b", scene.colors[1].getStyle());
|
||||
root.style.setProperty("--scene-c", scene.colors[2].getStyle());
|
||||
root.style.setProperty("--scene-angle", `${scene.angle.toFixed(2)}deg`);
|
||||
root.style.setProperty("--scene-opacity", (0.18 + scene.flow * 0.12).toFixed(3));
|
||||
root.style.setProperty("--scene-drift-x", `${(-4 + scene.x * 1.4 - scroll * 3).toFixed(2)}vw`);
|
||||
root.style.setProperty("--scene-drift-y", `${(-2 + scene.y * 2 + scroll * 2.5).toFixed(2)}vh`);
|
||||
root.style.setProperty("--scene-rotate", `${(-1 + scene.twist * 2.5 + scroll * 3).toFixed(2)}deg`);
|
||||
}
|
||||
|
||||
function renderMetrics() {
|
||||
metricRow.innerHTML = metrics
|
||||
.map((item, index) => `<article class="reveal" data-drift="${index % 2 === 0 ? 28 : -28}"><strong>${item.value}</strong><span>${item.label}</span></article>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderFocusAreas() {
|
||||
aiFocusGrid.innerHTML = focusAreas
|
||||
.map(
|
||||
(item, index) => `
|
||||
<article class="ai-focus-card reveal" data-drift="${index % 2 === 0 ? -34 : 34}">
|
||||
<span>${String(index + 1).padStart(2, "0")}</span>
|
||||
<h3>${item.title}</h3>
|
||||
<p>${item.summary}</p>
|
||||
<div>${item.points.map((point) => `<em>${point}</em>`).join("")}</div>
|
||||
</article>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderResumeSignals() {
|
||||
resumeSignalList.innerHTML = resumeSignals.map((signal) => `<li>${signal}</li>`).join("");
|
||||
}
|
||||
|
||||
function renderProjects() {
|
||||
projectStack.innerHTML = projects
|
||||
.map(
|
||||
(project, index) => `
|
||||
<article class="project-card reveal" data-drift="${index % 2 === 0 ? -52 : 52}">
|
||||
<div class="project-index">
|
||||
<span class="logo-frame"><img src="${project.logo}" alt="" loading="lazy" decoding="async" /></span>
|
||||
<span>${String(index + 1).padStart(2, "0")}</span>
|
||||
</div>
|
||||
<div class="project-body">
|
||||
<div class="project-head">
|
||||
<div>
|
||||
<h3>${project.name}</h3>
|
||||
<p>${project.subtitle}</p>
|
||||
</div>
|
||||
<time>${project.period}</time>
|
||||
</div>
|
||||
<p class="project-summary">${project.summary}</p>
|
||||
<ul>${project.modules.map((item) => `<li>${item}</li>`).join("")}</ul>
|
||||
<div class="tech-row">${project.tech.map((item) => `<span>${item}</span>`).join("")}</div>
|
||||
</div>
|
||||
</article>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderSkills() {
|
||||
skillMatrix.innerHTML = skills
|
||||
.map(
|
||||
(skill, index) => `
|
||||
<article class="skill-group reveal" data-drift="${index % 3 === 0 ? -36 : index % 3 === 1 ? 18 : 42}">
|
||||
<h3>${skill.group}</h3>
|
||||
<div>${skill.items.map((item) => `<span>${item}</span>`).join("")}</div>
|
||||
</article>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderExperience() {
|
||||
timeline.innerHTML = experiences
|
||||
.map(
|
||||
(item, index) => `
|
||||
<article class="reveal" data-drift="${index % 2 === 0 ? 38 : -38}">
|
||||
<div class="timeline-side">
|
||||
<span class="logo-frame"><img src="${item.logo}" alt="" loading="lazy" decoding="async" /></span>
|
||||
<time>${item.period}</time>
|
||||
</div>
|
||||
<div>
|
||||
<h3>${item.company}</h3>
|
||||
<p>${item.role}</p>
|
||||
<ul>${item.points.map((point) => `<li>${point}</li>`).join("")}</ul>
|
||||
</div>
|
||||
</article>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function setupReveal() {
|
||||
$$(".reveal").forEach((node) => {
|
||||
node.style.setProperty("--drift", node.dataset.drift || 0);
|
||||
});
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) entry.target.classList.add("is-visible");
|
||||
});
|
||||
},
|
||||
{ threshold: 0.14 },
|
||||
);
|
||||
|
||||
$$(".reveal").forEach((node) => observer.observe(node));
|
||||
}
|
||||
|
||||
function updateScrollProgress() {
|
||||
const max = document.documentElement.scrollHeight - window.innerHeight;
|
||||
const value = max > 0 ? window.scrollY / max : 0;
|
||||
root.style.setProperty("--scroll-progress", value.toFixed(4));
|
||||
root.style.setProperty("--bg-grid-x", `${(-220 * value).toFixed(2)}px`);
|
||||
root.style.setProperty("--bg-grid-y", `${(150 * value).toFixed(2)}px`);
|
||||
root.style.setProperty("--bg-grid-x-2", `${(140 * value).toFixed(2)}px`);
|
||||
root.style.setProperty("--bg-grid-y-2", `${(-190 * value).toFixed(2)}px`);
|
||||
progress.style.transform = `scaleX(${Math.min(1, Math.max(0, value))})`;
|
||||
|
||||
const frame = getSceneFrame();
|
||||
const activeScene = frame.blend > 0.58 ? frame.next : frame.current;
|
||||
const sections = fluidScenes
|
||||
.filter((scene) => scene.id !== "top")
|
||||
.map((scene) => document.getElementById(scene.id))
|
||||
.filter(Boolean);
|
||||
const current = activeScene.id === "top" ? null : sections.find((section) => section.id === activeScene.id);
|
||||
$$(".site-nav a").forEach((link) => link.classList.toggle("active", current && link.getAttribute("href") === `#${current.id}`));
|
||||
}
|
||||
|
||||
function restoreHashPosition() {
|
||||
const id = decodeURIComponent(location.hash.slice(1));
|
||||
if (!id) return;
|
||||
const target = document.getElementById(id);
|
||||
if (!target) return;
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo({ top: target.offsetTop, behavior: "auto" });
|
||||
updateScrollProgress();
|
||||
});
|
||||
}
|
||||
|
||||
function setupTheme() {
|
||||
const saved = localStorage.getItem("wy-fluid-theme");
|
||||
if (saved) document.documentElement.dataset.theme = saved;
|
||||
themeToggle.addEventListener("click", () => {
|
||||
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
|
||||
document.documentElement.dataset.theme = next;
|
||||
localStorage.setItem("wy-fluid-theme", next);
|
||||
});
|
||||
}
|
||||
|
||||
function setupTilts() {
|
||||
$$(".project-card").forEach((card) => {
|
||||
card.addEventListener("pointerenter", () => card.classList.add("is-hovered"));
|
||||
card.addEventListener("pointerleave", () => card.classList.remove("is-hovered"));
|
||||
});
|
||||
}
|
||||
|
||||
function setupFluidScene() {
|
||||
const canvas = $("#fluid-canvas");
|
||||
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (!canvas || reduceMotion) return;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
canvas,
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
preserveDrawingBuffer: true,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(39, 1, 0.1, 100);
|
||||
camera.position.set(0, 0, 6.2);
|
||||
|
||||
const pointer = new THREE.Vector2(0.18, -0.08);
|
||||
const pointerTarget = new THREE.Vector2(0.18, -0.08);
|
||||
const pointerVisual = new THREE.Vector2(58, 46);
|
||||
const pointerVisualTarget = new THREE.Vector2(58, 46);
|
||||
const pointerState = {
|
||||
down: false,
|
||||
lastX: window.innerWidth * 0.58,
|
||||
lastY: window.innerHeight * 0.46,
|
||||
velocity: 0,
|
||||
targetVelocity: 0,
|
||||
impact: 0,
|
||||
pressure: 0,
|
||||
};
|
||||
const group = new THREE.Group();
|
||||
scene.add(group);
|
||||
|
||||
const uniforms = {
|
||||
uTime: { value: 0 },
|
||||
uPointer: { value: pointer },
|
||||
uScroll: { value: 0 },
|
||||
uImpact: { value: 0 },
|
||||
uFlow: { value: fluidScenes[0].flow },
|
||||
uTwist: { value: fluidScenes[0].twist },
|
||||
uColorA: { value: fluidScenes[0].colorObjects[0].clone() },
|
||||
uColorB: { value: fluidScenes[0].colorObjects[1].clone() },
|
||||
uColorC: { value: fluidScenes[0].colorObjects[2].clone() },
|
||||
uTheme: { value: document.documentElement.dataset.theme === "light" ? 1 : 0 },
|
||||
};
|
||||
|
||||
const vertexShader = `
|
||||
uniform float uTime;
|
||||
uniform vec2 uPointer;
|
||||
uniform float uScroll;
|
||||
uniform float uImpact;
|
||||
uniform float uFlow;
|
||||
uniform float uTwist;
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vWorld;
|
||||
varying float vWave;
|
||||
|
||||
float ridge(float value) {
|
||||
return 1.0 - abs(value);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec3 p = position;
|
||||
float t = uTime;
|
||||
float sceneBreath = sin(uScroll * 6.283 + t * (0.18 + uFlow * 0.22));
|
||||
float waveA = sin(p.x * (2.15 + uTwist * 1.25) + t * (0.58 + uFlow * 0.82) + sin(p.y * 1.4 + t * 0.32));
|
||||
float waveB = cos(p.y * (2.6 + uTwist * 1.8) - t * (0.42 + uFlow * 0.74) + p.z * (1.35 + uTwist));
|
||||
float waveC = sin((p.x + p.y + p.z) * (1.58 + uTwist * 1.1) + t * (0.86 + uFlow * 1.05));
|
||||
vec2 pointerPlane = uPointer * vec2(1.55, 1.08);
|
||||
float pointerDistance = distance(p.xy, pointerPlane);
|
||||
float pointerWave = ridge(sin(pointerDistance * (4.8 + uImpact * 1.2) - t * (1.8 + uImpact * 0.8)));
|
||||
float pointerPull = smoothstep(1.62, 0.18, pointerDistance);
|
||||
float scrollWave = sin(uScroll * (5.5 + uFlow * 5.0) + p.z * (2.4 + uTwist * 1.6) + sceneBreath) * (0.045 + uTwist * 0.055);
|
||||
float displacement = waveA * (0.11 + uFlow * 0.065) + waveB * (0.085 + uTwist * 0.04) + waveC * (0.052 + uFlow * 0.035) + pointerWave * (0.055 + uImpact * 0.11) + pointerPull * (0.05 + uImpact * 0.075) + scrollWave;
|
||||
p += normal * displacement;
|
||||
vec2 pointerDir = normalize((p.xy - pointerPlane) + vec2(0.0001, -0.0001));
|
||||
p.xy += pointerDir * pointerPull * (0.024 + uImpact * 0.055);
|
||||
p.x += sin(t * (0.34 + uFlow * 0.28) + p.y * (2.1 + uTwist * 0.9)) * (0.04 + uFlow * 0.03);
|
||||
p.y += cos(t * (0.28 + uFlow * 0.26) + p.x * (1.8 + uTwist * 0.75)) * (0.032 + uTwist * 0.025);
|
||||
vec4 world = modelMatrix * vec4(p, 1.0);
|
||||
vNormal = normalize(normalMatrix * normal);
|
||||
vWorld = world.xyz;
|
||||
vWave = displacement;
|
||||
gl_Position = projectionMatrix * viewMatrix * world;
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = `
|
||||
uniform float uTime;
|
||||
uniform float uTheme;
|
||||
uniform vec3 uColorA;
|
||||
uniform vec3 uColorB;
|
||||
uniform vec3 uColorC;
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vWorld;
|
||||
varying float vWave;
|
||||
|
||||
void main() {
|
||||
vec3 viewDir = normalize(cameraPosition - vWorld);
|
||||
float fresnel = pow(1.0 - max(dot(normalize(vNormal), viewDir), 0.0), 2.4);
|
||||
float scan = sin((vWorld.x - vWorld.y) * 8.0 + uTime * 1.8) * 0.5 + 0.5;
|
||||
vec3 darkA = uColorA;
|
||||
vec3 darkB = uColorB;
|
||||
vec3 darkC = uColorC;
|
||||
vec3 lightA = mix(uColorA, vec3(0.95, 1.0, 0.96), 0.42);
|
||||
vec3 lightB = mix(uColorB, vec3(0.9, 0.94, 1.0), 0.34);
|
||||
vec3 lightC = mix(uColorC, vec3(1.0, 0.94, 0.9), 0.3);
|
||||
vec3 a = mix(darkA, lightA, uTheme);
|
||||
vec3 b = mix(darkB, lightB, uTheme);
|
||||
vec3 c = mix(darkC, lightC, uTheme);
|
||||
vec3 color = mix(a, b, smoothstep(-0.22, 0.38, vWave));
|
||||
color = mix(color, c, fresnel * 0.86 + scan * 0.12);
|
||||
color += fresnel * 0.16;
|
||||
float alpha = 0.56 + fresnel * 0.24 + abs(vWave) * 0.06;
|
||||
gl_FragColor = vec4(color, alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
const material = new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
const shellMaterial = new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
transparent: true,
|
||||
opacity: 0.13,
|
||||
wireframe: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
|
||||
const blob = new THREE.Mesh(new THREE.SphereGeometry(1.52, 128, 128), material);
|
||||
const shell = new THREE.Mesh(new THREE.SphereGeometry(1.72, 48, 32), shellMaterial);
|
||||
shell.rotation.set(0.6, 0.1, -0.4);
|
||||
group.add(blob, shell);
|
||||
|
||||
const ringMaterial = new THREE.MeshBasicMaterial({
|
||||
color: 0x74a7ff,
|
||||
transparent: true,
|
||||
opacity: 0.16,
|
||||
wireframe: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const rings = Array.from({ length: 4 }, (_, index) => {
|
||||
const ring = new THREE.Mesh(new THREE.TorusGeometry(1.9 + index * 0.16, 0.008, 8, 180), ringMaterial.clone());
|
||||
ring.rotation.set(Math.PI / 2.3 + index * 0.18, index * 0.5, index * 0.8);
|
||||
ring.material.opacity = 0.08 + index * 0.018;
|
||||
group.add(ring);
|
||||
return ring;
|
||||
});
|
||||
|
||||
const rayMaterial = new THREE.LineBasicMaterial({
|
||||
color: 0x56f7c5,
|
||||
transparent: true,
|
||||
opacity: 0.025,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const rayGeometry = new THREE.BufferGeometry();
|
||||
const rayPositions = new Float32Array(18 * 3);
|
||||
rayGeometry.setAttribute("position", new THREE.BufferAttribute(rayPositions, 3));
|
||||
const interactionRay = new THREE.LineSegments(rayGeometry, rayMaterial);
|
||||
group.add(interactionRay);
|
||||
|
||||
const particleCount = 950;
|
||||
const positions = new Float32Array(particleCount * 3);
|
||||
const colors = new Float32Array(particleCount * 3);
|
||||
const colorA = new THREE.Color("#56f7c5");
|
||||
const colorB = new THREE.Color("#ff5ea8");
|
||||
const colorC = new THREE.Color("#74a7ff");
|
||||
for (let index = 0; index < particleCount; index += 1) {
|
||||
const radius = 2.05 + Math.random() * 2.3;
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(Math.random() * 2 - 1);
|
||||
positions[index * 3] = Math.sin(phi) * Math.cos(theta) * radius;
|
||||
positions[index * 3 + 1] = Math.sin(phi) * Math.sin(theta) * radius;
|
||||
positions[index * 3 + 2] = Math.cos(phi) * radius;
|
||||
const color = index % 3 === 0 ? colorA : index % 3 === 1 ? colorB : colorC;
|
||||
colors[index * 3] = color.r;
|
||||
colors[index * 3 + 1] = color.g;
|
||||
colors[index * 3 + 2] = color.b;
|
||||
}
|
||||
const particleGeometry = new THREE.BufferGeometry();
|
||||
particleGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
particleGeometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
|
||||
const particles = new THREE.Points(
|
||||
particleGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
size: 0.018,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.78,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
}),
|
||||
);
|
||||
group.add(particles);
|
||||
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
const basePosition = new THREE.Vector3(fluidScenes[0].desktop.x, fluidScenes[0].desktop.y, fluidScenes[0].desktop.z);
|
||||
const sceneVisual = {
|
||||
name: fluidScenes[0].name,
|
||||
x: basePosition.x,
|
||||
y: basePosition.y,
|
||||
z: basePosition.z,
|
||||
scale: fluidScenes[0].desktop.scale,
|
||||
flow: fluidScenes[0].flow,
|
||||
twist: fluidScenes[0].twist,
|
||||
ring: fluidScenes[0].ring,
|
||||
particle: fluidScenes[0].particle,
|
||||
angle: fluidScenes[0].angle,
|
||||
colors: fluidScenes[0].colorObjects.map((color) => color.clone()),
|
||||
};
|
||||
function resize() {
|
||||
width = window.innerWidth;
|
||||
height = window.innerHeight;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
renderer.setPixelRatio(dpr);
|
||||
renderer.setSize(width, height, false);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
const compact = width < 760;
|
||||
const target = mixScene(getSceneFrame(), compact);
|
||||
basePosition.set(target.x, target.y, target.z);
|
||||
group.position.copy(basePosition);
|
||||
group.scale.setScalar(target.scale);
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
const dx = event.clientX - pointerState.lastX;
|
||||
const dy = event.clientY - pointerState.lastY;
|
||||
pointerState.lastX = event.clientX;
|
||||
pointerState.lastY = event.clientY;
|
||||
const speed = Math.hypot(dx, dy);
|
||||
pointerState.targetVelocity = Math.min(1, speed / 170);
|
||||
pointerState.impact = Math.max(pointerState.impact, Math.min(0.26, pointerState.targetVelocity * 0.2));
|
||||
pointerTarget.x = (event.clientX / window.innerWidth) * 2 - 1;
|
||||
pointerTarget.y = -((event.clientY / window.innerHeight) * 2 - 1);
|
||||
pointerVisualTarget.set((event.clientX / window.innerWidth) * 100, (event.clientY / window.innerHeight) * 100);
|
||||
}
|
||||
|
||||
function onPointerDown(event) {
|
||||
pointerState.down = true;
|
||||
pointerState.impact = Math.max(pointerState.impact, 0.46);
|
||||
root.classList.add("is-pointer-down");
|
||||
onPointerMove(event);
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
pointerState.down = false;
|
||||
root.classList.remove("is-pointer-down");
|
||||
}
|
||||
|
||||
window.addEventListener("resize", resize);
|
||||
window.addEventListener("pointermove", onPointerMove, { passive: true });
|
||||
window.addEventListener("pointerdown", onPointerDown, { passive: true });
|
||||
window.addEventListener("pointerup", onPointerUp, { passive: true });
|
||||
window.addEventListener("pointercancel", onPointerUp, { passive: true });
|
||||
resize();
|
||||
|
||||
const startedAt = performance.now();
|
||||
function animate() {
|
||||
const elapsed = (performance.now() - startedAt) / 1000;
|
||||
const scrollMax = document.documentElement.scrollHeight - window.innerHeight;
|
||||
const scroll = scrollMax > 0 ? window.scrollY / scrollMax : 0;
|
||||
const compact = width < 760;
|
||||
const sceneTarget = mixScene(getSceneFrame(), compact);
|
||||
sceneVisual.name = sceneTarget.name;
|
||||
sceneVisual.x = lerp(sceneVisual.x, sceneTarget.x, 0.045);
|
||||
sceneVisual.y = lerp(sceneVisual.y, sceneTarget.y, 0.045);
|
||||
sceneVisual.z = lerp(sceneVisual.z, sceneTarget.z, 0.045);
|
||||
sceneVisual.scale = lerp(sceneVisual.scale, sceneTarget.scale, 0.045);
|
||||
sceneVisual.flow = lerp(sceneVisual.flow, sceneTarget.flow, 0.04);
|
||||
sceneVisual.twist = lerp(sceneVisual.twist, sceneTarget.twist, 0.04);
|
||||
sceneVisual.ring = lerp(sceneVisual.ring, sceneTarget.ring, 0.04);
|
||||
sceneVisual.particle = lerp(sceneVisual.particle, sceneTarget.particle, 0.04);
|
||||
sceneVisual.angle = lerp(sceneVisual.angle, sceneTarget.angle, 0.035);
|
||||
sceneVisual.colors.forEach((color, index) => color.lerp(sceneTarget.colors[index], 0.045));
|
||||
basePosition.set(sceneVisual.x, sceneVisual.y, sceneVisual.z);
|
||||
writeSceneCss(sceneVisual, scroll);
|
||||
pointer.lerp(pointerTarget, 0.024);
|
||||
pointerVisual.lerp(pointerVisualTarget, 0.07);
|
||||
root.style.setProperty("--cursor-x", `${pointerVisual.x.toFixed(2)}vw`);
|
||||
root.style.setProperty("--cursor-y", `${pointerVisual.y.toFixed(2)}vh`);
|
||||
pointerState.velocity += (pointerState.targetVelocity - pointerState.velocity) * 0.045;
|
||||
pointerState.targetVelocity *= 0.82;
|
||||
pointerState.pressure += ((pointerState.down ? 1 : 0) - pointerState.pressure) * 0.075;
|
||||
pointerState.impact *= pointerState.down ? 0.985 : 0.955;
|
||||
uniforms.uImpact.value = Math.min(0.72, pointerState.velocity * 0.24 + pointerState.impact * 0.72 + pointerState.pressure * 0.12);
|
||||
uniforms.uTime.value = elapsed;
|
||||
uniforms.uScroll.value = scroll;
|
||||
uniforms.uFlow.value = sceneVisual.flow;
|
||||
uniforms.uTwist.value = sceneVisual.twist;
|
||||
uniforms.uColorA.value.copy(sceneVisual.colors[0]);
|
||||
uniforms.uColorB.value.copy(sceneVisual.colors[1]);
|
||||
uniforms.uColorC.value.copy(sceneVisual.colors[2]);
|
||||
uniforms.uTheme.value += ((document.documentElement.dataset.theme === "light" ? 1 : 0) - uniforms.uTheme.value) * 0.08;
|
||||
|
||||
group.position.x = basePosition.x + pointer.x * (0.06 + uniforms.uImpact.value * 0.035);
|
||||
group.position.y = basePosition.y + Math.sin(elapsed * 0.47) * 0.07 + pointer.y * (0.045 + uniforms.uImpact.value * 0.03);
|
||||
group.position.z = basePosition.z + uniforms.uImpact.value * 0.025;
|
||||
group.scale.setScalar(sceneVisual.scale + Math.sin(elapsed * 0.18 + scroll * 5.2) * 0.018);
|
||||
group.rotation.x = Math.sin(elapsed * 0.28) * 0.14 + pointer.y * (0.11 + uniforms.uImpact.value * 0.055);
|
||||
group.rotation.y = elapsed * (0.055 + sceneVisual.flow * 0.065 + uniforms.uImpact.value * 0.018) + pointer.x * (0.18 + uniforms.uImpact.value * 0.075) + scroll * (0.5 + sceneVisual.twist * 0.5);
|
||||
group.rotation.z = Math.cos(elapsed * 0.18) * 0.055 + pointer.x * pointer.y * 0.055 + sceneVisual.twist * 0.06;
|
||||
blob.scale.setScalar(1 + Math.sin(elapsed * 0.62) * 0.025 + uniforms.uImpact.value * 0.014);
|
||||
shell.rotation.y -= 0.0014;
|
||||
shell.rotation.z += 0.001;
|
||||
particles.material.opacity = 0.42 + sceneVisual.particle * 0.36;
|
||||
particles.rotation.y -= 0.00035 + sceneVisual.flow * 0.00045 + uniforms.uImpact.value * 0.00035;
|
||||
particles.rotation.x = Math.sin(elapsed * (0.14 + sceneVisual.flow * 0.12)) * (0.055 + sceneVisual.twist * 0.06);
|
||||
rings.forEach((ring, index) => {
|
||||
ring.material.opacity = (0.04 + index * 0.012) * (0.75 + sceneVisual.ring * 0.7);
|
||||
ring.rotation.z += 0.0007 + sceneVisual.ring * 0.00075 + index * 0.00035 + uniforms.uImpact.value * 0.00055;
|
||||
ring.rotation.x += Math.sin(elapsed * 0.24 + index) * 0.00048 + pointer.y * 0.00022;
|
||||
});
|
||||
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
const offset = index * 6;
|
||||
const angle = elapsed * (0.28 + sceneVisual.flow * 0.22 + index * 0.025) + index * 0.7 + scroll * sceneVisual.twist * 2.4;
|
||||
const length = 0.42 + sceneVisual.ring * 0.18 + uniforms.uImpact.value * 0.72;
|
||||
rayPositions[offset] = pointer.x * 0.82;
|
||||
rayPositions[offset + 1] = pointer.y * 0.58;
|
||||
rayPositions[offset + 2] = 0.38;
|
||||
rayPositions[offset + 3] = pointer.x * 0.82 + Math.cos(angle) * length;
|
||||
rayPositions[offset + 4] = pointer.y * 0.58 + Math.sin(angle) * length * 0.42;
|
||||
rayPositions[offset + 5] = 0.12 + Math.sin(angle * 0.7) * 0.28;
|
||||
}
|
||||
rayGeometry.attributes.position.needsUpdate = true;
|
||||
rayMaterial.opacity = 0.025 + uniforms.uImpact.value * 0.13;
|
||||
|
||||
renderer.render(scene, camera);
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
animate();
|
||||
}
|
||||
|
||||
renderMetrics();
|
||||
renderFocusAreas();
|
||||
renderResumeSignals();
|
||||
renderProjects();
|
||||
renderSkills();
|
||||
renderExperience();
|
||||
setupTheme();
|
||||
setupReveal();
|
||||
setupTilts();
|
||||
setupFluidScene();
|
||||
updateScrollProgress();
|
||||
restoreHashPosition();
|
||||
window.addEventListener("scroll", () => requestAnimationFrame(updateScrollProgress), { passive: true });
|
||||
window.addEventListener("hashchange", () => {
|
||||
restoreHashPosition();
|
||||
updateScrollProgress();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import * as THREE from "three";
|
||||
import { blobFragment, blobVertex, particleFragment, particleVertex } from "./shaders";
|
||||
|
||||
// 三状态目标值 —— 每帧向目标插值,无硬切
|
||||
const STATES = {
|
||||
idle: { freq: 1.15, amp: 0.24, speed: 0.22, heat: 0.0, scale: 1.0, attract: 0.12 },
|
||||
thinking: { freq: 2.6, amp: 0.27, speed: 0.95, heat: 0.7, scale: 0.86, attract: 1.0 },
|
||||
answering: { freq: 1.9, amp: 0.46, speed: 0.5, heat: 0.22, scale: 1.09, attract: 0.0 },
|
||||
};
|
||||
|
||||
const lerp = (a, b, t) => a + (b - a) * t;
|
||||
|
||||
export function createMind(canvas, { reduced = false } = {}) {
|
||||
const isMobile = window.innerWidth < 880;
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, isMobile ? 1.5 : 2));
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(42, window.innerWidth / window.innerHeight, 0.1, 30);
|
||||
camera.position.z = 6;
|
||||
|
||||
const group = new THREE.Group();
|
||||
scene.add(group);
|
||||
|
||||
/* ---- 流体球 ---- */
|
||||
const blobUniforms = {
|
||||
uTime: { value: 0 },
|
||||
uFreq: { value: STATES.idle.freq },
|
||||
uAmp: { value: STATES.idle.amp },
|
||||
uSpeed: { value: STATES.idle.speed },
|
||||
uHeat: { value: 0 },
|
||||
uOpacity: { value: 1 },
|
||||
uColorA: { value: new THREE.Color("#22d3ee") },
|
||||
uColorB: { value: new THREE.Color("#a78bfa") },
|
||||
};
|
||||
const blob = new THREE.Mesh(
|
||||
new THREE.IcosahedronGeometry(1.35, isMobile ? 48 : 96),
|
||||
new THREE.ShaderMaterial({
|
||||
vertexShader: blobVertex,
|
||||
fragmentShader: blobFragment,
|
||||
uniforms: blobUniforms,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
})
|
||||
);
|
||||
group.add(blob);
|
||||
|
||||
/* ---- 伴生粒子 ---- */
|
||||
const COUNT = isMobile ? 900 : 2000;
|
||||
const seeds = new Float32Array(COUNT * 3);
|
||||
const radii = new Float32Array(COUNT);
|
||||
const speeds = new Float32Array(COUNT);
|
||||
for (let i = 0; i < COUNT; i++) {
|
||||
seeds[i * 3] = Math.random();
|
||||
seeds[i * 3 + 1] = Math.random();
|
||||
seeds[i * 3 + 2] = Math.random();
|
||||
radii[i] = 1.7 + Math.random() * 1.5;
|
||||
speeds[i] = 0.25 + Math.random() * 0.6;
|
||||
}
|
||||
const pGeo = new THREE.BufferGeometry();
|
||||
pGeo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(COUNT * 3), 3));
|
||||
pGeo.setAttribute("aSeed", new THREE.BufferAttribute(seeds, 3));
|
||||
pGeo.setAttribute("aRadius", new THREE.BufferAttribute(radii, 1));
|
||||
pGeo.setAttribute("aSpeed", new THREE.BufferAttribute(speeds, 1));
|
||||
|
||||
const particleUniforms = {
|
||||
uTime: { value: 0 },
|
||||
uAttract: { value: STATES.idle.attract },
|
||||
uBurst: { value: 0 },
|
||||
uDir: { value: -1 },
|
||||
uSize: { value: isMobile ? 9 : 12 },
|
||||
uMouse: { value: new THREE.Vector3(999, 999, 999) },
|
||||
uMouseForce: { value: 0 },
|
||||
uGrab: { value: 0 },
|
||||
uHeat: { value: 0 },
|
||||
uOpacity: { value: 1 },
|
||||
uColorA: blobUniforms.uColorA,
|
||||
uColorB: blobUniforms.uColorB,
|
||||
};
|
||||
const particles = new THREE.Points(
|
||||
pGeo,
|
||||
new THREE.ShaderMaterial({
|
||||
vertexShader: particleVertex,
|
||||
fragmentShader: particleFragment,
|
||||
uniforms: particleUniforms,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
})
|
||||
);
|
||||
group.add(particles);
|
||||
|
||||
/* ---- 状态机 ---- */
|
||||
let current = { ...STATES.idle };
|
||||
let target = STATES.idle;
|
||||
let stateName = "idle";
|
||||
let burst = 0;
|
||||
|
||||
const colorA = blobUniforms.uColorA.value;
|
||||
const colorB = blobUniforms.uColorB.value;
|
||||
const targetColorA = colorA.clone();
|
||||
const targetColorB = colorB.clone();
|
||||
|
||||
// 停靠位置:side -1 内容在右 → 流体去左;1 → 右;0 → 居中偏后
|
||||
const dock = new THREE.Vector3();
|
||||
const dockTarget = new THREE.Vector3();
|
||||
const pointer = { x: 0, y: 0 };
|
||||
|
||||
function dockFor(side) {
|
||||
if (window.innerWidth < 880) {
|
||||
// 移动端:居中上移、退后、缩小,让内容可读
|
||||
return new THREE.Vector3(side * 0.3, 1.25, -1.6);
|
||||
}
|
||||
if (side === 0) return new THREE.Vector3(0, 0.95, -2.8);
|
||||
return new THREE.Vector3(side * 2.05, 0.1, -0.7);
|
||||
}
|
||||
|
||||
let side = 1;
|
||||
let opacityTarget = 1;
|
||||
let hasPointer = false;
|
||||
let mouseSnapped = false;
|
||||
// 按压牵引弹簧(欠阻尼 → 过冲回弹的阻尼感)
|
||||
let grab = 0;
|
||||
let grabVel = 0;
|
||||
let grabTarget = 0;
|
||||
const mouseRay = new THREE.Vector3();
|
||||
const mouseLocal = new THREE.Vector3();
|
||||
|
||||
const api = {
|
||||
setState(name) {
|
||||
if (name === stateName || reduced) return;
|
||||
stateName = name;
|
||||
target = STATES[name];
|
||||
if (name === "answering") burst = 1; // 喷发脉冲,随帧衰减
|
||||
},
|
||||
setSide(s) {
|
||||
side = s;
|
||||
dockTarget.copy(dockFor(s));
|
||||
particleUniforms.uDir.value = s === 0 ? 0 : -s; // 粒子喷向内容区
|
||||
opacityTarget = s === 0 ? 0.42 : 1; // 居中停靠时减淡,保证内容可读
|
||||
},
|
||||
setPalette([a, b]) {
|
||||
targetColorA.set(a);
|
||||
targetColorB.set(b);
|
||||
},
|
||||
get state() {
|
||||
return stateName;
|
||||
},
|
||||
debug() {
|
||||
return { stateName, side, dock: dock.toArray(), dockTarget: dockTarget.toArray(), group: group.position.toArray() };
|
||||
},
|
||||
};
|
||||
|
||||
api.setSide(1);
|
||||
dock.copy(dockTarget);
|
||||
group.position.copy(dock);
|
||||
|
||||
window.addEventListener("pointermove", (e) => {
|
||||
pointer.x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
pointer.y = (e.clientY / window.innerHeight) * 2 - 1;
|
||||
hasPointer = true;
|
||||
});
|
||||
|
||||
window.addEventListener("pointerdown", (e) => {
|
||||
pointer.x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
pointer.y = (e.clientY / window.innerHeight) * 2 - 1;
|
||||
hasPointer = true;
|
||||
grabTarget = 1;
|
||||
});
|
||||
const release = () => { grabTarget = 0; };
|
||||
window.addEventListener("pointerup", release);
|
||||
window.addEventListener("pointercancel", release);
|
||||
window.addEventListener("blur", release);
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
api.setSide(side);
|
||||
});
|
||||
|
||||
/* ---- 帧循环(由外部 ticker 驱动) ---- */
|
||||
const clock = new THREE.Clock();
|
||||
function tick() {
|
||||
const dt = Math.min(clock.getDelta(), 0.05);
|
||||
const t = clock.elapsedTime;
|
||||
const k = 1 - Math.pow(0.0014, dt); // 帧率无关的平滑系数
|
||||
|
||||
current.freq = lerp(current.freq, target.freq, k);
|
||||
current.amp = lerp(current.amp, target.amp, k);
|
||||
current.speed = lerp(current.speed, target.speed, k);
|
||||
current.heat = lerp(current.heat, target.heat, k);
|
||||
current.scale = lerp(current.scale, target.scale, k);
|
||||
current.attract = lerp(current.attract, target.attract, k);
|
||||
burst = lerp(burst, 0, 1 - Math.pow(0.25, dt)); // 脉冲衰减
|
||||
|
||||
blobUniforms.uTime.value = reduced ? t * 0.25 : t;
|
||||
blobUniforms.uFreq.value = current.freq;
|
||||
blobUniforms.uAmp.value = current.amp;
|
||||
blobUniforms.uSpeed.value = current.speed;
|
||||
blobUniforms.uHeat.value = current.heat;
|
||||
|
||||
particleUniforms.uTime.value = reduced ? t * 0.25 : t;
|
||||
particleUniforms.uAttract.value = current.attract;
|
||||
particleUniforms.uBurst.value = burst;
|
||||
particleUniforms.uHeat.value = current.heat;
|
||||
|
||||
colorA.lerp(targetColorA, k);
|
||||
colorB.lerp(targetColorB, k);
|
||||
|
||||
const op = lerp(blobUniforms.uOpacity.value, opacityTarget, k);
|
||||
blobUniforms.uOpacity.value = op;
|
||||
particleUniforms.uOpacity.value = op;
|
||||
|
||||
const breathe = 1 + Math.sin(t * 1.4) * 0.018;
|
||||
group.scale.setScalar(current.scale * breathe);
|
||||
|
||||
dock.lerp(dockTarget, 1 - Math.pow(0.02, dt));
|
||||
group.position.set(
|
||||
dock.x + pointer.x * 0.14,
|
||||
dock.y - pointer.y * 0.1,
|
||||
dock.z
|
||||
);
|
||||
group.rotation.y += dt * 0.12;
|
||||
group.rotation.x = pointer.y * 0.08;
|
||||
|
||||
// 鼠标牵引:把指针投影到流体所在深度平面,转到 group 局部空间后平滑跟随
|
||||
if (hasPointer && !reduced) {
|
||||
mouseRay.set(pointer.x, -pointer.y, 0.5).unproject(camera).sub(camera.position).normalize();
|
||||
const planeDist = (group.position.z - camera.position.z) / mouseRay.z;
|
||||
mouseLocal.copy(camera.position).addScaledVector(mouseRay, planeDist);
|
||||
group.updateMatrixWorld();
|
||||
group.worldToLocal(mouseLocal);
|
||||
if (!mouseSnapped) {
|
||||
particleUniforms.uMouse.value.copy(mouseLocal);
|
||||
mouseSnapped = true;
|
||||
} else {
|
||||
particleUniforms.uMouse.value.lerp(mouseLocal, 1 - Math.pow(0.01, dt));
|
||||
}
|
||||
particleUniforms.uMouseForce.value = lerp(particleUniforms.uMouseForce.value, 0.85, k);
|
||||
|
||||
// 按压牵引:欠阻尼弹簧积分 —— 按下缓冲吸入,松开过冲散开再归位
|
||||
grabVel += (grabTarget - grab) * 26 * dt;
|
||||
grabVel *= Math.exp(-3.2 * dt);
|
||||
grab += grabVel * dt;
|
||||
particleUniforms.uGrab.value = grab;
|
||||
}
|
||||
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
return { ...api, tick };
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// 思维流体 GLSL —— Ashima simplex noise + 3 octave FBM + Fresnel
|
||||
const SIMPLEX = /* glsl */ `
|
||||
vec3 mod289(vec3 x){ return x - floor(x * (1.0/289.0)) * 289.0; }
|
||||
vec4 mod289(vec4 x){ return x - floor(x * (1.0/289.0)) * 289.0; }
|
||||
vec4 permute(vec4 x){ return mod289(((x*34.0)+1.0)*x); }
|
||||
vec4 taylorInvSqrt(vec4 r){ return 1.79284291400159 - 0.85373472095314 * r; }
|
||||
|
||||
float snoise(vec3 v){
|
||||
const vec2 C = vec2(1.0/6.0, 1.0/3.0);
|
||||
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
|
||||
|
||||
vec3 i = floor(v + dot(v, C.yyy));
|
||||
vec3 x0 = v - i + dot(i, C.xxx);
|
||||
|
||||
vec3 g = step(x0.yzx, x0.xyz);
|
||||
vec3 l = 1.0 - g;
|
||||
vec3 i1 = min(g.xyz, l.zxy);
|
||||
vec3 i2 = max(g.xyz, l.zxy);
|
||||
|
||||
vec3 x1 = x0 - i1 + C.xxx;
|
||||
vec3 x2 = x0 - i2 + C.yyy;
|
||||
vec3 x3 = x0 - D.yyy;
|
||||
|
||||
i = mod289(i);
|
||||
vec4 p = permute(permute(permute(
|
||||
i.z + vec4(0.0, i1.z, i2.z, 1.0))
|
||||
+ i.y + vec4(0.0, i1.y, i2.y, 1.0))
|
||||
+ i.x + vec4(0.0, i1.x, i2.x, 1.0));
|
||||
|
||||
float n_ = 0.142857142857;
|
||||
vec3 ns = n_ * D.wyz - D.xzx;
|
||||
|
||||
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
|
||||
|
||||
vec4 x_ = floor(j * ns.z);
|
||||
vec4 y_ = floor(j - 7.0 * x_);
|
||||
|
||||
vec4 x = x_ * ns.x + ns.yyyy;
|
||||
vec4 y = y_ * ns.x + ns.yyyy;
|
||||
vec4 h = 1.0 - abs(x) - abs(y);
|
||||
|
||||
vec4 b0 = vec4(x.xy, y.xy);
|
||||
vec4 b1 = vec4(x.zw, y.zw);
|
||||
|
||||
vec4 s0 = floor(b0) * 2.0 + 1.0;
|
||||
vec4 s1 = floor(b1) * 2.0 + 1.0;
|
||||
vec4 sh = -step(h, vec4(0.0));
|
||||
|
||||
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
|
||||
vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
|
||||
|
||||
vec3 p0 = vec3(a0.xy, h.x);
|
||||
vec3 p1 = vec3(a0.zw, h.y);
|
||||
vec3 p2 = vec3(a1.xy, h.z);
|
||||
vec3 p3 = vec3(a1.zw, h.w);
|
||||
|
||||
vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3)));
|
||||
p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
|
||||
|
||||
vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
|
||||
m = m * m;
|
||||
return 42.0 * dot(m*m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3)));
|
||||
}
|
||||
|
||||
float fbm(vec3 p){
|
||||
float sum = 0.0;
|
||||
float amp = 0.5;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
sum += amp * snoise(p);
|
||||
p *= 2.1;
|
||||
amp *= 0.5;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
`;
|
||||
|
||||
export const blobVertex = /* glsl */ `
|
||||
uniform float uTime;
|
||||
uniform float uFreq;
|
||||
uniform float uAmp;
|
||||
uniform float uSpeed;
|
||||
|
||||
varying float vDisp;
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vViewDir;
|
||||
|
||||
${SIMPLEX}
|
||||
|
||||
void main(){
|
||||
float t = uTime * uSpeed;
|
||||
float d = fbm(normal * uFreq + vec3(t, t * 0.7, -t * 0.4));
|
||||
vDisp = d;
|
||||
vec3 displaced = position + normal * d * uAmp;
|
||||
|
||||
vec4 mvPosition = modelViewMatrix * vec4(displaced, 1.0);
|
||||
vNormal = normalize(normalMatrix * normal);
|
||||
vViewDir = normalize(-mvPosition.xyz);
|
||||
gl_Position = projectionMatrix * mvPosition;
|
||||
}
|
||||
`;
|
||||
|
||||
export const blobFragment = /* glsl */ `
|
||||
uniform vec3 uColorA;
|
||||
uniform vec3 uColorB;
|
||||
uniform float uHeat;
|
||||
uniform float uOpacity;
|
||||
|
||||
varying float vDisp;
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vViewDir;
|
||||
|
||||
void main(){
|
||||
float fresnel = pow(1.0 - max(dot(vNormal, vViewDir), 0.0), 2.4);
|
||||
vec3 base = mix(uColorA, uColorB, vDisp * 0.5 + 0.5);
|
||||
base = mix(base, vec3(1.0, 0.62, 0.38), uHeat * 0.4);
|
||||
vec3 color = base * (0.32 + fresnel * 1.6) + base * smoothstep(0.35, 0.9, vDisp) * 0.45;
|
||||
float alpha = (0.22 + fresnel * 0.85) * uOpacity;
|
||||
gl_FragColor = vec4(color, alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
export const particleVertex = /* glsl */ `
|
||||
uniform float uTime;
|
||||
uniform float uAttract; // 1 = 吸入核心 (thinking)
|
||||
uniform float uBurst; // 1 = 向外喷发 (answering)
|
||||
uniform float uDir; // 喷发的水平方向(指向内容区)
|
||||
uniform float uSize;
|
||||
uniform vec3 uMouse; // 指针在粒子局部空间的位置
|
||||
uniform float uMouseForce;
|
||||
uniform float uGrab; // 按压牵引强度(弹簧驱动,可为负 → 回弹散开)
|
||||
|
||||
attribute vec3 aSeed; // 每粒子随机种子
|
||||
attribute float aRadius;
|
||||
attribute float aSpeed;
|
||||
|
||||
varying float vFade;
|
||||
|
||||
void main(){
|
||||
float t = uTime * aSpeed;
|
||||
// 进动轨道:两组相位叠加,避免规则圆轨
|
||||
float theta = aSeed.x * 6.2831 + t;
|
||||
float phi = aSeed.y * 3.1415 + sin(t * 0.6 + aSeed.z * 6.2831) * 0.7;
|
||||
|
||||
float r = aRadius * mix(1.0, 0.3, uAttract);
|
||||
r += uBurst * (0.8 + aSeed.z * 2.6);
|
||||
|
||||
vec3 pos = vec3(
|
||||
cos(theta) * sin(phi) * r,
|
||||
cos(phi) * r * 0.85,
|
||||
sin(theta) * sin(phi) * r
|
||||
);
|
||||
// 喷发时整体偏向内容区一侧
|
||||
pos.x += uBurst * uDir * (0.6 + aSeed.y * 1.8);
|
||||
|
||||
// 鼠标牵引:靠近指针的粒子被吸过去,绕指针形成小漩涡
|
||||
float md = distance(pos, uMouse);
|
||||
float pull = smoothstep(2.8, 0.2, md) * uMouseForce * (0.35 + aSeed.z * 0.65);
|
||||
vec3 swirl = vec3(
|
||||
sin(t * 2.2 + aSeed.x * 6.2831),
|
||||
cos(t * 1.7 + aSeed.y * 6.2831),
|
||||
sin(t * 1.3 + aSeed.z * 6.2831)
|
||||
) * (0.12 + aSeed.y * 0.3);
|
||||
pos = mix(pos, uMouse + swirl, pull);
|
||||
|
||||
// 按住鼠标:全体粒子被牵向按压点,逐粒错相往复脉动;
|
||||
// uGrab 为负(松开回弹)时 mix 外推 → 粒子向外散开再被拉回
|
||||
float osc = 0.55 + 0.45 * sin(uTime * (1.2 + aSeed.x * 1.6) + aSeed.y * 6.2831);
|
||||
float gpull = clamp(uGrab, -0.35, 1.25) * osc * (0.5 + aSeed.z * 0.5);
|
||||
gpull = clamp(gpull, -0.4, 0.96);
|
||||
vec3 gswirl = vec3(
|
||||
sin(t * 1.8 + aSeed.y * 6.2831),
|
||||
cos(t * 1.4 + aSeed.z * 6.2831),
|
||||
sin(t * 1.1 + aSeed.x * 6.2831)
|
||||
) * (0.18 + aSeed.x * 0.5);
|
||||
pos = mix(pos, uMouse + gswirl, gpull);
|
||||
|
||||
vFade = 1.0 - uBurst * aSeed.z * 0.9;
|
||||
vFade = min(vFade + pull * 0.5 + max(gpull, 0.0) * 0.35, 1.2);
|
||||
|
||||
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
|
||||
gl_PointSize = uSize * (0.6 + aSeed.z) * (1.0 + pull * 0.5 + max(gpull, 0.0) * 0.3) * (3.4 / -mvPosition.z);
|
||||
gl_Position = projectionMatrix * mvPosition;
|
||||
}
|
||||
`;
|
||||
|
||||
export const particleFragment = /* glsl */ `
|
||||
uniform vec3 uColorA;
|
||||
uniform vec3 uColorB;
|
||||
uniform float uHeat;
|
||||
uniform float uOpacity;
|
||||
|
||||
varying float vFade;
|
||||
|
||||
void main(){
|
||||
vec2 uv = gl_PointCoord - 0.5;
|
||||
float d = length(uv);
|
||||
float mask = smoothstep(0.5, 0.05, d);
|
||||
vec3 color = mix(uColorA, uColorB, vFade);
|
||||
color = mix(color, vec3(1.0, 0.62, 0.38), uHeat * 0.35);
|
||||
gl_FragColor = vec4(color, mask * vFade * 0.75 * uOpacity);
|
||||
}
|
||||
`;
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* 将简历数据渲染为对话式作品集页面,负责不同回合的 DOM 结构装配。
|
||||
*/
|
||||
import {
|
||||
experiences,
|
||||
focusAreas,
|
||||
metrics,
|
||||
projects,
|
||||
resumeSignals,
|
||||
skills,
|
||||
} from "./resume-data";
|
||||
import { contact, rounds } from "./dialogue";
|
||||
|
||||
const el = (tag, className, html) => {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (html != null) node.innerHTML = html;
|
||||
return node;
|
||||
};
|
||||
|
||||
const externalLink = (className, href, text, ariaLabel = text) => {
|
||||
const node = el("a", className);
|
||||
node.href = href;
|
||||
node.target = "_blank";
|
||||
node.rel = "noreferrer";
|
||||
node.textContent = text;
|
||||
node.setAttribute("aria-label", ariaLabel);
|
||||
return node;
|
||||
};
|
||||
|
||||
/* ---------- 各轮回答内容 ---------- */
|
||||
|
||||
function bootContent(answer) {
|
||||
answer.append(
|
||||
el(
|
||||
"h1",
|
||||
"boot-greeting",
|
||||
`你好,我是<span class="grad">湛兮(花名)</span>。<br /><span class="typed-line"></span>`,
|
||||
),
|
||||
el(
|
||||
"p",
|
||||
"boot-identity gen",
|
||||
`7 年 Web 与跨端前端 · 最近一年在 <em>AI 产品团队</em> 做对话链路与控制台`,
|
||||
),
|
||||
);
|
||||
const stream = el("div", "metric-stream");
|
||||
metrics.forEach((m) => {
|
||||
const token = el("div", "metric-token gen");
|
||||
token.append(el("span", "v", m.value), el("span", "l", m.label));
|
||||
stream.append(token);
|
||||
});
|
||||
answer.append(stream, el("div", "boot-hint gen", "SCROLL TO ASK"));
|
||||
}
|
||||
|
||||
function signalsContent(answer) {
|
||||
answer.append(el("div", "a-title gen", "Self Introduction"));
|
||||
resumeSignals.forEach((line, i) => {
|
||||
const row = el("div", "signal-line gen");
|
||||
row.append(
|
||||
el("span", "idx", String(i + 1).padStart(2, "0")),
|
||||
el("p", null, line),
|
||||
);
|
||||
answer.append(row);
|
||||
});
|
||||
}
|
||||
|
||||
function focusContent(answer) {
|
||||
answer.append(el("div", "a-title gen", "Last 12 Months"));
|
||||
const grid = el("div", "focus-grid");
|
||||
answer.append(grid);
|
||||
focusAreas.forEach((f) => {
|
||||
const card = el("div", "focus-card gen");
|
||||
card.append(el("h3", null, f.title), el("p", null, f.summary));
|
||||
const tags = el("div", "tag-row");
|
||||
f.points.forEach((p) => tags.append(el("span", "tag", p)));
|
||||
card.append(tags);
|
||||
grid.append(card);
|
||||
});
|
||||
}
|
||||
|
||||
function projectsContent(answer) {
|
||||
answer.append(el("div", "a-title gen", "Selected Proof"));
|
||||
projects.forEach((p) => {
|
||||
const block = el("article", "project-block gen");
|
||||
const head = el("div", "project-head");
|
||||
const meta = el("div", "project-meta");
|
||||
meta.append(
|
||||
p.url
|
||||
? externalLink("name entity-link", p.url, p.name, `打开 ${p.name}`)
|
||||
: el("span", "name", p.name),
|
||||
);
|
||||
if (p.links?.length > 1) {
|
||||
const links = el("div", "project-links");
|
||||
p.links.forEach((link) => {
|
||||
links.append(
|
||||
externalLink("mini-link", link.url, link.label, `打开 ${link.label}`),
|
||||
);
|
||||
});
|
||||
meta.append(links);
|
||||
}
|
||||
head.append(
|
||||
Object.assign(el("img"), { src: p.logo, alt: p.name, loading: "lazy" }),
|
||||
meta,
|
||||
el("span", "period", p.period),
|
||||
);
|
||||
block.append(
|
||||
head,
|
||||
el("div", "project-sub", p.subtitle),
|
||||
el("p", "project-summary", p.summary),
|
||||
);
|
||||
const ul = el("ul", "project-modules");
|
||||
p.modules.forEach((m) => ul.append(el("li", "gen-sub", m)));
|
||||
block.append(ul);
|
||||
const tags = el("div", "tag-row");
|
||||
p.tech.forEach((t) => tags.append(el("span", "tag", t)));
|
||||
block.append(tags);
|
||||
answer.append(block);
|
||||
});
|
||||
}
|
||||
|
||||
function skillsContent(answer) {
|
||||
answer.append(el("div", "a-title gen", "Token Stream / Skills"));
|
||||
const HOT = new Set([
|
||||
"SSE 流式通信",
|
||||
"Next.js 16",
|
||||
"React 19",
|
||||
"React Native",
|
||||
"TypeScript",
|
||||
"NestJS",
|
||||
"MySQL",
|
||||
"Redis",
|
||||
"Jenkins",
|
||||
"Gitea",
|
||||
"BPMN",
|
||||
"Qiankun",
|
||||
]);
|
||||
const grid = el("div", "skills-grid");
|
||||
skills.forEach((group) => {
|
||||
const wrap = el("div", "skill-group gen");
|
||||
const head = el("div", "g-head");
|
||||
head.append(
|
||||
el("div", "g-name", group.group),
|
||||
el("span", "g-count", `${group.items.length} 项`),
|
||||
);
|
||||
wrap.append(head);
|
||||
if (group.summary) wrap.append(el("p", "g-summary", group.summary));
|
||||
const row = el("div", "tag-row");
|
||||
group.items.forEach((item) => {
|
||||
row.append(
|
||||
el("span", `skill-token gen${HOT.has(item) ? " hot" : ""}`, item),
|
||||
);
|
||||
});
|
||||
wrap.append(row);
|
||||
grid.append(wrap);
|
||||
});
|
||||
answer.append(grid);
|
||||
}
|
||||
|
||||
function experienceContent(answer) {
|
||||
answer.append(el("div", "a-title gen", "Career Path"));
|
||||
experiences.forEach((exp) => {
|
||||
const item = el("div", "exp-item gen");
|
||||
const head = el("div", "exp-head");
|
||||
head.append(
|
||||
Object.assign(el("img"), {
|
||||
src: exp.logo,
|
||||
alt: exp.company,
|
||||
loading: "lazy",
|
||||
}),
|
||||
exp.url
|
||||
? externalLink(
|
||||
"co entity-link",
|
||||
exp.url,
|
||||
exp.company,
|
||||
`打开 ${exp.company}`,
|
||||
)
|
||||
: el("span", "co", exp.company),
|
||||
el("span", "period", exp.period),
|
||||
);
|
||||
item.append(head, el("div", "exp-role", exp.role));
|
||||
const ul = el("ul", "exp-points");
|
||||
exp.points.forEach((pt) => ul.append(el("li", null, pt)));
|
||||
item.append(ul);
|
||||
answer.append(item);
|
||||
});
|
||||
}
|
||||
|
||||
function contactContent(answer) {
|
||||
answer.append(
|
||||
el("h2", "contact-lead gen", "随时可以开启下一轮对话。"),
|
||||
el("p", "contact-id gen", contact.identity),
|
||||
);
|
||||
const actions = el("div", "contact-actions gen");
|
||||
const mail = el("a", "contact-btn primary", "发送邮件");
|
||||
mail.href = `mailto:${contact.emails[0]}`;
|
||||
const mail2 = el("a", "contact-btn", contact.emails[1]);
|
||||
mail2.href = `mailto:${contact.emails[1]}`;
|
||||
const gh = el("a", "contact-btn", "GitHub / zhanBoss");
|
||||
gh.href = contact.github;
|
||||
gh.target = "_blank";
|
||||
gh.rel = "noreferrer";
|
||||
const tel = el("a", "contact-btn", contact.phone);
|
||||
tel.href = `tel:${contact.phone}`;
|
||||
actions.append(mail, mail2, gh, tel);
|
||||
answer.append(actions, el("div", "session-end gen", "SESSION SAVED · 2026"));
|
||||
}
|
||||
|
||||
const CONTENT = {
|
||||
boot: bootContent,
|
||||
signals: signalsContent,
|
||||
focus: focusContent,
|
||||
projects: projectsContent,
|
||||
skills: skillsContent,
|
||||
experience: experienceContent,
|
||||
contact: contactContent,
|
||||
};
|
||||
|
||||
/* ---------- 装配 ---------- */
|
||||
|
||||
/**
|
||||
* 根据对话回合配置生成主内容区,保留滚动叙事所需的 section 结构。
|
||||
*/
|
||||
export function renderDialogue(mount) {
|
||||
rounds.forEach((round) => {
|
||||
const section = el("section", "round");
|
||||
section.id = `r-${round.id}`;
|
||||
section.dataset.side = String(round.side);
|
||||
const inner = el("div", "round-inner");
|
||||
|
||||
if (round.question) {
|
||||
const qRow = el("div", "q-row");
|
||||
const bubble = el("div", "q-bubble");
|
||||
bubble.append(el("span", "q-text"), el("span", "q-caret"));
|
||||
qRow.append(bubble);
|
||||
const thinking = el("div", "thinking");
|
||||
const dots = el("span", "dots");
|
||||
dots.append(el("span", "dot"), el("span", "dot"), el("span", "dot"));
|
||||
thinking.append(dots, el("span", null, "THINKING"));
|
||||
inner.append(qRow, thinking);
|
||||
}
|
||||
|
||||
const answer = el("div", "answer");
|
||||
CONTENT[round.type](answer);
|
||||
if (round.type !== "boot") answer.append(el("span", "a-caret", "▋"));
|
||||
inner.append(answer);
|
||||
section.append(inner);
|
||||
mount.append(section);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成右侧回合导航节点,并把点击行为交给滚动编排模块处理。
|
||||
*/
|
||||
export function renderRail(mount, onJump) {
|
||||
rounds.forEach((round) => {
|
||||
const node = el("button", "rail-node");
|
||||
node.type = "button";
|
||||
node.dataset.round = round.id;
|
||||
node.setAttribute("aria-label", `跳转到 ${round.label}`);
|
||||
node.append(el("span", "rail-label", round.label));
|
||||
node.addEventListener("click", () => onJump(round.id));
|
||||
mount.append(node);
|
||||
});
|
||||
}
|
||||
+62
-17
@@ -1,6 +1,10 @@
|
||||
/**
|
||||
* 维护作品集页面的简历指标、能力标签、项目经历和职业路径文案。
|
||||
*/
|
||||
export const metrics = [
|
||||
{ value: "7 年", label: "Web 与跨端前端经验" },
|
||||
{ value: "2 条", label: "近一年参与的产品线" },
|
||||
{ value: "6 个", label: "纳管项目发布闭环" },
|
||||
{ value: "SSE", label: "对话流、思考态、消息重试" },
|
||||
{ value: "12 个", label: "微前端拆分基础项目" },
|
||||
{ value: "30%", label: "消息渲染链路优化结果" },
|
||||
@@ -25,54 +29,89 @@ export const focusAreas = [
|
||||
},
|
||||
{
|
||||
title: "工程习惯",
|
||||
summary: "经历过微前端拆分、跨项目公共包、i18n 协作、监控接入和代码审查,能把模块交付和团队维护放在一起考虑。",
|
||||
points: ["Qiankun", "Monorepo", "i18n", "Code Review"],
|
||||
summary: "经历过微前端拆分、跨项目公共包、i18n 协作、监控接入、发布平台和代码审查,能把模块交付和团队维护放在一起考虑。",
|
||||
points: ["Qiankun", "Monorepo", "DevOps", "Code Review"],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 首页自我介绍回合的关键叙事点,突出近一年方向与个人项目信号。
|
||||
*/
|
||||
export const resumeSignals = [
|
||||
"前端经验覆盖 PC 管理后台、小程序、H5、React Native 和微前端,最近一年主要在 AI 产品团队做业务前端。",
|
||||
"SeaBuzz 侧更偏用户体验:对话、内容流、新闻详情、游客数据、分享和反馈链路。",
|
||||
"SeaCloud / Vtrix 侧更偏控制台:Pricing、Billing、API Keys、组织权限、交易筛选和分销配置。",
|
||||
"技术栈以 React / Next.js / React Native / TypeScript 为主,也有 Vue、UniApp 和 Qiankun 项目经验。",
|
||||
"个人工程实践里搭建了 DevOps 运维平台,把 Gitea、Jenkins、BPMN、通知、审计、Agent 诊断和 Jenkins 直构导入串成发布闭环。",
|
||||
"技术栈以 React / Next.js / React Native / TypeScript 为主,能覆盖 Vue、UniApp、Qiankun、NestJS、MySQL、Redis 和 Jenkins/Gitea 发布链路。",
|
||||
];
|
||||
|
||||
/**
|
||||
* 技能栏按招聘方扫读路径分组,避免基础设施与数据层能力被埋在项目详情里。
|
||||
*/
|
||||
export const skills = [
|
||||
{
|
||||
group: "对话与内容",
|
||||
items: ["SSE 流式通信", "打字机响应", "Markdown/LaTeX", "来源引用", "对话历史", "新闻详情"],
|
||||
group: "前端主栈",
|
||||
summary: "主力交付栈,覆盖 AI 产品、控制台、移动端和多端内容流。",
|
||||
items: ["React 19", "Next.js 16", "React Native", "TypeScript", "Vue 3", "Element Plus", "Tailwind CSS", "Zustand", "Alova"],
|
||||
},
|
||||
{
|
||||
group: "控制台",
|
||||
items: ["Pricing", "Billing", "API Keys", "组织权限", "额度限制", "交易筛选", "Excel 导出"],
|
||||
group: "数据与后端协作",
|
||||
summary: "能理解接口、数据模型和状态一致性,不只停留在页面拼装。",
|
||||
items: ["NestJS", "OpenAPI", "DTO 校验", "Prisma", "MySQL", "Redis", "BullMQ", "幂等键", "乐观锁"],
|
||||
},
|
||||
{
|
||||
group: "跨端体验",
|
||||
items: ["React Native", "Expo Router", "NativeWind", "游客转登录", "只读分享", "Smart Image"],
|
||||
group: "DevOps 与发布",
|
||||
summary: "从个人项目实践中补齐发布闭环、可观测性和运维诊断意识。",
|
||||
items: ["DevOps 平台", "Jenkins", "Jenkins 直构导入", "Gitea", "BPMN", "Outbox", "审计日志", "构建日志脱敏", "Runbook"],
|
||||
},
|
||||
{
|
||||
group: "Web 主栈",
|
||||
items: ["Next.js", "React", "Vue 3", "TypeScript", "Tailwind CSS", "Zustand", "Alova"],
|
||||
group: "AI 对话与内容",
|
||||
summary: "最近一年重点投入的方向,关注流式体验、上下文恢复和内容可读性。",
|
||||
items: ["SSE 流式通信", "打字机响应", "Markdown/LaTeX", "来源引用", "思考态", "对话历史", "新闻详情"],
|
||||
},
|
||||
{
|
||||
group: "小程序与 H5",
|
||||
items: ["UniApp", "微信小程序", "飞书小程序", "H5", "摄像头扫码", "低功耗蓝牙"],
|
||||
group: "控制台业务",
|
||||
summary: "长期处理权限、账务、额度、筛选导出等后台业务边界。",
|
||||
items: ["Pricing", "Billing", "API Keys", "组织权限", "额度限制", "交易筛选", "Excel 导出", "成员权限"],
|
||||
},
|
||||
{
|
||||
group: "工程协作",
|
||||
items: ["pnpm Monorepo", "Git Submodules", "Qiankun", "Lerna", "i18n 同步", "Code Review"],
|
||||
group: "跨端与小程序",
|
||||
summary: "覆盖 App、H5、小程序和门店设备链路,能处理端侧能力差异。",
|
||||
items: ["Expo Router", "NativeWind", "UniApp", "微信小程序", "飞书小程序", "游客转登录", "Smart Image", "摄像头扫码", "低功耗蓝牙"],
|
||||
},
|
||||
{
|
||||
group: "工程化与质量",
|
||||
items: ["ARMS 监控", "CDN 优化", "Playwright", "Git Flow", "分支规范", "新人培训"],
|
||||
group: "工程协作与质量",
|
||||
summary: "偏团队长期维护视角,关注复用、规范、监控和评审质量。",
|
||||
items: ["pnpm Monorepo", "Git Submodules", "Qiankun", "Lerna", "i18n 同步", "ARMS 监控", "Playwright", "Git Flow", "Code Review"],
|
||||
},
|
||||
];
|
||||
|
||||
export const projects = [
|
||||
{
|
||||
id: "devops-platform",
|
||||
name: "DevOps 运维平台",
|
||||
logo: "/logos/devops-platform.svg",
|
||||
period: "2026.06 - 至今",
|
||||
subtitle: "个人项目发布与可观测控制台",
|
||||
summary:
|
||||
"从零搭建一套自用 DevOps 运维平台,把本地与线上项目的发布、Jenkins 构建与直构导入、Gitea refs、BPMN 流程、通知 outbox、审计日志和 Agent 诊断收敛到一个控制台。",
|
||||
modules: [
|
||||
"前端采用 Vue 3、Vite、TypeScript、Element Plus 和 Pinia,提供登录、成员权限、项目诊断、发布中心、运行记录、系统配置和全局 Agent 抽屉。",
|
||||
"后端采用 NestJS、Prisma、MySQL、Redis/BullMQ,接入统一 envelope、DTO 校验、结构化日志、审计记录、幂等键和发布单乐观锁。",
|
||||
"打通 Gitea 分支/tag/commit/PR 摘要、平台触发 Jenkins queue/build 同步、Jenkins 直接构建反向导入、构建日志脱敏读取、取消/重试发布和 BPMN 节点高亮。",
|
||||
"将通知投递改为 outbox 持久化与可重试模型,并把 LLM Agent 限定在发布风险、失败诊断、Runbook、发布说明和事故复盘场景。",
|
||||
],
|
||||
tech: ["Vue 3", "NestJS", "TypeScript", "Prisma", "MySQL", "Redis", "BullMQ", "Jenkins", "Gitea", "BPMN"],
|
||||
},
|
||||
{
|
||||
id: "vtrix",
|
||||
name: "SeaCloud / Vtrix",
|
||||
logo: "/logos/vtrix.png",
|
||||
url: "https://cloud.seaart.ai/",
|
||||
links: [
|
||||
{ label: "SeaCloud", url: "https://cloud.seaart.ai/" },
|
||||
{ label: "Vtrix", url: "https://www.vtrix.ai/" },
|
||||
],
|
||||
period: "2025.09 - 至今",
|
||||
subtitle: "模型服务平台控制台",
|
||||
summary:
|
||||
@@ -89,6 +128,7 @@ export const projects = [
|
||||
id: "seabuzz",
|
||||
name: "SeaBuzz",
|
||||
logo: "/logos/seabuzz.webp",
|
||||
url: "https://play.google.com/store/apps/details?id=app.seahot.ai",
|
||||
period: "2025.05 - 至今",
|
||||
subtitle: "资讯、搜索与对话应用",
|
||||
summary:
|
||||
@@ -106,6 +146,7 @@ export const projects = [
|
||||
id: "bawang",
|
||||
name: "霸王功夫",
|
||||
logo: "/logos/chagee.png",
|
||||
url: "https://bwcj.com/",
|
||||
period: "2024.03 - 2025.03",
|
||||
subtitle: "门店、食安与供应链系统",
|
||||
summary:
|
||||
@@ -122,6 +163,7 @@ export const projects = [
|
||||
id: "jingyingbang",
|
||||
name: "经营帮平台 + 经营帮拉新",
|
||||
logo: "/logos/jingyingbang.png",
|
||||
url: "https://jingyingbang.com/",
|
||||
period: "2022.04 - 2024.03",
|
||||
subtitle: "微前端平台与小程序",
|
||||
summary:
|
||||
@@ -140,6 +182,7 @@ export const experiences = [
|
||||
{
|
||||
company: "成都海艺互娱科技有限公司",
|
||||
logo: "/logos/seaart.webp",
|
||||
url: "https://www.seaart.ai/",
|
||||
role: "React Native 开发工程师",
|
||||
period: "2025.05 - 至今",
|
||||
points: [
|
||||
@@ -151,6 +194,7 @@ export const experiences = [
|
||||
{
|
||||
company: "四川茶姬企业管理有限公司",
|
||||
logo: "/logos/chagee.png",
|
||||
url: "https://bwcj.com/",
|
||||
role: "高级 Web 前端开发",
|
||||
period: "2024.03 - 2025.03",
|
||||
points: [
|
||||
@@ -162,6 +206,7 @@ export const experiences = [
|
||||
{
|
||||
company: "中钧科技有限公司四川分公司",
|
||||
logo: "/logos/zhongjun.png",
|
||||
url: "https://zhongjunkeji.com/",
|
||||
role: "前端开发组长",
|
||||
period: "2022.04 - 2024.03",
|
||||
points: [
|
||||
|
||||
+724
-897
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user