Initial resume site

This commit is contained in:
湛兮
2026-05-22 21:50:16 +08:00
commit daef1657aa
15 changed files with 7188 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
.astro/
.DS_Store
.env
.env.*
!.env.example
+10
View File
@@ -0,0 +1,10 @@
# 王元有 - 前端工程师简历网站
基于 Astro 的个人简历网站,内容整理自 `王元有-前端工程师.pdf`
## Commands
```bash
npm install
npm run dev
```
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from "astro/config";
export default defineConfig({
output: "static",
});
+4750
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "wang-yuanyou-resume-site",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"astro": "^6.3.1"
},
"devDependencies": {}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+316
View File
@@ -0,0 +1,316 @@
export const resume = {
name: "王元有",
alias: "湛兮",
title: "前端开发工程师",
intent: "求职意向:前端开发工程师",
profile: "7 年前端与跨端开发经验,长期负责 Web、App、H5、小程序与管理后台的架构设计、工程化建设和核心业务交付。熟悉 AI 产品工程、SSE 流式通信、Next.js/Vue/React Native 跨端体系、微前端和 Monorepo 协作模式。",
basics: [
{ label: "工作经验", value: "7 年" },
{ label: "技术管理", value: "2+ 年" },
{ label: "项目用户规模", value: "千万级 MAU" },
{ label: "教育背景", value: "电子科技大学 本科" },
],
contact: {
phone: "19980439383",
email: "419021733@qq.com",
emailAlt: "wmagmgema521@gmail.com",
meta: "男 | 29 岁",
},
highlights: [
"主导多款企业级产品从 0 到 1 搭建,覆盖 AI 模型服务、AI 资讯对话、供应链、门店数字化、工业互联网平台等场景。",
"掌握 Vue3、React、Next.js、React Native、Expo、UniApp 等技术栈,能独立完成 Web、移动端、App 与管理后台方案落地。",
"推动 Micro-Frontend、SSR、pnpm Monorepo、Git Flow、代码审查、CI/CD、监控体系等工程化实践。",
"有明确的性能与效率结果:构建体积优化 30%、首屏白屏时间缩短 25%、组件库工程化提升开发效率 50%、重复开发成本减少约 40%。",
],
metrics: [
{ value: "-30%", label: "构建体积优化" },
{ value: "-25%", label: "首屏白屏时间" },
{ value: "+50%", label: "组件库效率提升" },
{ value: "+40%", label: "团队人效提升" },
{ value: "40%", label: "重复开发成本降低" },
{ value: "30%", label: "消息渲染性能提升" },
],
skills: [
{
group: "前端框架",
items: ["Next.js 16", "React 19", "Vue 3", "TypeScript 5", "Tailwind CSS 4", "Nuxt/SSR"],
},
{
group: "跨端开发",
items: ["React Native 0.79", "Expo 53", "UniApp", "H5", "微信/飞书小程序"],
},
{
group: "AI 与通信",
items: ["Vercel AI SDK v6", "SSE 流式通信", "多 Agent 协作", "Markdown/LaTeX 渲染", "AI 对话", "AI 生图/生视频"],
},
{
group: "工程化",
items: ["pnpm Monorepo", "Git Submodules", "Qiankun", "Lerna", "Git Flow", "Playwright"],
},
{
group: "状态与 UI",
items: ["Zustand", "MobX", "Pinia", "Radix UI", "Ant Design", "Element Plus", "ProComponents"],
},
{
group: "基础设施",
items: ["Alova", "next-intl", "i18next", "ARMS 监控", "CDN 优化", "Adyen 支付"],
},
],
experiences: [
{
company: "成都海艺互娱科技有限公司",
role: "React Native 开发工程师",
period: "2025.05 - 至今",
points: [
"参与多款 AI 产品从 0 到 1 搭建与架构设计,覆盖前端性能、交互体验、国际化体系与多端组件复用。",
"在 AI 对话、生图、生视频与多 Agent 协作领域沉淀工程经验,负责流式通信与前端渲染体验优化。",
"支撑千万级海外用户产品,推动多端组件库统一建设,减少重复开发成本约 40%。",
],
},
{
company: "四川茶姬企业管理有限公司",
role: "高级 Web 前端开发",
period: "2024.03 - 2025.03",
points: [
"主导供应链管理系统前端开发,实现采购、仓储、配送等核心模块。",
"独立负责门店报损与食安管理核心功能搭建及迭代,覆盖国内、东南亚与北美业务逻辑。",
"引入阿里云 ARMS 前端监控体系,落地 CDN 静态资源优化方案,并制定代码审查与 Git 操作规范。",
],
},
{
company: "中钧科技有限公司四川分公司",
role: "前端开发组长",
period: "2022.04 - 2024.03",
points: [
"负责经营帮 PC 端微前端、门户和商管核心模块开发,完成 UI 还原、接口联调及性能优化。",
"从零搭建新 E 畅行小程序基础框架,完成技术方案制定与全流程开发。",
"主导经营帮小程序、H5、Admin 后台迭代开发与跨端技术方案设计,建立 Git Flow、代码审查和新人培训机制。",
],
},
],
projects: [
{
id: "vtrix",
name: "SeaCloud / Vtrix",
subtitle: "AI 模型服务平台(Web 端)",
period: "2025.09 - 至今",
tech: ["Next.js 16", "React 19", "TypeScript", "Tailwind CSS", "Zustand", "Alova", "Vercel AI SDK", "Radix UI", "next-intl"],
summary: "面向全球用户的 AI 模型聚合服务平台,聚合 LLM、图像、视频、音频、3D 等多模态模型能力,覆盖 C 端调用与 B 端组织/分销管理。",
modules: [
"分销商客户邀请、折扣模板、额度分配、销售配置与利润率计算。",
"多币种与汇率体系,封装 useCurrency Hook 并贯穿 Pricing、Billing、API Keys 等模块。",
"组织成员、角色权限守卫、配额设置、支出限额、账单报表、交易筛选与 Excel 导出。",
"i18n Submodule 架构迁移、企业微信同步工作流、通用表格/筛选器/分页组件增强。",
],
},
{
id: "seabuzz",
name: "SeaBuzz",
subtitle: "AI 智能资讯与对话平台",
period: "2025.05 - 至今",
tech: ["React Native", "Expo 53", "Expo Router", "Zustand", "NativeWind", "SSE", "i18next", "Adyen", "Lerna"],
summary: "海艺 AI 旗下 AI 新闻聚合、智能搜索与多模态对话平台,基于 Expo 实现 iOS、Android、Web 三端统一开发。",
modules: [
"AI Agent 对话完整链路:SSE 流式聊天、打字机渲染、Markdown/LaTeX、思考动画、来源引用与历史同步。",
"Discover 发现页与新闻详情,支持瀑布流、大/小卡动态布局、骨架屏与 Smart Image。",
"Google、Facebook、Discord、Email 登录与 SeaArt Auth SDK 对接。",
"Monorepo 公共包体系、API 层、数据模型、UI 组件、状态管理、OTA 热更新与代码签名机制。",
],
},
{
id: "bawang",
name: "霸王功夫",
subtitle: "门店数字化与供应链管理平台",
period: "2024.03 - 2025.03",
tech: ["React 18", "Vue 3", "UniApp", "MobX", "Pinia", "Element Plus", "Ant Design", "ProComponents"],
summary: "服务全球 6000+ 门店及运营伙伴的数字化管理平台,覆盖门店运营、食品安全、供应链协同等核心业务。",
modules: [
"小程序报损模块:摄像头扫码、在线报损登记,兼容微信小程序与飞书 H5。",
"食安管理模块:低频蓝牙连接 TSPL 指令集打印机,支持国内、东南亚、北美三套业务逻辑。",
"经营信息模块:多门店经营状态移动端看板,按区域、时间、指标筛选。",
"供应链模块:采购系统、供应商合同管理、供应商结算系统,对接云厉、费控等外部系统。",
],
},
{
id: "jingyingbang",
name: "经营帮平台 + 经营帮拉新",
subtitle: "工业互联网与微前端平台",
period: "2022.04 - 2024.03",
tech: ["Vue", "Qiankun", "Element UI", "华为云 OBS", "百度地图", "高德地图", "IM", "UniApp"],
summary: "基于信息化设计理念和区块链技术的工业互联网平台,为企业和个人提供数字化运营服务。",
modules: [
"参与单体前端到 Qiankun 微前端拆分,拆分出 12 个基础项目。",
"负责商管、门户、经营帮系列小程序/H5/Admin 后台核心业务交付。",
"引入华为云 OBS 直传减轻请求链路性能浪费。",
"开发内部 Chrome 插件 zjkj-decryption,提升数据解析效率。",
],
},
],
education: {
school: "电子科技大学",
degree: "本科",
major: "信息管理与信息系统",
period: "2023 - 2025",
},
};
export const resumeEn = {
name: "Wang Yuanyou",
alias: "mrZhan",
title: "Frontend Engineer",
intent: "Target Role: Frontend Engineer",
profile:
"Frontend and cross-platform engineer with 7 years of experience building Web, App, H5, mini-program and admin systems. Strong in AI product engineering, SSE streaming, Next.js/Vue/React Native ecosystems, micro-frontends and Monorepo collaboration.",
basics: [
{ label: "Experience", value: "7 years" },
{ label: "Tech Leadership", value: "2+ years" },
{ label: "Product Scale", value: "10M+ MAU" },
{ label: "Education", value: "UESTC Bachelor" },
],
contact: {
phone: "19980439383",
email: "419021733@qq.com",
emailAlt: "wmagmgema521@gmail.com",
meta: "Male | 29",
},
highlights: [
"Led multiple enterprise products from 0 to 1 across AI model services, AI news and chat, supply chain, store digitization and industrial internet platforms.",
"Hands-on with Vue3, React, Next.js, React Native, Expo and UniApp, capable of delivering Web, mobile, App and admin products end to end.",
"Drove engineering practices including Micro-Frontend, SSR, pnpm Monorepo, Git Flow, code review, CI/CD and frontend observability.",
"Delivered measurable outcomes: 30% smaller bundles, 25% faster first screen, 50% faster component-driven delivery and around 40% less duplicated work.",
],
metrics: [
{ value: "-30%", label: "Bundle Size" },
{ value: "-25%", label: "First Screen Blank Time" },
{ value: "+50%", label: "Component Delivery Efficiency" },
{ value: "+40%", label: "Team Productivity" },
{ value: "40%", label: "Duplicated Work Reduced" },
{ value: "30%", label: "Message Rendering Performance" },
],
skills: [
{
group: "Frontend Frameworks",
items: ["Next.js 16", "React 19", "Vue 3", "TypeScript 5", "Tailwind CSS 4", "Nuxt/SSR"],
},
{
group: "Cross-platform",
items: ["React Native 0.79", "Expo 53", "UniApp", "H5", "WeChat/Lark Mini Programs"],
},
{
group: "AI & Streaming",
items: ["Vercel AI SDK v6", "SSE Streaming", "Multi-Agent Collaboration", "Markdown/LaTeX", "AI Chat", "AI Image/Video"],
},
{
group: "Engineering",
items: ["pnpm Monorepo", "Git Submodules", "Qiankun", "Lerna", "Git Flow", "Playwright"],
},
{
group: "State & UI",
items: ["Zustand", "MobX", "Pinia", "Radix UI", "Ant Design", "Element Plus", "ProComponents"],
},
{
group: "Infrastructure",
items: ["Alova", "next-intl", "i18next", "ARMS Monitoring", "CDN Optimization", "Adyen Payments"],
},
],
experiences: [
{
company: "Chengdu Haiyi Interactive Entertainment Technology Co., Ltd.",
role: "React Native Engineer",
period: "2025.05 - Present",
points: [
"Contributed to architecture and 0-to-1 delivery of multiple AI products, covering performance, UX, i18n and multi-platform component reuse.",
"Built engineering experience in AI chat, image/video generation and multi-agent collaboration, with focus on streaming and frontend rendering performance.",
"Supported products serving 10M+ overseas users and promoted unified multi-platform component libraries, reducing duplicate work by around 40%.",
],
},
{
company: "Sichuan Chaji Enterprise Management Co., Ltd.",
role: "Senior Web Frontend Developer",
period: "2024.03 - 2025.03",
points: [
"Led frontend development of supply-chain management systems, including procurement, warehousing and delivery modules.",
"Owned store loss-reporting and food-safety features across China, Southeast Asia and North America business rules.",
"Introduced Alibaba Cloud ARMS frontend monitoring, delivered CDN optimization and established code review and Git workflow standards.",
],
},
{
company: "Zhongjun Technology Sichuan Branch",
role: "Frontend Team Lead",
period: "2022.04 - 2024.03",
points: [
"Delivered core modules for the Jingyingbang PC micro-frontend platform, portal and business management systems, including UI implementation, API integration and performance optimization.",
"Built the New E Travel mini-program foundation from scratch and owned technical planning through delivery.",
"Led mini-program, H5 and admin iterations, designed cross-platform solutions, and established Git Flow, code review and onboarding practices.",
],
},
],
projects: [
{
id: "vtrix",
name: "SeaCloud / Vtrix",
subtitle: "AI Model Service Platform (Web)",
period: "2025.09 - Present",
tech: ["Next.js 16", "React 19", "TypeScript", "Tailwind CSS", "Zustand", "Alova", "Vercel AI SDK", "Radix UI", "next-intl"],
summary:
"A global AI model aggregation platform covering LLM, image, video, audio and 3D model capabilities for developers, organizations and distributors.",
modules: [
"Built distributor invitation, discount templates, credit allocation, sales configuration and profit margin workflows.",
"Implemented global currency switching and exchange-rate conversion via a reusable useCurrency hook across Pricing, Billing and API Keys.",
"Delivered organization roles, quota controls, spending limits, billing reports, transaction filtering and Excel export flows.",
"Supported i18n submodule migration, WeCom translation sync and shared table/filter/pagination component enhancements.",
],
},
{
id: "seabuzz",
name: "SeaBuzz",
subtitle: "AI News and Conversation Platform",
period: "2025.05 - Present",
tech: ["React Native", "Expo 53", "Expo Router", "Zustand", "NativeWind", "SSE", "i18next", "Adyen", "Lerna"],
summary:
"An AI-powered news aggregation, smart search and multimodal conversation platform under SeaArt AI, built with Expo for iOS, Android and Web.",
modules: [
"Built the AI Agent chat flow with SSE streaming, typewriter rendering, Markdown/LaTeX, thinking animation, citations and history sync.",
"Delivered Discover feed and news detail pages with masonry layout, large/small dynamic cards, skeleton loading and Smart Image.",
"Integrated Google, Facebook, Discord and Email login with SeaArt Auth SDK.",
"Built Monorepo shared packages for API, data models, UI components, state, OTA updates and code signing.",
],
},
{
id: "bawang",
name: "Bawang Kungfu",
subtitle: "Store Digitization and Supply Chain Platform",
period: "2024.03 - 2025.03",
tech: ["React 18", "Vue 3", "UniApp", "MobX", "Pinia", "Element Plus", "Ant Design", "ProComponents"],
summary:
"A digital operations platform serving 6,000+ stores and partners, covering store operations, food safety and supply-chain collaboration.",
modules: [
"Built mini-program loss reporting with camera scanning and online registration, compatible with WeChat mini-program and Lark H5.",
"Implemented food safety flows with Bluetooth TSPL printers and separate China, Southeast Asia and North America business rules.",
"Delivered mobile dashboards for multi-store operation metrics with region, time and KPI filtering.",
"Built procurement, supplier contract and settlement modules, integrating multiple external systems.",
],
},
{
id: "jingyingbang",
name: "Jingyingbang Platform",
subtitle: "Industrial Internet and Micro-frontend Platform",
period: "2022.04 - 2024.03",
tech: ["Vue", "Qiankun", "Element UI", "Huawei Cloud OBS", "Baidu Map", "Amap", "IM", "UniApp"],
summary:
"An industrial internet platform based on informatization and blockchain concepts, providing digital operation services for companies and individuals.",
modules: [
"Participated in splitting a large frontend monolith into 12 Qiankun-based micro-frontend projects.",
"Delivered core business features for portals, business management, mini-program, H5 and admin systems.",
"Introduced Huawei Cloud OBS direct upload to reduce request-chain overhead.",
"Built an internal Chrome extension, zjkj-decryption, to improve data parsing efficiency.",
],
},
],
education: {
school: "University of Electronic Science and Technology of China",
degree: "Bachelor",
major: "Information Management and Information Systems",
period: "2023 - 2025",
},
};
+964
View File
@@ -0,0 +1,964 @@
---
import { resume, resumeEn } from "../data/resume";
import "../styles/global.css";
const githubUrl = "https://github.com/zhanBoss";
const translations = { zh: resume, en: resumeEn };
const navIds = ["top", "proof", "skills", "projects", "experience", "contact"];
const brandAssets = {
projects: {
vtrix: { src: "/logos/vtrix.png", className: "brand-logo", label: "Vtrix" },
seabuzz: { src: "/logos/seabuzz.webp", className: "brand-logo", label: "SeaBuzz" },
bawang: { src: "/logos/chagee.png", className: "brand-logo", label: "CHAGEE" },
jingyingbang: { src: "/logos/jingyingbang.png", className: "brand-logo brand-logo-wide", label: "经营帮" },
},
companies: {
seaart: { src: "/logos/seaart.webp", className: "brand-logo", label: "SeaArt" },
chagee: { src: "/logos/chagee.png", className: "brand-logo", label: "CHAGEE" },
zhongjun: { src: "/logos/zhongjun.png", className: "brand-logo brand-logo-wide", label: "中钧科技" },
},
};
const displayNameFor = (data: typeof resume, lang: "zh" | "en" = "zh") =>
lang === "zh" ? `${data.name}${data.alias}` : `${data.name} (${data.alias})`;
const labels = {
zh: {
nav: {
top: "首页",
proof: "证据",
skills: "能力",
projects: "项目",
experience: "经历",
contact: "联系",
},
metaDescription: `${displayNameFor(resume)} - ${resume.title}7 年前端与跨端开发经验,聚焦 AI 产品、Next.js、React Native、Vue、微前端与工程化。`,
topbarContact: "联系",
language: "EN",
languageLabel: "Switch to English",
themeLabel: "切换主题",
themeLight: "浅色",
themeDark: "深色",
heroEyebrow: "Frontend Engineer · AI Product Systems · Cross-platform",
heroTitle: "把复杂前端系统做成可交付的产品。",
viewProjects: "查看项目案例",
profileAria: "候选人摘要",
mapAria: "项目能力地图",
mapMain: "AI 前端架构",
mapMeta: "SSE · Agent · 多端",
mapNodes: ["Next.js / React", "Expo / RN", "Vue / Qiankun", "i18n / Billing"],
proofKicker: "Proof",
proofTitle: "用指标说明影响力。",
candidate: "Candidate",
phone: "Phone",
email: "Email",
education: "Education",
strengthsKicker: "Strengths",
strengthsTitle: "适合负责高复杂度前端业务闭环。",
skillsKicker: "Capabilities",
skillsTitle: "不是技术罗列,而是围绕交付场景组织能力。",
projectsKicker: "Selected Work",
projectsTitle: "把项目经历改成可扫描的案例索引。",
filterAll: "全部",
experienceKicker: "Experience",
experienceTitle: "工作经历按职责演进呈现。",
domainsKicker: "Featured Domains",
domainsTitle: "最近项目重点。",
contactKicker: "Contact",
contactTitle: "可沟通前端开发工程师岗位。",
sendEmail: "发送邮件",
footer: "Built with Astro.",
},
en: {
nav: {
top: "Home",
proof: "Proof",
skills: "Skills",
projects: "Projects",
experience: "Experience",
contact: "Contact",
},
metaDescription: `${displayNameFor(resumeEn, "en")} - ${resumeEn.title}. 7 years of frontend and cross-platform experience across AI products, Next.js, React Native, Vue, micro-frontends and engineering systems.`,
topbarContact: "Contact",
language: "中",
languageLabel: "切换到中文",
themeLabel: "Toggle theme",
themeLight: "Light",
themeDark: "Dark",
heroEyebrow: "Frontend Engineer · AI Product Systems · Cross-platform",
heroTitle: "I turn complex frontend systems into shippable products.",
viewProjects: "View case studies",
profileAria: "Candidate summary",
mapAria: "Project capability map",
mapMain: "AI Frontend Architecture",
mapMeta: "SSE · Agent · Multi-platform",
mapNodes: ["Next.js / React", "Expo / RN", "Vue / Qiankun", "i18n / Billing"],
proofKicker: "Proof",
proofTitle: "Impact backed by measurable outcomes.",
candidate: "Candidate",
phone: "Phone",
email: "Email",
education: "Education",
strengthsKicker: "Strengths",
strengthsTitle: "Built for high-complexity frontend ownership.",
skillsKicker: "Capabilities",
skillsTitle: "Capabilities organized around delivery scenarios.",
projectsKicker: "Selected Work",
projectsTitle: "Project experience shaped into scannable case studies.",
filterAll: "All",
experienceKicker: "Experience",
experienceTitle: "Responsibilities evolved from core delivery to team leadership.",
domainsKicker: "Featured Domains",
domainsTitle: "Recent product focus.",
contactKicker: "Contact",
contactTitle: "Open to frontend engineering opportunities.",
sendEmail: "Send email",
footer: "Built with Astro.",
},
};
const leadProjects = resume.projects.slice(0, 3);
const companyLogoFor = (company: string) => {
if (company.includes("海艺") || company.includes("Haiyi")) return brandAssets.companies.seaart;
if (company.includes("茶姬") || company.includes("Chaji")) return brandAssets.companies.chagee;
if (company.includes("中钧") || company.includes("Zhongjun")) return brandAssets.companies.zhongjun;
return null;
};
const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null;
---
<!doctype html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={labels.zh.metaDescription} />
<title>{displayNameFor(resume)} - {resume.title}</title>
<script is:inline>
(() => {
const saved = localStorage.getItem("resume-theme");
const system = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
document.documentElement.dataset.theme = saved || system;
})();
</script>
</head>
<body>
<div class="scroll-progress" id="scroll-progress"></div>
<canvas class="ambient-canvas" id="ambient-canvas" aria-hidden="true"></canvas>
<header class="topbar">
<a class="brand" href="#top" aria-label={`${displayNameFor(resume)} 简历首页`}>
<span>WY</span>
<strong id="brand-name">{displayNameFor(resume)}</strong>
</a>
<nav class="nav" aria-label="页面导航">
{navIds.map((id) => <a href={`#${id}`} data-nav-id={id}>{labels.zh.nav[id]}</a>)}
</nav>
<div class="topbar-actions">
<a class="github-link" href={githubUrl} target="_blank" rel="noreferrer">
<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>
<span>GitHub</span>
</a>
<button class="control-button" id="lang-toggle" type="button">{labels.zh.language}</button>
<button class="control-button" id="theme-toggle" type="button" aria-label={labels.zh.themeLabel}>
<span id="theme-icon">◐</span>
</button>
<a class="topbar-cta" id="topbar-contact" href={`mailto:${resume.contact.email}`}>{labels.zh.topbarContact}</a>
</div>
</header>
<main id="top">
<section class="hero">
<div class="hero-copy reveal">
<p class="eyebrow" id="hero-eyebrow">{labels.zh.heroEyebrow}</p>
<h1 id="hero-title">{labels.zh.heroTitle}</h1>
<p class="lead" id="hero-lead">{resume.profile}</p>
<div class="hero-actions">
<a class="button button-primary" id="projects-button" href="#projects">{labels.zh.viewProjects}</a>
<a class="button" id="email-button" href={`mailto:${resume.contact.email}`}>{resume.contact.email}</a>
<a class="button" id="email-button-alt" href={`mailto:${resume.contact.emailAlt}`}>{resume.contact.emailAlt}</a>
</div>
</div>
<aside class="hero-panel reveal" id="hero-panel" aria-label={labels.zh.profileAria}>
<div class="profile-card">
<div class="avatar" id="avatar">王</div>
<div>
<p class="profile-name" id="profile-name">{displayNameFor(resume)}</p>
<p id="profile-meta">{resume.title} · {resume.contact.meta}</p>
</div>
</div>
<div class="signal-grid" id="signal-grid">
{resume.basics.map((item) => (
<div>
<span>{item.label}</span>
<strong>{item.value}</strong>
</div>
))}
</div>
<div class="system-map" id="system-map" aria-label={labels.zh.mapAria}>
<canvas class="map-canvas" id="map-canvas" aria-hidden="true"></canvas>
<div class="map-node map-node-main">
<strong id="map-main">{labels.zh.mapMain}</strong>
<span id="map-meta">{labels.zh.mapMeta}</span>
</div>
{labels.zh.mapNodes.map((node, index) => <div class="map-node" data-map-node={index}>{node}</div>)}
</div>
</aside>
</section>
<section id="proof" class="proof-section reveal">
<div class="section-title">
<p id="proof-kicker">{labels.zh.proofKicker}</p>
<h2 id="proof-title">{labels.zh.proofTitle}</h2>
</div>
<div class="proof-grid" id="proof-grid">
{resume.metrics.map((metric) => (
<article>
<strong>{metric.value}</strong>
<span>{metric.label}</span>
</article>
))}
</div>
</section>
<div class="page-grid">
<aside class="resume-rail reveal">
<p class="rail-label" id="rail-label">{labels.zh.candidate}</p>
<h2 id="rail-name">{displayNameFor(resume)}</h2>
<p id="rail-intent">{resume.intent}</p>
<dl>
<div>
<dt id="phone-label">{labels.zh.phone}</dt>
<dd><a id="rail-phone" href={`tel:${resume.contact.phone}`}>{resume.contact.phone}</a></dd>
</div>
<div>
<dt id="email-label">{labels.zh.email}</dt>
<dd class="email-list" id="rail-emails">
<a id="rail-email" href={`mailto:${resume.contact.email}`}>{resume.contact.email}</a>
<a id="rail-email-alt" href={`mailto:${resume.contact.emailAlt}`}>{resume.contact.emailAlt}</a>
</dd>
</div>
<div>
<dt id="education-label">{labels.zh.education}</dt>
<dd id="rail-education">{resume.education.school} · {resume.education.degree}</dd>
</div>
</dl>
</aside>
<div class="content-flow">
<section class="section reveal">
<div class="section-title">
<p id="strengths-kicker">{labels.zh.strengthsKicker}</p>
<h2 id="strengths-title">{labels.zh.strengthsTitle}</h2>
</div>
<div class="strength-grid" id="strength-grid">
{resume.highlights.map((item, index) => (
<article>
<span>{String(index + 1).padStart(2, "0")}</span>
<p>{item}</p>
</article>
))}
</div>
</section>
<section id="skills" class="section reveal">
<div class="section-title">
<p id="skills-kicker">{labels.zh.skillsKicker}</p>
<h2 id="skills-title">{labels.zh.skillsTitle}</h2>
</div>
<div class="skill-list" id="skill-list">
{resume.skills.map((skill) => (
<article>
<h3>{skill.group}</h3>
<div>
{skill.items.map((item) => <span>{item}</span>)}
</div>
</article>
))}
</div>
</section>
<section id="projects" class="section reveal">
<div class="section-title with-action">
<div>
<p id="projects-kicker">{labels.zh.projectsKicker}</p>
<h2 id="projects-title">{labels.zh.projectsTitle}</h2>
</div>
<div class="filter-tabs" id="filter-tabs" aria-label="项目筛选">
<button type="button" class="active" data-filter="all">{labels.zh.filterAll}</button>
{resume.projects.map((project) => (
<button type="button" data-filter={project.id}>{project.name}</button>
))}
</div>
</div>
<div class="project-stack" id="project-stack">
{resume.projects.map((project, index) => (
<article class="project-row" data-project={project.id}>
<div class="project-index">
{projectLogoFor(project.id) && (
<span class={projectLogoFor(project.id)?.className} title={projectLogoFor(project.id)?.label} aria-hidden="true">
<img src={projectLogoFor(project.id)?.src} alt="" loading="lazy" decoding="async" />
</span>
)}
<span>{String(index + 1).padStart(2, "0")}</span>
</div>
<div class="project-main">
<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((module) => <li>{module}</li>)}
</ul>
<div class="tech-line">
{project.tech.map((tech) => <span>{tech}</span>)}
</div>
</div>
</article>
))}
</div>
</section>
<section id="experience" class="section reveal">
<div class="section-title">
<p id="experience-kicker">{labels.zh.experienceKicker}</p>
<h2 id="experience-title">{labels.zh.experienceTitle}</h2>
</div>
<div class="experience-list" id="experience-list">
{resume.experiences.map((experience) => (
<article>
<div class="experience-brand">
{companyLogoFor(experience.company) && (
<span class={companyLogoFor(experience.company)?.className} title={companyLogoFor(experience.company)?.label} aria-hidden="true">
<img src={companyLogoFor(experience.company)?.src} alt="" loading="lazy" decoding="async" />
</span>
)}
<time>{experience.period}</time>
</div>
<div>
<h3>{experience.company}</h3>
<p>{experience.role}</p>
<ul>
{experience.points.map((point) => <li>{point}</li>)}
</ul>
</div>
</article>
))}
</div>
</section>
<section class="section reveal">
<div class="section-title">
<p id="domains-kicker">{labels.zh.domainsKicker}</p>
<h2 id="domains-title">{labels.zh.domainsTitle}</h2>
</div>
<div class="domain-strip" id="domain-strip">
{leadProjects.map((project) => (
<article>
{projectLogoFor(project.id) && (
<span class={projectLogoFor(project.id)?.className} title={projectLogoFor(project.id)?.label} aria-hidden="true">
<img src={projectLogoFor(project.id)?.src} alt="" loading="lazy" decoding="async" />
</span>
)}
<strong>{project.name}</strong>
<em>{project.subtitle}</em>
</article>
))}
</div>
</section>
<section id="contact" class="contact-section reveal">
<div>
<p id="contact-kicker">{labels.zh.contactKicker}</p>
<h2 id="contact-title">{labels.zh.contactTitle}</h2>
<span id="contact-education">{resume.education.school} · {resume.education.major} · {resume.education.period}</span>
</div>
<div class="contact-actions">
<a class="button button-primary" id="send-email" href={`mailto:${resume.contact.email}`}>{labels.zh.sendEmail}</a>
<a class="button" id="contact-email-alt" href={`mailto:${resume.contact.emailAlt}`}>{resume.contact.emailAlt}</a>
<a class="button" id="contact-phone" href={`tel:${resume.contact.phone}`}>{resume.contact.phone}</a>
</div>
</section>
</div>
</div>
</main>
<footer class="footer" id="footer-text">© 2026 {displayNameFor(resume)}. {labels.zh.footer}</footer>
<script type="application/json" id="resume-payload" set:html={JSON.stringify({ translations, labels, brandAssets })}></script>
<script is:inline>
const payload = JSON.parse(document.getElementById("resume-payload").textContent);
const progress = document.getElementById("scroll-progress");
const langToggle = document.getElementById("lang-toggle");
const themeToggle = document.getElementById("theme-toggle");
const themeIcon = document.getElementById("theme-icon");
const navLinks = [...document.querySelectorAll(".nav a")];
const sections = navLinks
.map((link) => document.querySelector(link.getAttribute("href")))
.filter(Boolean);
const brandAssets = payload.brandAssets;
let activeLang = localStorage.getItem("resume-lang") || "zh";
let activeFilter = "all";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
const $ = (id) => document.getElementById(id);
const esc = (value) =>
String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
function setText(id, value) {
const node = $(id);
if (node) node.textContent = value;
}
function displayName(data, lang) {
return lang === "zh" ? `${data.name}${data.alias}` : `${data.name} (${data.alias})`;
}
function companyLogo(experience) {
const company = experience.company || "";
if (company.includes("海艺") || company.includes("Haiyi")) return brandAssets.companies.seaart;
if (company.includes("茶姬") || company.includes("Chaji")) return brandAssets.companies.chagee;
if (company.includes("中钧") || company.includes("Zhongjun")) return brandAssets.companies.zhongjun;
return null;
}
function projectLogo(project) {
return brandAssets.projects[project.id] || null;
}
function renderLogo(asset) {
if (!asset) return "";
return `<span class="${esc(asset.className)}" title="${esc(asset.label)}" aria-hidden="true"><img src="${esc(asset.src)}" alt="" loading="lazy" decoding="async" /></span>`;
}
function renderBasics(data) {
$("signal-grid").innerHTML = data.basics
.map((item) => `<div><span>${esc(item.label)}</span><strong>${esc(item.value)}</strong></div>`)
.join("");
}
function renderMetrics(data) {
$("proof-grid").innerHTML = data.metrics
.map((metric) => `<article><strong>${esc(metric.value)}</strong><span>${esc(metric.label)}</span></article>`)
.join("");
}
function renderStrengths(data) {
$("strength-grid").innerHTML = data.highlights
.map(
(item, index) =>
`<article><span>${String(index + 1).padStart(2, "0")}</span><p>${esc(item)}</p></article>`,
)
.join("");
}
function renderSkills(data) {
$("skill-list").innerHTML = data.skills
.map(
(skill) =>
`<article><h3>${esc(skill.group)}</h3><div>${skill.items
.map((item) => `<span>${esc(item)}</span>`)
.join("")}</div></article>`,
)
.join("");
}
function renderFilters(data, text) {
$("filter-tabs").innerHTML = [
`<button type="button" data-filter="all">${esc(text.filterAll)}</button>`,
...data.projects.map(
(project) => `<button type="button" data-filter="${esc(project.id)}">${esc(project.name)}</button>`,
),
].join("");
if (activeFilter !== "all" && !data.projects.some((project) => project.id === activeFilter)) {
activeFilter = "all";
}
bindFilters();
}
function renderProjects(data) {
$("project-stack").innerHTML = data.projects
.map(
(project, index) => `
<article class="project-row" data-project="${esc(project.id)}" ${activeFilter !== "all" && activeFilter !== project.id ? "hidden" : ""}>
<div class="project-index">
${renderLogo(projectLogo(project))}
<span>${String(index + 1).padStart(2, "0")}</span>
</div>
<div class="project-main">
<div class="project-head">
<div>
<h3>${esc(project.name)}</h3>
<p>${esc(project.subtitle)}</p>
</div>
<time>${esc(project.period)}</time>
</div>
<p class="project-summary">${esc(project.summary)}</p>
<ul>${project.modules.map((module) => `<li>${esc(module)}</li>`).join("")}</ul>
<div class="tech-line">${project.tech.map((tech) => `<span>${esc(tech)}</span>`).join("")}</div>
</div>
</article>
`,
)
.join("");
}
function renderExperience(data) {
$("experience-list").innerHTML = data.experiences
.map(
(experience) => `
<article>
<div class="experience-brand">
${renderLogo(companyLogo(experience))}
<time>${esc(experience.period)}</time>
</div>
<div>
<h3>${esc(experience.company)}</h3>
<p>${esc(experience.role)}</p>
<ul>${experience.points.map((point) => `<li>${esc(point)}</li>`).join("")}</ul>
</div>
</article>
`,
)
.join("");
}
function renderDomains(data) {
$("domain-strip").innerHTML = data.projects
.slice(0, 3)
.map(
(project) =>
`<article>${renderLogo(projectLogo(project))}<strong>${esc(project.name)}</strong><em>${esc(project.subtitle)}</em></article>`,
)
.join("");
}
function bindFilters() {
const buttons = [...document.querySelectorAll(".filter-tabs button")];
buttons.forEach((button) => {
button.classList.toggle("active", button.dataset.filter === activeFilter);
button.addEventListener("click", () => {
activeFilter = button.dataset.filter;
buttons.forEach((item) => item.classList.toggle("active", item === button));
document.querySelectorAll(".project-row").forEach((project) => {
project.hidden = activeFilter !== "all" && project.dataset.project !== activeFilter;
});
});
});
}
function applyLanguage(lang) {
activeLang = lang;
localStorage.setItem("resume-lang", lang);
const data = payload.translations[lang];
const text = payload.labels[lang];
const name = displayName(data, lang);
document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
document.title = `${name} - ${data.title}`;
document.querySelector('meta[name="description"]').setAttribute("content", text.metaDescription);
navLinks.forEach((link) => {
link.textContent = text.nav[link.dataset.navId];
});
$("hero-panel").setAttribute("aria-label", text.profileAria);
$("system-map").setAttribute("aria-label", text.mapAria);
langToggle.textContent = text.language;
langToggle.setAttribute("aria-label", text.languageLabel);
themeToggle.setAttribute("aria-label", text.themeLabel);
setText("brand-name", name);
setText("topbar-contact", text.topbarContact);
setText("hero-eyebrow", text.heroEyebrow);
setText("hero-title", text.heroTitle);
setText("hero-lead", data.profile);
setText("projects-button", text.viewProjects);
setText("email-button", data.contact.email);
$("email-button").href = `mailto:${data.contact.email}`;
setText("email-button-alt", data.contact.emailAlt);
$("email-button-alt").href = `mailto:${data.contact.emailAlt}`;
setText("avatar", lang === "zh" ? data.name[0] : "WY");
setText("profile-name", name);
setText("profile-meta", `${data.title} · ${data.contact.meta}`);
setText("map-main", text.mapMain);
setText("map-meta", text.mapMeta);
document.querySelectorAll("[data-map-node]").forEach((node) => {
node.textContent = text.mapNodes[Number(node.dataset.mapNode)];
});
setText("proof-kicker", text.proofKicker);
setText("proof-title", text.proofTitle);
setText("rail-label", text.candidate);
setText("rail-name", name);
setText("rail-intent", data.intent);
setText("phone-label", text.phone);
setText("email-label", text.email);
setText("education-label", text.education);
setText("rail-phone", data.contact.phone);
$("rail-phone").href = `tel:${data.contact.phone}`;
setText("rail-email", data.contact.email);
$("rail-email").href = `mailto:${data.contact.email}`;
setText("rail-email-alt", data.contact.emailAlt);
$("rail-email-alt").href = `mailto:${data.contact.emailAlt}`;
setText("rail-education", `${data.education.school} · ${data.education.degree}`);
setText("strengths-kicker", text.strengthsKicker);
setText("strengths-title", text.strengthsTitle);
setText("skills-kicker", text.skillsKicker);
setText("skills-title", text.skillsTitle);
setText("projects-kicker", text.projectsKicker);
setText("projects-title", text.projectsTitle);
setText("experience-kicker", text.experienceKicker);
setText("experience-title", text.experienceTitle);
setText("domains-kicker", text.domainsKicker);
setText("domains-title", text.domainsTitle);
setText("contact-kicker", text.contactKicker);
setText("contact-title", text.contactTitle);
setText("contact-education", `${data.education.school} · ${data.education.major} · ${data.education.period}`);
setText("send-email", text.sendEmail);
$("send-email").href = `mailto:${data.contact.email}`;
setText("contact-email-alt", data.contact.emailAlt);
$("contact-email-alt").href = `mailto:${data.contact.emailAlt}`;
setText("contact-phone", data.contact.phone);
$("contact-phone").href = `tel:${data.contact.phone}`;
setText("footer-text", `© 2026 ${name}. ${text.footer}`);
renderBasics(data);
renderMetrics(data);
renderStrengths(data);
renderSkills(data);
renderFilters(data, text);
renderProjects(data);
renderExperience(data);
renderDomains(data);
updateProgress();
}
function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
localStorage.setItem("resume-theme", theme);
const text = payload.labels[activeLang];
themeIcon.textContent = theme === "dark" ? "☾" : "☼";
themeToggle.title = theme === "dark" ? text.themeDark : text.themeLight;
}
function updateProgress() {
const max = document.documentElement.scrollHeight - window.innerHeight;
const percent = max > 0 ? Math.min(100, (window.scrollY / max) * 100) : 0;
progress.style.transform = `scaleX(${percent / 100})`;
let current = sections[0];
const atBottom = window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 6;
for (const section of sections) {
if (section.getBoundingClientRect().top <= 140) current = section;
}
if (atBottom) current = sections[sections.length - 1];
navLinks.forEach((link) => {
link.classList.toggle("active", link.getAttribute("href") === `#${current?.id}`);
});
}
function setupAmbientCanvas() {
const canvas = $("ambient-canvas");
const ctx = canvas?.getContext("2d", { alpha: true });
if (!canvas || !ctx || reduceMotion.matches) {
canvas?.setAttribute("hidden", "");
return;
}
const pointer = {
active: false,
x: window.innerWidth * 0.52,
y: window.innerHeight * 0.42,
tx: window.innerWidth * 0.52,
ty: window.innerHeight * 0.42,
speed: 0,
};
let width = 0;
let height = 0;
let dpr = 1;
let particles = [];
function palette() {
const isDark = document.documentElement.dataset.theme === "dark";
return isDark
? {
dot: "rgba(139, 180, 255, 0.55)",
dotAlt: "rgba(99, 215, 155, 0.5)",
line: "rgba(244, 239, 230, 0.12)",
cursor: "rgba(240, 179, 90, 0.34)",
}
: {
dot: "rgba(47, 95, 190, 0.5)",
dotAlt: "rgba(22, 133, 94, 0.45)",
line: "rgba(23, 23, 23, 0.11)",
cursor: "rgba(167, 101, 22, 0.28)",
};
}
function seedParticles() {
const count = Math.min(86, Math.max(38, Math.floor((width * height) / 17000)));
particles = Array.from({ length: count }, (_, index) => ({
x: Math.random() * width,
y: Math.random() * height,
vx: (Math.random() - 0.5) * 0.24,
vy: (Math.random() - 0.5) * 0.24,
size: 0.8 + Math.random() * 1.8,
hue: index % 3,
}));
}
function resize() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
width = window.innerWidth;
height = window.innerHeight;
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
seedParticles();
}
function onPointerMove(event) {
const dx = event.clientX - pointer.tx;
const dy = event.clientY - pointer.ty;
pointer.tx = event.clientX;
pointer.ty = event.clientY;
pointer.speed = Math.min(1, Math.hypot(dx, dy) / 80);
pointer.active = true;
}
function draw() {
const colors = palette();
ctx.clearRect(0, 0, width, height);
pointer.x += (pointer.tx - pointer.x) * 0.08;
pointer.y += (pointer.ty - pointer.y) * 0.08;
pointer.speed *= 0.92;
particles.forEach((particle, index) => {
const dx = particle.x - pointer.x;
const dy = particle.y - pointer.y;
const dist = Math.hypot(dx, dy);
if (pointer.active && dist < 150) {
const force = (1 - dist / 150) * (0.55 + pointer.speed);
particle.vx += (dx / (dist || 1)) * force * 0.018;
particle.vy += (dy / (dist || 1)) * force * 0.018;
}
particle.vx *= 0.988;
particle.vy *= 0.988;
particle.x += particle.vx;
particle.y += particle.vy;
if (particle.x < -20) particle.x = width + 20;
if (particle.x > width + 20) particle.x = -20;
if (particle.y < -20) particle.y = height + 20;
if (particle.y > height + 20) particle.y = -20;
for (let next = index + 1; next < particles.length; next += 1) {
const other = particles[next];
const lineDistance = Math.hypot(particle.x - other.x, particle.y - other.y);
if (lineDistance < 112) {
ctx.globalAlpha = (1 - lineDistance / 112) * 0.46;
ctx.strokeStyle = colors.line;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(particle.x, particle.y);
ctx.lineTo(other.x, other.y);
ctx.stroke();
}
}
ctx.globalAlpha = 0.58;
ctx.fillStyle = particle.hue === 1 ? colors.dotAlt : colors.dot;
ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
ctx.fill();
});
if (pointer.active) {
ctx.globalAlpha = 0.45;
ctx.strokeStyle = colors.cursor;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(pointer.x, pointer.y, 26 + pointer.speed * 18, 0, Math.PI * 2);
ctx.stroke();
}
ctx.globalAlpha = 1;
requestAnimationFrame(draw);
}
window.addEventListener("resize", resize);
window.addEventListener("pointermove", onPointerMove, { passive: true });
window.addEventListener("pointerdown", onPointerMove, { passive: true });
document.addEventListener("pointerleave", () => {
pointer.active = false;
});
resize();
draw();
}
function setupMapCanvas() {
const map = $("system-map");
const canvas = $("map-canvas");
const ctx = canvas?.getContext("2d", { alpha: true });
if (!map || !canvas || !ctx || reduceMotion.matches) {
canvas?.setAttribute("hidden", "");
return;
}
const pointer = { active: false, x: 0, y: 0 };
const travelers = Array.from({ length: 9 }, (_, index) => ({
edge: index % 4,
progress: Math.random(),
speed: 0.002 + Math.random() * 0.0025,
}));
let width = 0;
let height = 0;
let dpr = 1;
function palette() {
const isDark = document.documentElement.dataset.theme === "dark";
return isDark
? {
line: "rgba(244, 239, 230, 0.22)",
active: "rgba(139, 180, 255, 0.55)",
pulse: "rgba(99, 215, 155, 0.86)",
}
: {
line: "rgba(23, 23, 23, 0.18)",
active: "rgba(47, 95, 190, 0.52)",
pulse: "rgba(22, 133, 94, 0.78)",
};
}
function resize() {
const rect = map.getBoundingClientRect();
dpr = Math.min(window.devicePixelRatio || 1, 2);
width = rect.width;
height = rect.height;
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function centers() {
const mapRect = map.getBoundingClientRect();
return [...map.querySelectorAll(".map-node")].map((node) => {
const rect = node.getBoundingClientRect();
return {
main: node.classList.contains("map-node-main"),
x: rect.left - mapRect.left + rect.width / 2,
y: rect.top - mapRect.top + rect.height / 2,
};
});
}
function pointOnEdge(start, end, progress) {
return {
x: start.x + (end.x - start.x) * progress,
y: start.y + (end.y - start.y) * progress,
};
}
function draw() {
const colors = palette();
const nodes = centers();
const main = nodes.find((node) => node.main) || nodes[0];
const targets = nodes.filter((node) => node !== main);
ctx.clearRect(0, 0, width, height);
targets.forEach((target) => {
const distanceToPointer = pointer.active ? Math.hypot(pointer.x - target.x, pointer.y - target.y) : 999;
ctx.strokeStyle = distanceToPointer < 135 ? colors.active : colors.line;
ctx.lineWidth = distanceToPointer < 135 ? 1.6 : 1;
ctx.beginPath();
ctx.moveTo(main.x, main.y);
ctx.lineTo(target.x, target.y);
ctx.stroke();
});
travelers.forEach((traveler) => {
if (!targets.length) return;
const target = targets[traveler.edge % targets.length];
traveler.progress += traveler.speed;
if (traveler.progress > 1) {
traveler.progress = 0;
traveler.edge = (traveler.edge + 1) % targets.length;
}
const point = pointOnEdge(main, target, traveler.progress);
ctx.fillStyle = colors.pulse;
ctx.beginPath();
ctx.arc(point.x, point.y, 2.2, 0, Math.PI * 2);
ctx.fill();
});
if (pointer.active) {
targets.forEach((target) => {
const distance = Math.hypot(pointer.x - target.x, pointer.y - target.y);
if (distance < 170) {
ctx.globalAlpha = 1 - distance / 170;
ctx.strokeStyle = colors.active;
ctx.beginPath();
ctx.moveTo(pointer.x, pointer.y);
ctx.lineTo(target.x, target.y);
ctx.stroke();
}
});
ctx.globalAlpha = 1;
}
requestAnimationFrame(draw);
}
map.addEventListener("pointermove", (event) => {
const rect = map.getBoundingClientRect();
pointer.x = event.clientX - rect.left;
pointer.y = event.clientY - rect.top;
pointer.active = true;
});
map.addEventListener("pointerleave", () => {
pointer.active = false;
});
window.addEventListener("resize", resize);
if ("ResizeObserver" in window) new ResizeObserver(resize).observe(map);
resize();
draw();
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) entry.target.classList.add("is-visible");
});
},
{ threshold: 0.12 },
);
document.querySelectorAll(".reveal").forEach((node) => observer.observe(node));
langToggle.addEventListener("click", () => applyLanguage(activeLang === "zh" ? "en" : "zh"));
themeToggle.addEventListener("click", () => {
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark");
});
window.addEventListener("scroll", updateProgress, { passive: true });
applyLanguage(activeLang);
applyTheme(document.documentElement.dataset.theme || "light");
updateProgress();
setupAmbientCanvas();
setupMapCanvas();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "astro/tsconfigs/strict"
}