From 2244082fd3d0c54eaa62d10d3f71d0f96940029d Mon Sep 17 00:00:00 2001 From: YunaAyase Date: Tue, 14 Jul 2026 19:40:49 +0800 Subject: [PATCH 1/4] feat: Enhance FreeWindow with closing and restoring animations, add new windows for authentication and timeline feed, and improve API request handling with AbortSignal --- README.md | 2 +- docs/roadmap/ReduceMemUse.md | 48 ++++ web/README.md | 2 +- web/next.config.ts | 11 +- web/package.json | 10 +- web/pnpm-lock.yaml | 100 ++------ web/pnpm-workspace.yaml | 4 + web/src/app/not-found.tsx | 56 +++++ web/src/components/layout/MainLayout.tsx | 53 +++- web/src/components/layout/Sidebar.tsx | 6 +- web/src/components/layout/WindowHeader.tsx | 8 + web/src/components/post/PostItem.tsx | 6 +- web/src/components/post/TimelineFeed.tsx | 26 +- web/src/components/termity/Termity.tsx | 249 +++++++++++++++++-- web/src/components/ui/Icon.tsx | 17 +- web/src/components/ui/SplashScreen.tsx | 2 +- web/src/components/welcome/Welcome.tsx | 211 +++++++++------- web/src/components/windows/FreeWindow.tsx | 116 +++++++-- web/src/components/windows/WindowManager.tsx | 22 ++ web/src/lib/api.ts | 2 + web/src/messages/en-US.json | 19 +- web/src/messages/ja-JP.json | 19 +- web/src/messages/zh-CN.json | 19 +- web/src/messages/zh-TW.json | 19 +- web/src/stores/freeWindow.ts | 4 + web/src/stores/music.ts | 16 ++ web/src/stores/soundManager.ts | 28 ++- web/src/stores/theme.ts | 19 +- web/src/stores/user.ts | 6 +- web/src/types/windows.ts | 6 +- 30 files changed, 843 insertions(+), 263 deletions(-) create mode 100644 docs/roadmap/ReduceMemUse.md create mode 100644 web/src/app/not-found.tsx diff --git a/README.md b/README.md index 4819840..882336f 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ cd web pnpm install pnpm dev ``` -Visit `http://localhost:3000` to enter the Cyan Universe! +Visit `http://localhost:2000` to enter the Cyan Universe! ## Project Structure diff --git a/docs/roadmap/ReduceMemUse.md b/docs/roadmap/ReduceMemUse.md new file mode 100644 index 0000000..9b70049 --- /dev/null +++ b/docs/roadmap/ReduceMemUse.md @@ -0,0 +1,48 @@ +# 前端内存优化计划 + +## 一、构建期内存优化 (Build-time) + +| # | 措施 | 说明 | 风险 | +|---|------|------|------| +| 1 | `experimental.webpackMemoryOptimizations: true` | Next.js 15+ 内置,降低构建峰值内存,代价是编译时间略增 | 低 | +| 2 | `experimental.preloadEntriesOnStart: false` | 禁用启动时预加载所有页面 JS 模块,改为按需加载 | 低 | +| 3 | 关闭生产 Source Maps | `productionBrowserSourceMaps: false` + `enablePrerenderSourceMaps: false`,减少构建期内存分配 | 低 | +| 4 | `pnpm analyze` 审计依赖 | 用 Bundle Analyzer 找出未使用/过重的包(如 `echarts` 全量引入、`@dnd-kit` 未使用部分) | 无 | + +## 二、运行期内存优化 (Runtime) + +| # | 措施 | 说明 | 风险 | +|---|------|------|------| +| 5 | 路由级代码分割 | 当前 `page.tsx` 已用 `next/dynamic`,但 `MainLayout` 内的子组件(`ContextMenu`、`NetworkStatus`、`SplashScreen`)未懒加载 — 改为 `dynamic` 导入 | 低 | +| 6 | Zustand store 瘦身 | `useMusicStore` 持久化整个 playlist(含 ArrayBuffer 音频数据),应 `partialize` 排除大数据字段;`useFreeWindowStore` 的 `currentPost`/`currentUser` 在关闭窗口后不清空 — 添加 `close()` 时重置 | 中 | +| 7 | React.memo 防级联渲染 | `PostItem`、`Icon`、`Sidebar` 等高频渲染组件用 `React.memo` 包裹,避免父组件 state 变化导致整棵树重绘 | 低 | +| 8 | 事件监听器清理 | `Welcome.tsx` 的 `mousemove`、`MainLayout.tsx` 的 `mousedown` 已有 cleanup,但需检查 `system.ts` 的 `setInterval` 心跳在组件卸载后是否仍在运行 — 应在 `MainLayout` 的 `useEffect` return 中调用 `stopHeartbeat` | 中 | +| 9 | AbortController 取消请求 | `fetchHostInfoWithTimeout` 已有 AbortController,但 `fetchMe`、`refreshAccessToken` 等未使用 — 统一添加信号取消 | 中 | + +## 三、Bundle 体积瘦身 + +| # | 措施 | 说明 | 风险 | +|---|------|------|------| +| 10 | ECharts 按需引入 | 当前 `import * as echarts from "echarts"` 全量引入约 800KB,改为 `echarts/core` + 按需注册组件 | 中 | +| 11 | `@fluentui/react-icons` tree-shaking | 当前 100+ 图标全量注册到 `iconMap`,每个图标独立导入会增加 module 数量 — 评估是否改用 `@iconify/react` 按需加载 | 中 | +| 12 | 移除未使用依赖 | `package.json` 中 `@dnd-kit/core`、`@dnd-kit/sortable`、`@dnd-kit/utilities`、`lucide-react`、`@iconify/react` 可能未实际使用 — `pnpm analyze` 后清理 | 低 | + +## 四、监控与持续优化 + +| # | 措施 | 说明 | +|---|------|------| +| 13 | 添加 `bundlesize` 配置 | 在 `package.json` 设置 JS/CSS 大小预算(如 JS < 300KB, CSS < 50KB) | +| 14 | 生产构建内存监控 | `next build --experimental-debug-memory-usage` 记录构建期 heap 使用 | +| 15 | Chrome DevTools heap snapshot | 定期在生产环境用 `--inspect` 抓取 heap profile,定位内存泄漏 | + +## 五、执行优先级 + +``` +Phase 1 (低风险立即做): #1, #2, #3, #5, #12 +Phase 2 (需测试验证): #6, #7, #8, #9 +Phase 3 (需重构): #4, #10, #11 +Phase 4 (持续): #13, #14, #15 +``` + +--- +*Created by CyaniAgent - 2026-07-06* diff --git a/web/README.md b/web/README.md index 85281c0..dac73ab 100644 --- a/web/README.md +++ b/web/README.md @@ -6,7 +6,7 @@ ```bash pnpm install -pnpm dev # http://localhost:3000 +pnpm dev # http://localhost:2000 ``` ## Commands diff --git a/web/next.config.ts b/web/next.config.ts index fb7ddbb..614c912 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -5,12 +5,14 @@ const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === "true", }); +const isDev = process.env.NODE_ENV !== "production"; + const securityHeaders = [ { key: "Content-Security-Policy", value: [ "default-src 'self'", - "script-src 'self' 'unsafe-inline'", + `script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""}`, "style-src 'self' 'unsafe-inline'", "font-src 'self' data:", "img-src 'self' data: blob: https:", @@ -47,6 +49,13 @@ const securityHeaders = [ ]; const nextConfig: NextConfig = { + experimental: { + webpackMemoryOptimizations: true, + preloadEntriesOnStart: false, + }, + + productionBrowserSourceMaps: false, + async rewrites() { return [ { diff --git a/web/package.json b/web/package.json index af47d8b..5075115 100644 --- a/web/package.json +++ b/web/package.json @@ -3,9 +3,9 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --turbopack", + "dev": "next dev --turbopack --port 2000 --hostname ::", "build": "next build", - "start": "next start", + "start": "next start --port 2000 --hostname ::", "lint": "next lint --fix", "lint:check": "next lint", "typecheck": "tsc --noEmit", @@ -16,11 +16,7 @@ "test:e2e:ui": "playwright test --ui" }, "dependencies": { - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", "@fluentui/react-icons": "^2.0.316", - "@iconify/react": "^6.0.2", "@tanstack/react-query": "^5.101.2", "clsx": "^2.1.1", "date-fns": "^4.4.0", @@ -28,7 +24,6 @@ "echarts-for-react": "^3.0.6", "framer-motion": "^12.42.2", "lrc-kit": "^1.2.1", - "lucide-react": "^1.22.0", "mfm-js": "^0.26.0", "music-metadata": "^11.13.0", "next": "16.2.9", @@ -37,6 +32,7 @@ "react-dom": "19.2.4", "react-resizable-panels": "^4.12.0", "react-rnd": "^10.5.3", + "react-window": "^2.2.7", "recharts": "^3.9.1", "tailwind-merge": "^3.6.0", "ua-parser-js": "^2.0.10", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 7c87833..a971838 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,21 +8,9 @@ importers: .: dependencies: - '@dnd-kit/core': - specifier: ^6.3.1 - version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@dnd-kit/sortable': - specifier: ^10.0.0 - version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) - '@dnd-kit/utilities': - specifier: ^3.2.2 - version: 3.2.2(react@19.2.4) '@fluentui/react-icons': specifier: ^2.0.316 version: 2.0.316(react@19.2.4) - '@iconify/react': - specifier: ^6.0.2 - version: 6.0.2(react@19.2.4) '@tanstack/react-query': specifier: ^5.101.2 version: 5.101.2(react@19.2.4) @@ -44,9 +32,6 @@ importers: lrc-kit: specifier: ^1.2.1 version: 1.2.1 - lucide-react: - specifier: ^1.22.0 - version: 1.22.0(react@19.2.4) mfm-js: specifier: ^0.26.0 version: 0.26.0 @@ -71,6 +56,9 @@ importers: react-rnd: specifier: ^10.5.3 version: 10.5.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react-window: + specifier: ^2.2.7 + version: 2.2.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) recharts: specifier: ^3.9.1 version: 3.9.1(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1) @@ -210,28 +198,6 @@ packages: resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} - '@dnd-kit/accessibility@3.1.1': - resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} - peerDependencies: - react: '>=16.8.0' - - '@dnd-kit/core@6.3.1': - resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@dnd-kit/sortable@10.0.0': - resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} - peerDependencies: - '@dnd-kit/core': ^6.3.0 - react: '>=16.8.0' - - '@dnd-kit/utilities@3.2.2': - resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} - peerDependencies: - react: '>=16.8.0' - '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -281,14 +247,6 @@ packages: '@griffel/style-types@1.4.2': resolution: {integrity: sha512-MsSghfpyxR2MpTrYdcCozISsSLkmFjNw94wNPi4bDBRLW8W43718W/ZjmUdVkoM0KXMtJPYuEkx8Mzibqb03qA==} - '@iconify/react@6.0.2': - resolution: {integrity: sha512-SMmC2sactfpJD427WJEDN6PMyznTFMhByK9yLW0gOTtnjzzbsi/Ke/XqsumsavFPwNiXs8jSiYeZTmLCLwO+Fg==} - peerDependencies: - react: '>=16' - - '@iconify/types@2.0.0': - resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} - '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -1459,11 +1417,6 @@ packages: resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} engines: {node: 20 || >=22} - lucide-react@1.22.0: - resolution: {integrity: sha512-c9o3l0PiNcgOQDW4F31BEYHudE7kgxVt3o30qMl36ZPwTxXlGB4QnLilhERvVM4uh/pl5MDyY1/gzZSYcHDtBg==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -1652,6 +1605,12 @@ packages: react: '>=16.3.0' react-dom: '>=16.3.0' + react-window@2.2.7: + resolution: {integrity: sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -2071,31 +2030,6 @@ snapshots: '@discoveryjs/json-ext@0.5.7': {} - '@dnd-kit/accessibility@3.1.1(react@19.2.4)': - dependencies: - react: 19.2.4 - tslib: 2.8.1 - - '@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@dnd-kit/accessibility': 3.1.1(react@19.2.4) - '@dnd-kit/utilities': 3.2.2(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - tslib: 2.8.1 - - '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': - dependencies: - '@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@dnd-kit/utilities': 3.2.2(react@19.2.4) - react: 19.2.4 - tslib: 2.8.1 - - '@dnd-kit/utilities@3.2.2(react@19.2.4)': - dependencies: - react: 19.2.4 - tslib: 2.8.1 - '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -2153,13 +2087,6 @@ snapshots: dependencies: csstype: 3.2.3 - '@iconify/react@6.0.2(react@19.2.4)': - dependencies: - '@iconify/types': 2.0.0 - react: 19.2.4 - - '@iconify/types@2.0.0': {} - '@img/colour@1.1.0': optional: true @@ -3045,10 +2972,6 @@ snapshots: lru-cache@11.5.1: {} - lucide-react@1.22.0(react@19.2.4): - dependencies: - react: 19.2.4 - lz-string@1.5.0: {} magic-string@0.30.21: @@ -3235,6 +3158,11 @@ snapshots: react-draggable: 4.7.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tslib: 2.6.2 + react-window@2.2.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react@19.2.4: {} recharts@3.9.1(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1): diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml index 581a9d5..15f9cfd 100644 --- a/web/pnpm-workspace.yaml +++ b/web/pnpm-workspace.yaml @@ -1,3 +1,7 @@ +allowBuilds: + '@parcel/watcher': true + '@swc/core': true + sharp: true ignoredBuiltDependencies: - sharp - unrs-resolver diff --git a/web/src/app/not-found.tsx b/web/src/app/not-found.tsx new file mode 100644 index 0000000..3bd98b7 --- /dev/null +++ b/web/src/app/not-found.tsx @@ -0,0 +1,56 @@ +"use client"; + +import dynamic from "next/dynamic"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useI18n } from "@/components/providers/I18nProvider"; + +const MainLayout = dynamic( + () => import("@/components/layout/MainLayout").then((m) => m.MainLayout), + { ssr: false } +); + +export default function NotFound() { + const { t } = useI18n(); + const router = useRouter(); + + return ( + +
+
+ 404 +
+

