Initial resume site
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
.DS_Store
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -0,0 +1,10 @@
|
||||
# 王元有 - 前端工程师简历网站
|
||||
|
||||
基于 Astro 的个人简历网站,内容整理自 `王元有-前端工程师.pdf`。
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from "astro/config";
|
||||
|
||||
export default defineConfig({
|
||||
output: "static",
|
||||
});
|
||||
Generated
+4750
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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",
|
||||
},
|
||||
};
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
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
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict"
|
||||
}
|
||||
Reference in New Issue
Block a user