+ 404 +

+

+ {t("notFound.message")} +

+
+ + + {t("notFound.goHome")} + +
+
+
+
+
+ ); +} diff --git a/web/src/components/layout/MainLayout.tsx b/web/src/components/layout/MainLayout.tsx index 1d72239..23ea2b9 100644 --- a/web/src/components/layout/MainLayout.tsx +++ b/web/src/components/layout/MainLayout.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import Link from "next/link"; import Image from "next/image"; import { usePathname, useRouter } from "next/navigation"; +import dynamic from "next/dynamic"; import { useSystemStore } from "@/stores/system"; import { useUserStore } from "@/stores/user"; import { useSplitViewStore } from "@/stores/splitView"; @@ -12,14 +13,26 @@ import { useMusicStore } from "@/stores/music"; import { Sidebar } from "./Sidebar"; import { MoreMenuPopover } from "./MoreMenuPopover"; import { SplitView } from "./SplitView"; -import { SplashScreen } from "@/components/ui/SplashScreen"; -import { ContextMenu } from "@/components/ui/ContextMenu"; -import { NetworkStatus } from "@/components/shared/NetworkStatus"; import { MobileNav } from "./MobileNav"; import { Icon } from "@/components/ui/Icon"; import { useI18n } from "@/components/providers/I18nProvider"; -export function MainLayout({ children }: { children: React.ReactNode }) { +const SplashScreen = dynamic( + () => import("@/components/ui/SplashScreen").then((m) => m.SplashScreen), + { ssr: false } +); + +const ContextMenu = dynamic( + () => import("@/components/ui/ContextMenu").then((m) => m.ContextMenu), + { ssr: false } +); + +const NetworkStatus = dynamic( + () => import("@/components/shared/NetworkStatus").then((m) => m.NetworkStatus), + { ssr: false } +); + +export function MainLayout({ children, notFound }: { children: React.ReactNode; notFound?: boolean }) { const { t } = useI18n(); const pathname = usePathname(); const router = useRouter(); @@ -98,6 +111,11 @@ export function MainLayout({ children }: { children: React.ReactNode }) { const aboutTabs: { label: string; icon: string; to: string }[] = []; + const notFoundTabs = [ + { label: t("notFound.back"), icon: "arrow_back", to: "__back__" }, + { label: t("tabs.timeline"), icon: "public", to: "/" }, + ]; + useEffect(() => { systemStore.initSequence(); }, []); @@ -114,6 +132,7 @@ export function MainLayout({ children }: { children: React.ReactNode }) { }, [moreMenuOpen]); const currentTabs = (() => { + if (notFound) return notFoundTabs; if (pathname.startsWith("/chat")) return chatTabs; if (pathname.startsWith("/settings")) return settingsTabs; if (pathname.startsWith("/drive")) return driveTabs; @@ -170,15 +189,31 @@ export function MainLayout({ children }: { children: React.ReactNode }) {