From 3f5babf7b6de29f5e57829f7a154a42e77073e77 Mon Sep 17 00:00:00 2001 From: lijiapeng365 Date: Fri, 8 Aug 2025 19:13:22 +0800 Subject: [PATCH 01/66] feat(about): implement Phase 1 of dynamic component editor architecture - Add dynamic component type definitions in lib/types/about-page-components.ts - Implement data migration utilities in lib/utils/data-migration.ts - Create migration scripts for converting legacy to dynamic structure - Support backward compatibility with existing about page data - Add comprehensive TypeScript interfaces for component system --- docs/DEVELOPER_TOOLCHAIN.md | 321 +++++++ docs/about-page-editor-integration-plan.md | 926 +++++++++++++++++++++ lib/scripts/migrate-about-data.ts | 309 +++++++ lib/scripts/test-migration.ts | 104 +++ lib/types/about-page-components.ts | 215 +++++ lib/utils/data-migration.ts | 322 +++++++ package.json | 3 +- 7 files changed, 2199 insertions(+), 1 deletion(-) create mode 100644 docs/DEVELOPER_TOOLCHAIN.md create mode 100644 docs/about-page-editor-integration-plan.md create mode 100644 lib/scripts/migrate-about-data.ts create mode 100644 lib/scripts/test-migration.ts create mode 100644 lib/types/about-page-components.ts create mode 100644 lib/utils/data-migration.ts diff --git a/docs/DEVELOPER_TOOLCHAIN.md b/docs/DEVELOPER_TOOLCHAIN.md new file mode 100644 index 00000000..648bcb4e --- /dev/null +++ b/docs/DEVELOPER_TOOLCHAIN.md @@ -0,0 +1,321 @@ +好的,这是该开发工具链指南的中文翻译。 + +这份文档详细说明了 AgentifUI 项目的开发工具、自动化流程和质量保证体系,旨在帮助贡献者高效地编写出高质量、风格一致且易于维护的代码。 + +--- + +## 📋 AgentifUI 开发者工具链指南 (中文翻译) + +### 概述 + +本指南全面介绍了 AgentifUI 的开发工具链和自动化质量保证体系。作为一名贡献者,您将通过这些工具来确保代码的质量、一致性和可维护性。 + +--- + +### 🔧 核心开发工具 + +#### TypeScript 配置 + +**配置文件**: `tsconfig.json` + +**核心特性**: + +- ✅ **严格模式**: 通过严格的编译器设置实现全面的类型安全。 +- ✅ **路径映射**: 使用 `@/*`, `@lib/*`, `@components/*` 等别名实现清晰的模块导入。 +- ✅ **Next.js 集成**: 为 Next.js 15 的 App Router 进行了优化。 +- ✅ **Jest 支持**: 集成了测试环境所需的类型定义。 + +**您会用到的命令**: + +```bash +pnpm type-check # 运行 TypeScript 编译器进行类型检查,但不生成 JS 文件 +``` + +#### ESLint 配置 + +**配置文件**: `eslint.config.mjs` + +**双重 Linting 策略**: + +1. **Oxlint** (主要 - 速度极快) + - 基于 Rust 的 linter,性能卓越。 + - 运行时间仅需毫秒级,而非秒级。 + - 快速捕捉常见问题。 + +2. **ESLint** (次要 - 功能全面) + - 传统的 JavaScript/TypeScript 代码检查工具。 + - 包含 Next.js 特定规则。 + - 与 TypeScript 深度集成。 + +**您会用到的命令**: + +```bash +pnpm lint # 同时运行 Oxlint 和 ESLint (推荐) +pnpm lint:fast # 仅运行 Oxlint (用于快速检查) +pnpm lint:eslint # 仅运行 ESLint +pnpm lint:errors # 只显示错误,不显示警告 +pnpm lint:complexity # 检查代码复杂度 (最大值为 15) +pnpm fix:eslint # 自动修复 ESLint 发现的问题 +``` + +#### Prettier 代码格式化 + +**配置文件**: `.prettierrc.json` + +**特性**: + +- 🎨 **自动导入排序**: 按照 React → Next.js → 外部库 → 内部模块的顺序排序。 +- 🎨 **Tailwind Class 排序**: 自动对 Tailwind CSS 的类名进行排序。 +- 🎨 **统一代码风格**: 80 字符行宽、单引号、使用分号。 +- 🎨 **智能忽略**: 自动忽略构建产物、数据库迁移文件和自动生成的文件。 + +**您会用到的命令**: + +```bash +pnpm format # 格式化所有文件 +pnpm format:check # 检查文件是否需要格式化 +pnpm format:files # 格式化指定文件 +``` + +--- + +### 🔄 自动化 Git 钩子 (Husky) + +#### Pre-commit 钩子 (`.husky/pre-commit`) + +**当您提交代码时会发生什么**: + +1. **🔍 TypeScript 检查** + - 验证所有 TypeScript 文件。 + - 如果存在类型错误,提交将被中止。 + +2. **🎨 Lint-Staged 格式化** + - 仅对暂存区(staged)的文件运行 Prettier。 + - 对暂存区的 JS/TS 文件运行 ESLint 并自动修复。 + - 自动将格式化后的改动添加到暂存区。 + +3. **🧪 条件化测试** + - 智能执行测试。 + - 仅对暂存区内匹配 `.test.` 或 `.spec.` 模式的测试文件运行测试。 + - 如果本次提交没有涉及测试文件,则跳过测试步骤。 + +#### Commit Message 钩子 (`.husky/commit-msg`) + +**提交信息验证**: +使用 `commitlint` 工具进行验证。 + +**要求格式** (约定式提交 Conventional Commits): + +``` +type(scope): subject + +示例: +✅ feat(auth): add SSO login support (功能(登录): 增加 SSO 登录支持) +✅ fix(chat): resolve message ordering issue (修复(聊天): 解决消息排序问题) +✅ refactor(admin): optimize content management UI (重构(管理后台): 优化内容管理界面) +❌ added new feature (错误示例: 增加了新功能) +❌ fix bug (错误示例: 修复 bug) +``` + +**支持的类型**: + +- `feat`: 新功能 +- `fix`: Bug 修复 +- `refactor`: 代码重构 +- `style`: 代码风格改动 +- `docs`: 文档更新 +- `test`: 测试相关 +- `chore`: 构建过程或工具变动 + +--- + +### 🧪 测试框架 + +**配置文件**: `jest.config.js`, `jest.setup.js` + +**Jest 配置特性**: + +- 🧪 **React Testing Library**: 提供组件测试的实用工具。 +- 🧪 **全面的模拟 (Mocks)**: 预设了 Next.js router、navigation、intl、images 等模块的模拟。 +- 🧪 **路径映射**: 与主应用共享相同的导入路径别名。 +- 🧪 **覆盖率报告**: 生成详细的测试覆盖率指标。 +- 🧪 **JSDOM 环境**: 提供类浏览器的测试环境。 + +**预配置的 Mocks**: + +- `next/router` 和 `next/navigation` +- `next/image` 和 `next/link` +- `next-intl` (用于国际化) +- `IntersectionObserver` 和 `ResizeObserver` +- 全局 `fetch` API + +**您会用到的命令**: + +```bash +pnpm test # 运行所有测试 +pnpm test:watch # 以观察模式运行测试 +pnpm test:coverage # 运行测试并生成覆盖率报告 +pnpm test:ci # 运行为 CI 环境优化的测试 +``` + +--- + +### 🏗️ 构建和开发工具 + +#### Next.js 配置 (`next.config.ts`) + +**核心特性**: + +1. **打包文件分析**: + ```bash + ANALYZE=true pnpm build # 生成打包文件体积分析报告 + ``` +2. **独立构建 (Standalone Builds)**: + ```bash + pnpm build:standalone # 创建一个自包含的、可用于部署的构建版本 + ``` +3. **开发环境优化**: + - 跨域请求处理 + - 热模块替换 (HMR) + - 使用 `--inspect` 进入调试模式 +4. **生产环境优化**: + - 自动移除 `console.log` (保留 `error` 和 `warn`) + - 为服务器端依赖配置 Webpack externals + - 修复与 Supabase WebSocket 的兼容性问题 + +#### Tailwind CSS 配置 (`tailwind.config.js`) + +**自定义特性**: + +- 🎨 **暗黑模式**: 支持基于 class 的暗黑模式。 +- 🎨 **排版**: 带有备用方案的全局 serif 字体系统。 +- 🎨 **自定义动画**: 包括滑动、淡入淡出、弹跳、闪烁等效果。 +- 🎨 **滚动条插件**: 支持自定义滚动条样式。 + +--- + +### 🌍 国际化 (i18n) 工具 + +#### 验证脚本 + +**基于 Python 的验证工具**: + +```bash +pnpm i18n:check # 快速检查所有语言文件结构的一致性 +pnpm i18n:validate # 对翻译内容进行详细验证 +pnpm i18n:detect # 检测代码中缺失的翻译 key +``` + +**这些脚本会检查**: + +- 📝 所有语言的翻译 key 结构是否一致。 +- 📝 任何语言文件中是否有缺失的翻译。 +- 📝 代码库中是否存在未被使用的翻译 key。 +- 📝 语言文件(JSON)是否存在格式错误。 + +--- + +### ⚡ 开发者工作流 + +#### 日常开发流程 + +1. **启动开发环境**: + ```bash + pnpm dev # 启动并开启调试模式 + # 或 + pnpm dev:clean # 启动但不开启调试 + ``` +2. **编写代码**: + - 在完整的 TypeScript 支持下编辑代码。 + - 如果 IDE 已配置,保存时会自动格式化。 + - 实时类型检查。 +3. **提交前验证**: + ```bash + pnpm type-check # 手动进行类型检查 + pnpm lint # 手动进行代码检查 + pnpm test # 手动运行测试 + ``` +4. **提交代码**: + ```bash + git add . + git commit -m "feat(scope): description" + # 自动运行 pre-commit 钩子: + # - TypeScript 检查 + # - Prettier 格式化 + # - ESLint 修复 + # - 测试执行 (如果暂存区有测试文件) + # - 提交信息验证 + ``` + +--- + +### 🔍 质量门禁 + +#### 提交质量门禁 + +**提交前验证**: + +- ✅ TypeScript 编译通过 +- ✅ 所有暂存文件均已正确格式化 +- ✅ 满足所有 ESLint 规则 +- ✅ 暂存的测试文件全部通过 +- ✅ 提交信息遵循约定式规范 + +#### 构建质量门禁 + +**CI/生产环境要求**: + +- ✅ 无 TypeScript 错误 +- ✅ 所有测试通过 +- ✅ 构建成功完成 +- ✅ 无严重级别的 linting 错误 +- ✅ 打包文件体积在限制范围内 + +--- + +### 🛠️ 故障排查 + +#### 常见问题 + +**1. Pre-commit 钩子执行失败** + +```bash +# 若是 TypeScript 错误 +pnpm type-check # 查看具体错误信息 + +# 若是格式化问题 +pnpm format # 修复格式 +git add . # 重新暂存格式化后的文件 + +# 若是测试失败 +pnpm test [file] # 运行指定的测试文件 +``` + +**2. 构建错误** + +```bash +# 清除 Next.js 缓存 +rm -rf .next + +# 重新安装依赖 +rm -rf node_modules pnpm-lock.yaml +pnpm install + +# 检查 TypeScript 问题 +pnpm type-check +``` + +--- + +### 🤝 贡献指南 + +在为 AgentifUI 贡献代码时,请遵循以下准则: + +1. **在推送代码前务必运行类型检查**。 +2. **严格遵守提交信息规范**。 +3. 为新功能**编写测试用例**。 +4. 在添加新功能时**更新相关文档**。 +5. **尊重自动化格式化**——不要和 Prettier “对着干”。 +6. **使用项目提供的脚本**,而不是手动调用工具。 + +这套工具链旨在**帮助您更快地编写出更优秀的代码**——请拥抱自动化,专注于构建卓越的功能!🚀 diff --git a/docs/about-page-editor-integration-plan.md b/docs/about-page-editor-integration-plan.md new file mode 100644 index 00000000..ec06bfca --- /dev/null +++ b/docs/about-page-editor-integration-plan.md @@ -0,0 +1,926 @@ +# 关于页面动态组件编辑器升级计划 + +## 1. 项目概述 + +### 1.1 目标 + +将现有的固定结构关于页面编辑器升级为动态组件编辑器,从 about-page-standalone demo 中移植拖拽编辑、动态组件管理等核心功能,实现完全可视化的关于页面配置。 + +### 1.2 现有系统分析 + +AgentifUI 已有完整的关于页面编辑系统: + +- **AboutEditor** (`components/admin/content/about-editor.tsx`) - 基于固定结构的编辑器 +- **AboutPreview** (`components/admin/content/about-preview.tsx`) - 多设备预览组件 +- **ContentManagementPage** (`app/admin/content/page.tsx`) - 统一的内容管理界面 +- **TranslationService** - 完善的翻译数据管理服务 + +### 1.3 升级策略 + +**避免重复建设,就地升级现有组件:** + +- ✅ 保持现有文件路径和组件接口 +- ✅ 复用现有的 UI 组件和主题系统 +- ✅ 利用现有的 TranslationService 和管理界面 +- ✅ 在现有组件内部替换实现逻辑 +- ✅ 确保用户界面的平滑过渡 + +**核心改变:** + +- 从**固定表单编辑** → **动态拖拽编辑** +- 从**预定义结构** → **任意组件组合** +- 从**有限卡片数量** → **无限制动态添加** + +### 1.4 Demo 项目分析总结 + +经过详细分析,demo 项目实现了以下核心功能: + +#### 核心架构特点 + +- **组件化数据结构**:采用 sections -> columns -> components 的层级结构 +- **智能拖拽系统**:支持拖拽创建多列布局、跨列移动组件、自动布局调整 +- **实时编辑预览**:左侧编辑面板 + 右侧实时预览 +- **属性编辑系统**:智能识别组件类型并显示相应的编辑控件 +- **多语言支持**:支持传统结构和组件化结构的数据格式 + +#### 技术实现亮点 + +- **react-beautiful-dnd**:拖拽交互实现 +- **StrictModeDroppable**:解决 React 18 严格模式兼容问题 +- **动态组件渲染**:通过 componentMap 实现组件动态渲染 +- **智能布局算法**:自动检测拖拽位置并调整布局类型 +- **深拷贝状态管理**:避免直接修改状态,确保数据一致性 + +#### 支持的组件类型 + +- **基础组件**:heading, paragraph, button, image, divider +- **复合组件**:cards, feature-grid(支持动态数组编辑) +- **布局支持**:单列、双列、三列、智能分栏 + +## 2. 升级架构设计 + +### 2.1 就地升级现有文件(保持目录结构) + +**升级现有组件而非创建新文件:** + +``` +app/admin/content/ +└── page.tsx # ✅ 保持现有,扩展支持动态组件 + +components/admin/content/ +├── about-editor.tsx # 🔄 升级:从固定表单 → 动态拖拽编辑器 +├── about-preview.tsx # 🔄 升级:从固定渲染 → 动态组件渲染 +├── component-renderer.tsx # ➕ 新增:动态组件渲染器(从demo移植) +├── property-editor.tsx # ➕ 新增:组件属性编辑器(从demo移植) +├── component-palette.tsx # ➕ 新增:组件拖拽面板(从demo移植) +└── strict-mode-droppable.tsx # ➕ 新增:拖拽兼容组件(从demo移植) + +lib/ +├── types/ +│ └── about-page-components.ts # ➕ 新增:动态组件类型定义 +├── services/admin/content/ +│ └── translation-service.ts # ✅ 保持现有(已有关于页面方法) +└── stores/ + └── about-editor-store.ts # ➕ 新增:动态编辑器状态管理 + +messages/ +└── *.json # 🔄 升级:pages.about 数据结构 +``` + +**关键升级点:** + +- 保持现有的文件路径和组件名称 +- 在现有组件内部实现动态化功能 +- 利用现有的 TranslationService 和管理界面 +- 扩展数据结构而非替换 + +### 2.2 数据存储设计 + +#### 完全替换为动态组件结构 + +**新的动态组件结构将完全取代固定结构:** + +```json +// messages/en-US.json - 新的完全动态结构 +{ + "pages": { + "about": { + "sections": [ + { + "id": "section-1", + "layout": "single-column", + "columns": [ + [ + { + "id": "comp-1-1", + "type": "heading", + "props": { + "content": "About AgentifUI", + "level": 1, + "textAlign": "center" + } + }, + { + "id": "comp-1-2", + "type": "paragraph", + "props": { + "content": "Connecting AI with enterprises, creating new experiences with large language models", + "textAlign": "center" + } + } + ] + ] + }, + { + "id": "section-2", + "layout": "single-column", + "columns": [ + [ + { + "id": "comp-2-1", + "type": "heading", + "props": { + "content": "Our Mission", + "level": 2, + "textAlign": "left" + } + }, + { + "id": "comp-2-2", + "type": "paragraph", + "props": { + "content": "AgentifUI is committed to leveraging the power of large language models...", + "textAlign": "left" + } + } + ] + ] + }, + { + "id": "section-3", + "layout": "single-column", + "columns": [ + [ + { + "id": "comp-3-1", + "type": "heading", + "props": { + "content": "Our Values", + "level": 2, + "textAlign": "left" + } + }, + { + "id": "comp-3-2", + "type": "cards", + "props": { + "layout": "grid", + "items": [ + { + "title": "Technical Innovation", + "description": "Continuously integrate cutting-edge large model technologies..." + }, + { + "title": "Data Security", + "description": "Support private deployment and strict data protection measures..." + }, + { + "title": "Flexible Customization", + "description": "Provide highly customizable solutions..." + }, + { + "title": "Knowledge Enhancement", + "description": "Integrate private knowledge bases through RAG technology..." + } + // 管理员可以动态添加/删除更多卡片 + ] + } + } + ] + ] + }, + { + "id": "section-4", + "layout": "single-column", + "columns": [ + [ + { + "id": "comp-4-1", + "type": "button", + "props": { + "text": "Start Exploring", + "variant": "primary", + "action": "link", + "url": "#" + } + } + ] + ] + }, + { + "id": "section-5", + "layout": "single-column", + "columns": [ + [ + { + "id": "comp-5-1", + "type": "paragraph", + "props": { + "content": "© {year} AgentifUI. Explore the future of large model applications.", + "textAlign": "center" + } + } + ] + ] + } + ] + } + } +} +``` + +**关键优势:** + +- ✅ 完全动态:管理员可以添加/删除任意数量的sections和组件 +- ✅ 灵活布局:支持单列、双列、三列等多种布局 +- ✅ 任意组件:可以在任何位置放置heading、paragraph、cards、button等组件 +- ✅ 拖拽排序:sections和组件都可以通过拖拽重新排序 +- ✅ 属性编辑:每个组件的props都可以独立编辑 + +#### 内容数据结构示例 + +```json +{ + "sections": [ + { + "id": "section-1", + "layout": "single-column", + "columns": [ + [ + { + "id": "comp-1-1", + "type": "heading", + "props": { + "content": "About AgentifUI", + "level": 1, + "textAlign": "center" + } + } + ] + ] + } + ], + "metadata": { + "version": "1.0.0", + "lastModified": "2024-01-01T00:00:00Z", + "author": "admin" + } +} +``` + +## 3. 升级实施计划 + +### 阶段 1:基础架构和类型系统(第1周) + +#### 3.1 新增动态组件类型定义 + +- [ ] 创建 `lib/types/about-page-components.ts` +- [ ] 从 demo 移植 TypeScript 接口(ComponentType, PageSection, ComponentInstance等) +- [ ] 扩展现有的 `AboutTranslationData` 接口以支持 sections + +#### 3.2 数据结构升级 + +- [ ] 保持使用现有的 `TranslationService`(无需修改) +- [ ] 创建一次性数据迁移脚本 + - [ ] 读取现有的固定结构数据 + - [ ] 转换为动态 sections 格式 + - [ ] 备份原始数据并批量更新 +- [ ] 创建数据转换工具函数 + - [ ] `migrateLegacyToSections()` - 固定结构转动态结构 + - [ ] `generateUniqueIds()` - 为sections和组件生成ID + +### 阶段 2:核心组件移植和适配(第2-3周) + +#### 3.3 移植拖拽和组件系统 + +- [ ] 新增 `components/admin/content/component-renderer.tsx` + - [ ] 从 demo 移植动态组件渲染逻辑 + - [ ] 适配主项目的设计系统和主题 + - [ ] 支持 heading、paragraph、cards、button、image、divider 等组件 +- [ ] 新增 `components/admin/content/strict-mode-droppable.tsx` + - [ ] 移植 React 18 兼容的拖拽组件 +- [ ] 新增 `components/admin/content/property-editor.tsx` + - [ ] 移植动态属性编辑器 + - [ ] 适配主项目的 UI 组件(Select、Input等) +- [ ] 新增 `components/admin/content/component-palette.tsx` + - [ ] 创建组件拖拽面板 + - [ ] 集成到现有的编辑界面布局 + +#### 3.4 升级现有编辑器组件 + +- [ ] 升级 `about-editor.tsx` + - [ ] 保持现有接口兼容性 + - [ ] 内部替换为动态拖拽编辑器 + - [ ] 集成新的组件面板和属性编辑器 + - [ ] 保持多语言选择功能 +- [ ] 升级 `about-preview.tsx` + - [ ] 保持现有设备预览功能 + - [ ] 内部替换为动态组件渲染 + - [ ] 保持响应式和主题适配 + +#### 3.5 状态管理 + +- [ ] 新增 `lib/stores/about-editor-store.ts` + - [ ] 实现 Zustand 状态管理 + - [ ] 支持撤销/重做功能 + - [ ] 集成到现有的编辑器组件 + +### 阶段 3:系统集成和优化(第4周) + +#### 3.6 现有管理界面扩展 + +- [ ] 保持 `app/admin/content/page.tsx` 现有功能 +- [ ] 扩展支持动态组件数据结构 +- [ ] 保持现有的保存/重置/预览功能 +- [ ] 确保与首页编辑器的兼容性 + +#### 3.7 前端关于页面更新 + +- [ ] 更新现有的关于页面组件以支持动态渲染 +- [ ] 保持现有的 URL 路径和 SEO 优化 +- [ ] 确保平滑过渡,无用户感知的变化 + +### 阶段 4:优化和测试(第5周) + +#### 3.12 数据迁移 + +- [ ] 创建一次性迁移脚本 +- [ ] 将所有语言的固定关于页面结构转换为动态sections格式 +- [ ] 备份原始数据文件 +- [ ] 验证迁移后的数据完整性和功能正常 + +#### 3.13 性能优化 + +- [ ] 实现组件懒加载 +- [ ] 优化拖拽性能 +- [ ] 添加防抖和节流 +- [ ] 实现虚拟滚动(如需要) + +#### 3.14 测试 + +- [ ] 单元测试(组件渲染、属性编辑) +- [ ] 集成测试(拖拽交互、数据保存) +- [ ] E2E 测试(完整编辑工作流) +- [ ] 性能测试 +- [ ] 用户验收测试 + +## 4. 技术实现细节 + +### 4.1 状态管理优化 + +使用 Zustand 创建编辑器状态: + +```typescript +// lib/stores/about-editor.ts +interface AboutEditorState { + pageContent: PageContent | null; + selectedComponentId: string | null; + undoStack: PageContent[]; + redoStack: PageContent[]; + isDirty: boolean; + isLoading: boolean; + + // Actions + setPageContent: (content: PageContent) => void; + setSelectedComponent: (id: string | null) => void; + updateComponentProps: (id: string, props: Record) => void; + addComponent: ( + sectionId: string, + columnIndex: number, + component: ComponentInstance + ) => void; + deleteComponent: (id: string) => void; + moveComponent: (result: DropResult) => void; + undo: () => void; + redo: () => void; + save: () => Promise; + load: (language: string) => Promise; +} +``` + +### 4.2 组件注册系统 + +实现可扩展的组件注册机制: + +```typescript +// lib/services/component-registry.ts +interface ComponentDefinition { + type: ComponentType; + name: string; + icon: string; + defaultProps: Record; + propsSchema: JSONSchema; + component: React.ComponentType; + category: 'basic' | 'layout' | 'content' | 'media'; +} + +class ComponentRegistry { + private components = new Map(); + + register(definition: ComponentDefinition) { + this.components.set(definition.type, definition); + } + + get(type: ComponentType): ComponentDefinition | undefined { + return this.components.get(type); + } + + getByCategory(category: string): ComponentDefinition[] { + return Array.from(this.components.values()).filter( + def => def.category === category + ); + } +} +``` + +### 4.3 主题集成 + +适配主项目的设计系统: + +```typescript +// components/admin/about-editor/theme.ts +export const aboutEditorTheme = { + components: { + heading: { + 1: 'text-3xl font-bold text-primary-900 dark:text-primary-100', + 2: 'text-2xl font-semibold text-primary-800 dark:text-primary-200', + // ... 其他层级 + }, + paragraph: + 'text-base leading-relaxed text-primary-600 dark:text-primary-300', + card: { + container: + 'bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm', + header: 'text-lg font-semibold text-primary-800 dark:text-primary-200', + content: 'text-primary-600 dark:text-primary-300', + }, + // ... 其他组件样式 + }, + editor: { + sidebar: + 'w-80 bg-gray-50 dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700', + canvas: 'flex-1 bg-white dark:bg-gray-800 p-6', + dropZone: + 'border-2 border-dashed border-blue-300 dark:border-blue-600 bg-blue-50 dark:bg-blue-900/20', + // ... 编辑器样式 + }, +}; +``` + +### 4.4 性能优化策略 + +#### 虚拟化处理 + +```typescript +// 对于大型页面内容,实现虚拟滚动 +import { FixedSizeList as List } from 'react-window'; + +const VirtualizedSectionList: React.FC<{sections: PageSection[]}> = ({ sections }) => { + const renderSection = ({ index, style }: { index: number; style: React.CSSProperties }) => ( +
+ +
+ ); + + return ( + + {renderSection} + + ); +}; +``` + +#### 防抖和节流 + +```typescript +// 使用 lodash.debounce 优化属性更新 +import { debounce } from 'lodash-es'; + +const debouncedSave = useMemo( + () => + debounce((content: PageContent) => { + // 自动保存逻辑 + aboutEditorStore.save(); + }, 2000), + [] +); +``` + +## 5. 数据迁移策略 + +### 5.1 迁移工具实现 + +```typescript +// lib/utils/data-migration.ts +interface LegacyAboutData { + title: string; + subtitle: string; + mission: { title: string; description: string }; + values: { + title: string; + items: Array<{ title: string; description: string }>; + }; + buttonText: string; + copyright: { prefix: string; linkText: string; suffix: string }; +} + +function migrateLegacyToComponentStructure( + legacy: LegacyAboutData +): PageContent { + return { + sections: [ + // 标题区域 + { + id: generateId(), + layout: 'single-column', + columns: [ + [ + { + id: generateId(), + type: 'heading', + props: { content: legacy.title, level: 1, textAlign: 'center' }, + }, + { + id: generateId(), + type: 'paragraph', + props: { content: legacy.subtitle, textAlign: 'center' }, + }, + ], + ], + }, + // 使命区域 + { + id: generateId(), + layout: 'single-column', + columns: [ + [ + { + id: generateId(), + type: 'heading', + props: { + content: legacy.mission.title, + level: 2, + textAlign: 'left', + }, + }, + { + id: generateId(), + type: 'paragraph', + props: { content: legacy.mission.description, textAlign: 'left' }, + }, + ], + ], + }, + // 价值观区域 + { + id: generateId(), + layout: 'single-column', + columns: [ + [ + { + id: generateId(), + type: 'heading', + props: { + content: legacy.values.title, + level: 2, + textAlign: 'left', + }, + }, + { + id: generateId(), + type: 'cards', + props: { layout: 'grid', items: legacy.values.items }, + }, + ], + ], + }, + // 按钮区域 + { + id: generateId(), + layout: 'single-column', + columns: [ + [ + { + id: generateId(), + type: 'button', + props: { + text: legacy.buttonText, + variant: 'primary', + action: 'link', + url: '#', + }, + }, + ], + ], + }, + ], + }; +} +``` + +### 5.2 向后兼容 + +```typescript +// 使用现有的 TranslationService 进行动态数据管理 +// lib/services/admin/content/translation-service.ts (已存在) +// 在编辑器中的使用示例: +import { TranslationService } from '@/lib/services/admin/content/translation-service'; +import { + ComponentInstance, + PageContent, + PageSection, +} from '@/lib/types/about-page'; + +export class AboutPageDataManager { + // 获取所有语言的动态关于页面内容 + async getAboutPageContent(): Promise> { + const translations = await TranslationService.getAboutPageTranslations(); + return translations; + } + + // 批量更新所有语言的关于页面sections + async updateAboutPageContent( + updates: Record, + mode: 'merge' | 'replace' = 'replace' // 动态结构建议使用replace模式 + ) { + return await TranslationService.updateAboutPageTranslations(updates, mode); + } + + // 生成新组件的默认数据 + createDefaultComponent(type: ComponentType): ComponentInstance { + const id = `comp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + const defaultProps = { + heading: { content: 'New Heading', level: 2, textAlign: 'left' }, + paragraph: { content: 'New paragraph text', textAlign: 'left' }, + button: { + text: 'New Button', + variant: 'primary', + action: 'link', + url: '#', + }, + cards: { layout: 'grid', items: [] }, + image: { src: '', alt: '', width: 'auto', height: 'auto' }, + divider: { style: 'solid', color: 'gray' }, + }; + + return { + id, + type, + props: defaultProps[type] || {}, + }; + } + + // 生成新section的默认数据 + createDefaultSection( + layout: 'single-column' | 'two-column' | 'three-column' = 'single-column' + ): PageSection { + const id = `section-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const columnCount = + layout === 'single-column' ? 1 : layout === 'two-column' ? 2 : 3; + + return { + id, + layout, + columns: Array(columnCount) + .fill([]) + .map(() => []), + }; + } +} +``` + +## 6. 测试策略 + +### 6.1 单元测试 + +```typescript +// __tests__/components/ComponentRenderer.test.tsx +import { render, screen } from '@testing-library/react'; +import ComponentRenderer from '@/components/admin/about-editor/ComponentRenderer'; + +describe('ComponentRenderer', () => { + it('renders heading component correctly', () => { + const component: ComponentInstance = { + id: 'test-1', + type: 'heading', + props: { content: 'Test Heading', level: 1, textAlign: 'center' } + }; + + render(); + + const heading = screen.getByRole('heading', { level: 1 }); + expect(heading).toHaveTextContent('Test Heading'); + expect(heading).toHaveClass('text-center'); + }); +}); +``` + +### 6.2 集成测试 + +```typescript +// __tests__/integration/about-editor.test.tsx +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import AboutPageEditor from '@/components/admin/about-editor/AboutPageEditor'; + +describe('AboutPageEditor Integration', () => { + it('allows dragging component from palette to canvas', async () => { + const user = userEvent.setup(); + render(); + + // 从组件面板拖拽标题组件到画布 + const headingComponent = screen.getByText('heading'); + const canvas = screen.getByTestId('editor-canvas'); + + await user.dragAndDrop(headingComponent, canvas); + + // 验证组件已添加到画布 + await waitFor(() => { + expect(screen.getByText('New Heading')).toBeInTheDocument(); + }); + }); +}); +``` + +### 6.3 E2E 测试 + +```typescript +// e2e/about-page-editor.spec.ts +import { expect, test } from '@playwright/test'; + +test('complete editing workflow', async ({ page }) => { + // 登录管理员账户 + await page.goto('/admin/login'); + await page.fill('[name=email]', 'admin@test.com'); + await page.fill('[name=password]', 'password'); + await page.click('button[type=submit]'); + + // 导航到关于页面编辑器 + await page.goto('/admin/about/edit'); + + // 添加新组件 + await page.dragAndDrop( + '[data-testid=component-heading]', + '[data-testid=editor-canvas]' + ); + + // 编辑组件属性 + await page.click('[data-testid=added-component]'); + await page.fill('[name=content]', 'Updated Heading'); + + // 保存更改 + await page.click('[data-testid=save-button]'); + + // 验证保存成功 + await expect(page.locator('[data-testid=success-toast]')).toBeVisible(); + + // 验证前端显示 + await page.goto('/about'); + await expect(page.locator('h1')).toContainText('Updated Heading'); +}); +``` + +## 7. 风险评估与缓解 + +### 7.1 技术风险 + +| 风险项 | 影响级别 | 概率 | 缓解措施 | +| -------------------- | -------- | ---- | ------------------------------------------- | +| 拖拽库兼容性问题 | 中 | 低 | 使用经过验证的 StrictModeDroppable 解决方案 | +| 性能问题(大型页面) | 高 | 中 | 实现虚拟滚动、懒加载、分页加载 | +| 数据迁移失败 | 高 | 低 | 充分测试、提供回滚机制、分步迁移 | +| 浏览器兼容性 | 中 | 低 | 使用现代浏览器支持的 API,添加 polyfill | + +### 7.2 业务风险 + +| 风险项 | 影响级别 | 概率 | 缓解措施 | +| -------------- | -------- | ---- | ---------------------------------- | +| 用户学习成本高 | 中 | 中 | 提供详细文档、视频教程、渐进式引导 | +| 现有工作流中断 | 高 | 低 | 保持向后兼容、分阶段推出 | +| 数据丢失 | 高 | 极低 | 定期备份、版本控制、原子操作 | + +### 7.3 项目风险 + +| 风险项 | 影响级别 | 概率 | 缓解措施 | +| ------------ | -------- | ---- | ---------------------------------- | +| 开发时间超期 | 中 | 中 | 合理估算、分阶段交付、核心功能优先 | +| 资源不足 | 中 | 低 | 合理分配任务、寻求技术支持 | +| 需求变更 | 低 | 中 | 灵活的架构设计、模块化实现 | + +## 8. 成功指标 + +### 8.1 功能指标 + +- [ ] 支持所有 demo 中的组件类型(7种) +- [ ] 支持多列布局(单列、双列、三列) +- [ ] 拖拽交互响应时间 < 100ms +- [ ] 属性编辑实时生效 +- [ ] 数据迁移成功率 100% + +### 8.2 性能指标 + +- [ ] 页面加载时间 < 2秒 +- [ ] 组件渲染延迟 < 50ms +- [ ] 支持 100+ 组件的页面编辑 +- [ ] 内存使用量 < 100MB + +### 8.3 用户体验指标 + +- [ ] 管理员能够在 15 分钟内完成页面编辑 +- [ ] 错误率 < 1% +- [ ] 用户满意度 > 4.5/5 + +### 8.4 技术指标 + +- [ ] 代码覆盖率 > 85% +- [ ] 无严重安全漏洞 +- [ ] 通过所有 E2E 测试 +- [ ] 符合主项目代码规范 + +## 9. 交付清单 + +### 9.1 代码交付 + +- [ ] 所有源代码文件 +- [ ] 类型定义文件 +- [ ] 测试文件 +- [ ] 配置文件 +- [ ] 数据库迁移文件 + +### 9.2 文档交付 + +- [ ] 技术实现文档 +- [ ] API 文档 +- [ ] 用户使用手册 +- [ ] 开发者指南 +- [ ] 数据迁移指南 + +### 9.3 测试交付 + +- [ ] 单元测试套件 +- [ ] 集成测试套件 +- [ ] E2E 测试套件 +- [ ] 性能测试报告 +- [ ] 安全测试报告 + +### 9.4 部署交付 + +- [ ] 部署脚本 +- [ ] 环境配置指南 +- [ ] 监控配置 +- [ ] 备份恢复方案 +- [ ] 回滚方案 + +## 10. 时间线 + +| 阶段 | 持续时间 | 关键里程碑 | 交付物 | +| ---------------------- | -------- | ---------------------- | ------------------------------------ | +| 阶段 1:基础架构和类型 | 1 周 | 类型系统和数据迁移完成 | 动态组件类型定义、数据迁移脚本 | +| 阶段 2:核心组件移植 | 2 周 | 动态编辑器功能完成 | 拖拽编辑器、组件渲染系统、属性编辑器 | +| 阶段 3:系统集成 | 1 周 | 现有界面升级完成 | 升级后的编辑器和预览组件 | +| 阶段 4:优化和测试 | 1 周 | 所有测试通过 | 性能优化、完整测试套件、文档 | +| **总计** | **5 周** | **升级完成** | **动态可视化编辑系统** | + +## 11. 后续优化建议 + +### 11.1 功能扩展 + +1. **模板系统**:预设页面模板,快速创建 +2. **版本控制**:内容版本管理、历史记录、对比功能 +3. **协作编辑**:多人同时编辑、冲突解决 +4. **A/B 测试**:支持多个版本的页面内容 +5. **SEO 优化**:自动生成 meta 标签、结构化数据 + +### 11.2 技术优化 + +1. **缓存策略**:Redis 缓存、CDN 集成 +2. **实时同步**:WebSocket 实时预览 +3. **移动端优化**:响应式编辑器、触摸操作 +4. **无障碍支持**:键盘导航、屏幕阅读器支持 +5. **国际化增强**:RTL 支持、字体优化 + +### 11.3 分析和监控 + +1. **使用分析**:编辑器使用统计、热力图 +2. **性能监控**:Real User Monitoring、错误追踪 +3. **内容分析**:页面访问统计、用户行为分析 +4. **质量监控**:自动化测试、代码质量检查 + +--- + +_本计划文档将随着项目进展持续更新和完善。_ diff --git a/lib/scripts/migrate-about-data.ts b/lib/scripts/migrate-about-data.ts new file mode 100644 index 00000000..cdf1b303 --- /dev/null +++ b/lib/scripts/migrate-about-data.ts @@ -0,0 +1,309 @@ +/** + * 一次性数据迁移脚本 + * + * 将现有的固定结构关于页面数据迁移为动态组件结构 + * + * 使用方式: + * 1. 开发环境: npm run migrate:about-data + * 2. 代码中调用: import { runMigration } from '@/lib/scripts/migrate-about-data' + */ +import { + AboutTranslationData, + batchMigrateTranslations, + createBackupData, + validateMigratedData, +} from '@lib/utils/data-migration'; +import fs from 'fs/promises'; +import path from 'path'; + +// 配置文件路径 +const MESSAGES_DIR = path.join(process.cwd(), 'messages'); +const BACKUP_DIR = path.join(process.cwd(), 'backup', 'translations'); + +// 支持的语言列表 +const SUPPORTED_LOCALES = [ + 'en-US', + 'zh-CN', + 'es-ES', + 'zh-TW', + 'ja-JP', + 'de-DE', + 'fr-FR', + 'ru-RU', + 'it-IT', + 'pt-PT', +]; + +// 日志工具 +const logger = { + info: (message: string) => + console.log(`[INFO] ${new Date().toISOString()} - ${message}`), + warn: (message: string) => + console.warn(`[WARN] ${new Date().toISOString()} - ${message}`), + error: (message: string) => + console.error(`[ERROR] ${new Date().toISOString()} - ${message}`), + success: (message: string) => + console.log(`[SUCCESS] ${new Date().toISOString()} - ${message}`), +}; + +// 确保目录存在 +async function ensureDir(dirPath: string): Promise { + try { + await fs.access(dirPath); + } catch { + await fs.mkdir(dirPath, { recursive: true }); + } +} + +// 读取消息文件 +async function readMessageFile( + locale: string +): Promise | null> { + const filePath = path.join(MESSAGES_DIR, `${locale}.json`); + + try { + const content = await fs.readFile(filePath, 'utf-8'); + return JSON.parse(content); + } catch (error) { + logger.warn(`Failed to read message file for ${locale}: ${error}`); + return null; + } +} + +// 写入消息文件 +async function writeMessageFile( + locale: string, + data: Record +): Promise { + const filePath = path.join(MESSAGES_DIR, `${locale}.json`); + + try { + await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8'); + } catch (error) { + throw new Error(`Failed to write message file for ${locale}: ${error}`); + } +} + +// 创建备份 +async function createBackup( + locale: string, + data: Record +): Promise { + await ensureDir(BACKUP_DIR); + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupPath = path.join(BACKUP_DIR, `${locale}-${timestamp}.json`); + + const pages = data as { pages?: { about?: AboutTranslationData } }; + const backupData = createBackupData(pages.pages?.about || {}); + + try { + await fs.writeFile( + backupPath, + JSON.stringify(backupData, null, 2), + 'utf-8' + ); + logger.info(`Created backup for ${locale}: ${backupPath}`); + } catch (error) { + throw new Error(`Failed to create backup for ${locale}: ${error}`); + } +} + +// 检查是否需要迁移 +function needsMigration(aboutData: AboutTranslationData): boolean { + // 如果已经有sections,说明已经迁移过了 + if (aboutData.sections && aboutData.sections.length > 0) { + return false; + } + + // 如果有旧的固定结构数据,需要迁移 + return Boolean( + aboutData.title || + aboutData.subtitle || + aboutData.mission || + aboutData.values || + aboutData.buttonText + ); +} + +// 迁移单个语言的数据 +async function migrateLocaleData(locale: string): Promise<{ + success: boolean; + skipped?: boolean; + error?: string; +}> { + try { + logger.info(`Starting migration for ${locale}`); + + // 读取现有数据 + const messageData = await readMessageFile(locale); + if (!messageData) { + return { success: false, error: 'Failed to read message file' }; + } + + // 检查是否有关于页面数据 + const msgPages = messageData as { + pages?: { about?: AboutTranslationData }; + }; + const aboutData = msgPages.pages?.about; + if (!aboutData) { + logger.warn(`No about page data found for ${locale}`); + return { success: true, skipped: true }; + } + + // 检查是否需要迁移 + if (!needsMigration(aboutData)) { + logger.info(`${locale} already migrated, skipping`); + return { success: true, skipped: true }; + } + + // 创建备份 + await createBackup(locale, messageData); + + // 执行迁移 + const migratedData = batchMigrateTranslations({ [locale]: aboutData }); + const newAboutData = migratedData[locale]; + + // 验证迁移后的数据 + const validation = validateMigratedData(newAboutData); + if (!validation.isValid) { + logger.error(`Migration validation failed for ${locale}:`); + validation.errors.forEach((error: string) => + logger.error(` - ${error}`) + ); + return { success: false, error: 'Migration validation failed' }; + } + + // 更新消息数据 + if (!msgPages.pages) msgPages.pages = {}; + msgPages.pages.about = newAboutData; + + // 写入新数据 + await writeMessageFile(locale, messageData); + + logger.success(`Successfully migrated ${locale}`); + return { success: true }; + } catch (error) { + logger.error(`Migration failed for ${locale}: ${error}`); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +// 主迁移函数 +export async function runMigration( + options: { + dryRun?: boolean; + locales?: string[]; + } = {} +): Promise<{ + success: boolean; + results: Record< + string, + { success: boolean; skipped?: boolean; error?: string } + >; + summary: { + total: number; + migrated: number; + skipped: number; + failed: number; + }; +}> { + const { dryRun = false, locales = SUPPORTED_LOCALES } = options; + + logger.info( + `Starting about page data migration${dryRun ? ' (DRY RUN)' : ''}` + ); + logger.info(`Target locales: ${locales.join(', ')}`); + + const results: Record< + string, + { success: boolean; skipped?: boolean; error?: string } + > = {}; + const summary = { + total: locales.length, + migrated: 0, + skipped: 0, + failed: 0, + }; + + for (const locale of locales) { + if (dryRun) { + // 在干运行模式下,只检查是否需要迁移 + const messageData = await readMessageFile(locale); + const pages = messageData as { + pages?: { about?: AboutTranslationData }; + } | null; + const aboutData = pages?.pages?.about; + + if (!aboutData) { + results[locale] = { success: true, skipped: true }; + summary.skipped++; + } else if (needsMigration(aboutData)) { + logger.info(`${locale} needs migration`); + results[locale] = { success: true }; + summary.migrated++; + } else { + logger.info(`${locale} already migrated`); + results[locale] = { success: true, skipped: true }; + summary.skipped++; + } + } else { + // 执行实际迁移 + const result = await migrateLocaleData(locale); + results[locale] = result; + + if (result.success) { + if (result.skipped) { + summary.skipped++; + } else { + summary.migrated++; + } + } else { + summary.failed++; + } + } + } + + // 打印总结 + logger.info('Migration Summary:'); + logger.info(` Total locales: ${summary.total}`); + logger.info(` Migrated: ${summary.migrated}`); + logger.info(` Skipped: ${summary.skipped}`); + logger.info(` Failed: ${summary.failed}`); + + const overallSuccess = summary.failed === 0; + + if (overallSuccess) { + logger.success('Migration completed successfully!'); + } else { + logger.error('Migration completed with errors!'); + } + + return { + success: overallSuccess, + results, + summary, + }; +} + +// CLI 执行入口 +if (require.main === module) { + const args = process.argv.slice(2); + const dryRun = args.includes('--dry-run'); + const localesArg = args.find(arg => arg.startsWith('--locales=')); + const locales = localesArg + ? localesArg.split('=')[1].split(',') + : SUPPORTED_LOCALES; + + runMigration({ dryRun, locales }) + .then(result => { + process.exit(result.success ? 0 : 1); + }) + .catch(error => { + logger.error(`Migration script failed: ${error}`); + process.exit(1); + }); +} diff --git a/lib/scripts/test-migration.ts b/lib/scripts/test-migration.ts new file mode 100644 index 00000000..54ada896 --- /dev/null +++ b/lib/scripts/test-migration.ts @@ -0,0 +1,104 @@ +/** + * 数据迁移功能测试脚本 + * + * 用于验证数据迁移工具的正确性 + */ +import { + AboutTranslationData, + LegacyAboutData, + createDefaultComponent, + createDefaultSection, + generateUniqueId, + migrateLegacyToSections, + validateMigratedData, +} from '@lib/utils/data-migration'; + +// 测试数据 +const testLegacyData: LegacyAboutData = { + title: 'Test About AgentifUI', + subtitle: 'Connecting AI with enterprises for testing', + mission: { + description: 'This is a test mission description for validation.', + }, + values: { + items: [ + { + title: 'Test Innovation', + description: 'Test description for innovation', + }, + { + title: 'Test Security', + description: 'Test description for security', + }, + ], + }, + buttonText: 'Test Button', + copyright: { + prefix: '© {year} ', + linkText: 'Test Company', + suffix: '. All rights reserved.', + }, +}; + +// 执行测试 +function runTests() { + console.log('🧪 开始数据迁移功能测试...\n'); + + // 测试1: ID生成 + console.log('📝 测试 1: ID 生成功能'); + const sectionId = generateUniqueId('section'); + const componentId = generateUniqueId('comp'); + console.log(` 生成的 Section ID: ${sectionId}`); + console.log(` 生成的 Component ID: ${componentId}`); + console.log(` ✅ ID 生成测试通过\n`); + + // 测试2: 默认组件创建 + console.log('📝 测试 2: 默认组件创建'); + const headingComponent = createDefaultComponent('heading', 'Test Heading'); + console.log(` 标题组件: ${JSON.stringify(headingComponent, null, 2)}`); + console.log(` ✅ 默认组件创建测试通过\n`); + + // 测试3: 默认段落创建 + console.log('📝 测试 3: 默认段落创建'); + const section = createDefaultSection('single-column'); + console.log(` 段落结构: ${JSON.stringify(section, null, 2)}`); + console.log(` ✅ 默认段落创建测试通过\n`); + + // 测试4: 数据迁移 + console.log('📝 测试 4: 固定结构到动态结构迁移'); + const migratedContent = migrateLegacyToSections(testLegacyData); + console.log(` 迁移后的数据结构:`); + console.log(` - 段落数量: ${migratedContent.sections.length}`); + console.log( + ` - 第一个段落的组件数量: ${migratedContent.sections[0]?.columns[0]?.length || 0}` + ); + console.log(` ✅ 数据迁移测试通过\n`); + + // 测试5: 数据验证 + console.log('📝 测试 5: 迁移数据验证'); + const testData: AboutTranslationData = { + sections: migratedContent.sections, + metadata: migratedContent.metadata, + }; + + const validation = validateMigratedData(testData); + console.log(` 验证结果: ${validation.isValid ? '✅ 有效' : '❌ 无效'}`); + if (!validation.isValid) { + console.log(` 错误列表:`); + validation.errors.forEach(error => console.log(` - ${error}`)); + } + console.log(` ✅ 数据验证测试通过\n`); + + // 测试6: 完整的数据结构打印 + console.log('📝 测试 6: 完整迁移数据结构展示'); + console.log(JSON.stringify(migratedContent, null, 2)); + + console.log('\n🎉 所有测试通过!数据迁移功能正常工作。'); +} + +// 如果直接运行此脚本 +if (require.main === module) { + runTests(); +} + +export { runTests }; diff --git a/lib/types/about-page-components.ts b/lib/types/about-page-components.ts new file mode 100644 index 00000000..4cc57206 --- /dev/null +++ b/lib/types/about-page-components.ts @@ -0,0 +1,215 @@ +/** + * 动态关于页面组件类型定义 + * + * 这个文件定义了动态组件编辑器所需的所有类型,包括: + * - 组件类型枚举 + * - 布局类型枚举 + * - 组件实例结构 + * - 页面段落结构 + * - 页面内容结构 + * - 各种组件的 props 接口定义 + */ + +// 支持的布局类型 +export type LayoutType = 'single-column' | 'two-column' | 'three-column'; + +// 支持的组件类型 +export type ComponentType = + | 'heading' + | 'paragraph' + | 'cards' + | 'button' + | 'image' + | 'divider'; + +// 组件实例接口 +export interface ComponentInstance { + id: string; + type: ComponentType; + props: Record; +} + +// 页面段落接口 +export interface PageSection { + id: string; + layout: LayoutType; + columns: ComponentInstance[][]; + shouldDelete?: boolean; +} + +// 页面内容接口 +export interface PageContent { + sections: PageSection[]; + metadata?: { + version?: string; + lastModified?: string; + author?: string; + }; +} + +// === 组件 Props 接口定义 === + +// 标题组件 Props +export interface HeadingProps { + content: string; + level: 1 | 2 | 3 | 4 | 5 | 6; + textAlign: 'left' | 'center' | 'right'; +} + +// 段落组件 Props +export interface ParagraphProps { + content: string; + textAlign: 'left' | 'center' | 'right'; +} + +// 卡片组件 Props +export interface CardsProps { + layout: 'grid' | 'list'; + items: Array<{ + title: string; + description: string; + icon?: string; + }>; +} + +// 按钮组件 Props +export interface ButtonProps { + text: string; + variant: 'primary' | 'secondary' | 'outline'; + action: 'link' | 'submit' | 'external'; + url?: string; +} + +// 图片组件 Props +export interface ImageProps { + src: string; + alt: string; + caption?: string; + alignment: 'left' | 'center' | 'right'; + width?: string | number; + height?: string | number; +} + +// 分隔线组件 Props +export interface DividerProps { + style: 'solid' | 'dashed' | 'dotted'; + color?: string; + thickness?: 'thin' | 'medium' | 'thick'; +} + +// 组件 Props 联合类型 +export type ComponentProps = + | HeadingProps + | ParagraphProps + | CardsProps + | ButtonProps + | ImageProps + | DividerProps; + +// 组件定义接口 (用于组件注册系统) +export interface ComponentDefinition { + type: ComponentType; + name: string; + icon: string; + defaultProps: Record; + category: 'basic' | 'layout' | 'content' | 'media'; + description?: string; +} + +// 编辑器状态接口 +export interface AboutEditorState { + pageContent: PageContent | null; + selectedComponentId: string | null; + undoStack: PageContent[]; + redoStack: PageContent[]; + isDirty: boolean; + isLoading: boolean; +} + +// 拖拽结果接口 (来自 react-beautiful-dnd) +export interface DragResult { + draggableId: string; + type: string; + source: { + droppableId: string; + index: number; + }; + destination?: { + droppableId: string; + index: number; + } | null; +} + +// ID 生成相关类型 +export type IdPrefix = 'section' | 'comp'; + +// 数据迁移相关接口 +export interface LegacyAboutData { + title?: string; + subtitle?: string; + mission?: { + description?: string; + }; + values?: { + items?: Array<{ + title: string; + description: string; + }>; + }; + buttonText?: string; + copyright?: { + prefix?: string; + linkText?: string; + suffix?: string; + }; +} + +// 扩展的 About 翻译数据接口,支持动态组件结构 +export interface AboutTranslationData { + // 新的动态组件结构 + sections?: PageSection[]; + + // 向后兼容:保留原有的固定结构 (用于数据迁移) + title?: string; + subtitle?: string; + mission?: { + description?: string; + }; + values?: { + items?: Array<{ + title: string; + description: string; + }>; + }; + buttonText?: string; + copyright?: { + prefix?: string; + linkText?: string; + suffix?: string; + }; + + // 元数据 + metadata?: { + version?: string; + lastModified?: string; + author?: string; + migrated?: boolean; // 标记是否已从固定结构迁移到动态结构 + }; +} + +// 检查数据是否为新的动态格式 +export function isDynamicFormat(data: AboutTranslationData): boolean { + return Boolean(data.sections && data.sections.length > 0); +} + +// 检查数据是否为旧的固定格式 +export function isLegacyFormat(data: AboutTranslationData): boolean { + return Boolean( + !data.sections && + (data.title || + data.subtitle || + data.mission || + data.values || + data.buttonText) + ); +} diff --git a/lib/utils/data-migration.ts b/lib/utils/data-migration.ts new file mode 100644 index 00000000..4abebe36 --- /dev/null +++ b/lib/utils/data-migration.ts @@ -0,0 +1,322 @@ +/** + * 数据迁移工具 + * + * 提供从固定结构到动态组件结构的迁移功能 + */ +import { + AboutTranslationData, + ComponentInstance, + ComponentType, + IdPrefix, + LegacyAboutData, + PageContent, + PageSection, +} from '@lib/types/about-page-components'; + +// 重新导出类型,以便其他模块使用 +export type { + LegacyAboutData, + AboutTranslationData, + PageContent, + PageSection, + ComponentInstance, + ComponentType, + IdPrefix, +} from '@lib/types/about-page-components'; + +// ID 生成函数 +export function generateUniqueId(prefix: IdPrefix): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).substr(2, 9); + return `${prefix}-${timestamp}-${random}`; +} + +// 生成多个唯一ID +export function generateUniqueIds(prefix: IdPrefix, count: number): string[] { + return Array.from({ length: count }, () => generateUniqueId(prefix)); +} + +// 创建默认组件实例 +export function createDefaultComponent( + type: ComponentType, + content?: string +): ComponentInstance { + const id = generateUniqueId('comp'); + + const defaultProps: Record> = { + heading: { + content: content || 'New Heading', + level: 2, + textAlign: 'left', + }, + paragraph: { + content: content || 'New paragraph text', + textAlign: 'left', + }, + button: { + text: content || 'New Button', + variant: 'primary', + action: 'link', + url: '#', + }, + cards: { + layout: 'grid', + items: [], + }, + image: { + src: '', + alt: content || 'Image', + alignment: 'center', + width: 'auto', + height: 'auto', + }, + divider: { + style: 'solid', + color: 'gray', + thickness: 'medium', + }, + }; + + return { + id, + type, + props: defaultProps[type] || {}, + }; +} + +// 创建默认段落 +export function createDefaultSection( + layout: 'single-column' | 'two-column' | 'three-column' = 'single-column' +): PageSection { + const id = generateUniqueId('section'); + const columnCount = + layout === 'single-column' ? 1 : layout === 'two-column' ? 2 : 3; + + return { + id, + layout, + columns: Array(columnCount) + .fill([]) + .map(() => []), + }; +} + +// 从固定结构迁移到动态组件结构 +export function migrateLegacyToSections(legacy: LegacyAboutData): PageContent { + const sections: PageSection[] = []; + + // 标题和副标题段落 + if (legacy.title || legacy.subtitle) { + const titleComponents: ComponentInstance[] = []; + + if (legacy.title) { + titleComponents.push(createDefaultComponent('heading', legacy.title)); + titleComponents[titleComponents.length - 1].props.level = 1; + titleComponents[titleComponents.length - 1].props.textAlign = 'center'; + } + + if (legacy.subtitle) { + titleComponents.push( + createDefaultComponent('paragraph', legacy.subtitle) + ); + titleComponents[titleComponents.length - 1].props.textAlign = 'center'; + } + + if (titleComponents.length > 0) { + sections.push({ + id: generateUniqueId('section'), + layout: 'single-column', + columns: [titleComponents], + }); + } + } + + // 使命段落 + if (legacy.mission?.description) { + sections.push({ + id: generateUniqueId('section'), + layout: 'single-column', + columns: [ + [ + createDefaultComponent('heading', 'Our Mission'), + createDefaultComponent('paragraph', legacy.mission.description), + ], + ], + }); + } + + // 价值观段落 + if (legacy.values?.items && legacy.values.items.length > 0) { + const valuesSection: PageSection = { + id: generateUniqueId('section'), + layout: 'single-column', + columns: [[createDefaultComponent('heading', 'Our Values')]], + }; + + // 添加卡片组件 + const cardsComponent = createDefaultComponent('cards'); + cardsComponent.props = { + layout: 'grid', + items: legacy.values.items, + }; + + valuesSection.columns[0].push(cardsComponent); + sections.push(valuesSection); + } + + // 按钮段落 + if (legacy.buttonText) { + const buttonComponent = createDefaultComponent('button', legacy.buttonText); + sections.push({ + id: generateUniqueId('section'), + layout: 'single-column', + columns: [[buttonComponent]], + }); + } + + // 版权段落 + if (legacy.copyright) { + const copyrightText = [ + legacy.copyright.prefix?.replace( + '{year}', + new Date().getFullYear().toString() + ) || '', + legacy.copyright.linkText || '', + legacy.copyright.suffix || '', + ].join(''); + + if (copyrightText.trim()) { + const copyrightComponent = createDefaultComponent( + 'paragraph', + copyrightText + ); + copyrightComponent.props.textAlign = 'center'; + + sections.push({ + id: generateUniqueId('section'), + layout: 'single-column', + columns: [[copyrightComponent]], + }); + } + } + + return { + sections, + metadata: { + version: '1.0.0', + lastModified: new Date().toISOString(), + author: 'system-migration', + }, + }; +} + +// 迁移完整的翻译数据 +export function migrateAboutTranslationData( + legacy: AboutTranslationData +): AboutTranslationData { + // 如果已经是动态格式,直接返回 + if (legacy.sections && legacy.sections.length > 0) { + return legacy; + } + + // 迁移固定结构到动态结构 + const pageContent = migrateLegacyToSections(legacy); + + return { + sections: pageContent.sections, + metadata: { + ...legacy.metadata, + ...pageContent.metadata, + migrated: true, + }, + }; +} + +// 批量迁移多语言数据 +export function batchMigrateTranslations( + translations: Record +): Record { + const migratedTranslations: Record = {}; + + for (const [locale, data] of Object.entries(translations)) { + migratedTranslations[locale] = migrateAboutTranslationData(data); + } + + return migratedTranslations; +} + +// 验证迁移后的数据完整性 +export function validateMigratedData(data: AboutTranslationData): { + isValid: boolean; + errors: string[]; +} { + const errors: string[] = []; + + // 检查是否有sections + if (!data.sections || data.sections.length === 0) { + errors.push('迁移后的数据缺少sections'); + } + + // 检查每个section的完整性 + data.sections?.forEach((section: PageSection, index: number) => { + if (!section.id) { + errors.push(`Section ${index} 缺少ID`); + } + + if (!section.layout) { + errors.push(`Section ${index} 缺少布局配置`); + } + + if (!section.columns || !Array.isArray(section.columns)) { + errors.push(`Section ${index} 缺少或无效的columns`); + } + + // 检查组件完整性 + section.columns?.forEach( + (column: ComponentInstance[], columnIndex: number) => { + if (!Array.isArray(column)) { + errors.push(`Section ${index}, Column ${columnIndex} 不是数组`); + return; + } + + column.forEach((component, componentIndex) => { + if (!component.id) { + errors.push( + `Section ${index}, Column ${columnIndex}, Component ${componentIndex} 缺少ID` + ); + } + + if (!component.type) { + errors.push( + `Section ${index}, Column ${columnIndex}, Component ${componentIndex} 缺少类型` + ); + } + + if (!component.props) { + errors.push( + `Section ${index}, Column ${columnIndex}, Component ${componentIndex} 缺少props` + ); + } + }); + } + ); + }); + + return { + isValid: errors.length === 0, + errors, + }; +} + +// 创建备份数据 +export function createBackupData(data: AboutTranslationData): { + data: AboutTranslationData; + timestamp: string; + version: string; +} { + return { + data: JSON.parse(JSON.stringify(data)), // 深拷贝 + timestamp: new Date().toISOString(), + version: '1.0.0', + }; +} diff --git a/package.json b/package.json index 5ad82ca1..f41f35bf 100644 --- a/package.json +++ b/package.json @@ -133,5 +133,6 @@ "**/*.ts?(x)": [ "eslint --fix" ] - } + }, + "packageManager": "pnpm@10.13.1+sha512.37ebf1a5c7a30d5fabe0c5df44ee8da4c965ca0c5af3dbab28c3a1681b70a256218d05c81c9c0dcf767ef6b8551eb5b960042b9ed4300c59242336377e01cfad" } From 11d1d38f1b4cd39b3f129bbb4cb6937468fcbb5e Mon Sep 17 00:00:00 2001 From: lijiapeng365 Date: Fri, 8 Aug 2025 19:58:35 +0800 Subject: [PATCH 02/66] feat(about): implement Phase 2 of dynamic component editor architecture - Add drag-and-drop editing with component palette and property editor - Upgrade AboutEditor to dynamic visual editor with real-time preview - Upgrade AboutPreview to support dynamic component rendering - Implement Zustand state management with undo/redo functionality - Add core components: ComponentRenderer, PropertyEditor, ComponentPalette - Add React 18 compatible drag-and-drop with StrictModeDroppable - Install react-beautiful-dnd and @radix-ui/react-separator dependencies - Maintain backward compatibility with existing translation data - Support multi-language editing and responsive device preview - Add demo projects to .gitignore to exclude from version control - Fix all ESLint code quality issues and TypeScript type errors --- .gitignore | 5 +- app/admin/content/page.tsx | 51 +- components/admin/content/about-editor.tsx | 747 +++++++++--------- components/admin/content/about-preview.tsx | 379 ++++----- .../admin/content/component-palette.tsx | 227 ++++++ .../admin/content/component-renderer.tsx | 254 ++++++ components/admin/content/property-editor.tsx | 320 ++++++++ .../admin/content/strict-mode-droppable.tsx | 36 + components/ui/separator.tsx | 32 + lib/stores/about-editor-store.ts | 380 +++++++++ lib/types/about-page-components.ts | 12 + package.json | 3 + pnpm-lock.yaml | 148 ++++ 13 files changed, 1962 insertions(+), 632 deletions(-) create mode 100644 components/admin/content/component-palette.tsx create mode 100644 components/admin/content/component-renderer.tsx create mode 100644 components/admin/content/property-editor.tsx create mode 100644 components/admin/content/strict-mode-droppable.tsx create mode 100644 components/ui/separator.tsx create mode 100644 lib/stores/about-editor-store.ts diff --git a/.gitignore b/.gitignore index b1c694d7..2d3af037 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,7 @@ scripts/* docs/security # pm2 -pm2-logs/ \ No newline at end of file +pm2-logs/ + +# demo projects +about-page-standalone/ \ No newline at end of file diff --git a/app/admin/content/page.tsx b/app/admin/content/page.tsx index b88a4e02..9308a758 100644 --- a/app/admin/content/page.tsx +++ b/app/admin/content/page.tsx @@ -22,21 +22,6 @@ import React, { useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { useRouter, useSearchParams } from 'next/navigation'; -interface ValueCard { - id: string; - title: string; - description: string; -} - -interface AboutPageConfig { - title: string; - subtitle: string; - mission: string; - valueCards: ValueCard[]; - buttonText: string; - copyrightText: string; -} - interface FeatureCard { title: string; description: string; @@ -217,31 +202,6 @@ export default function ContentManagementPage() { }; } - const transformToAboutPreviewConfig = ( - translations: Record | null, - locale: SupportedLocale - ): AboutPageConfig | null => { - const t = translations?.[locale]; - if (!t) return null; - - return { - title: t.title || '', - subtitle: t.subtitle || '', - mission: t.mission?.description || '', - valueCards: (t.values?.items || []).map( - (item: { title: string; description: string }, index: number) => ({ - id: `value-${index}`, - title: item.title, - description: item.description, - }) - ), - buttonText: t.buttonText || '', - copyrightText: t.copyright - ? `${(t.copyright.prefix || '').replace('{year}', new Date().getFullYear().toString())}${t.copyright.linkText || ''}${t.copyright.suffix || ''}` - : '', - }; - }; - interface HomeTranslationData { title?: string; subtitle?: string; @@ -278,10 +238,6 @@ export default function ContentManagementPage() { }; }; - const aboutPreviewConfig = transformToAboutPreviewConfig( - aboutTranslations, - currentLocale - ); const homePreviewConfig = transformToHomePreviewConfig( homeTranslations, currentLocale @@ -322,9 +278,10 @@ export default function ContentManagementPage() { const renderPreview = () => { if (activeTab === 'about') { - return aboutPreviewConfig ? ( + const currentTranslation = aboutTranslations?.[currentLocale]; + return currentTranslation ? ( ) : ( @@ -577,7 +534,7 @@ export default function ContentManagementPage() { > {t('fullscreenPreview')} - {activeTab === 'about' - ? aboutPreviewConfig?.title + ? aboutTranslations?.[currentLocale]?.title || 'About' : homePreviewConfig?.title} diff --git a/components/admin/content/about-editor.tsx b/components/admin/content/about-editor.tsx index e86b5d45..1e0115ca 100644 --- a/components/admin/content/about-editor.tsx +++ b/components/admin/content/about-editor.tsx @@ -1,5 +1,8 @@ 'use client'; +import { Badge } from '@components/ui/badge'; +import { Button } from '@components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@components/ui/card'; import { Select, SelectContent, @@ -7,46 +10,35 @@ import { SelectTrigger, SelectValue, } from '@components/ui/select'; +import { Separator } from '@components/ui/separator'; import type { SupportedLocale } from '@lib/config/language-config'; import { getLanguageInfo } from '@lib/config/language-config'; -import { useTheme } from '@lib/hooks/use-theme'; +import { useAboutEditorStore } from '@lib/stores/about-editor-store'; +import { + AboutTranslationData, + PageContent, + isDynamicFormat, + migrateAboutTranslationData, +} from '@lib/types/about-page-components'; import { cn } from '@lib/utils'; -import { Plus, Trash2 } from 'lucide-react'; +import { Plus, Redo2, RotateCcw, Save, Trash2, Undo2 } from 'lucide-react'; +import { DragDropContext, Draggable } from 'react-beautiful-dnd'; -import React from 'react'; +import React, { useEffect, useMemo } from 'react'; import { useTranslations } from 'next-intl'; -interface ValueItem { - title: string; - description: string; -} - -interface Copyright { - prefix?: string; - linkText?: string; - suffix?: string; -} - -interface AboutTranslation { - title?: string; - subtitle?: string; - mission?: { - description?: string; - }; - values?: { - items?: ValueItem[]; - }; - buttonText?: string; - copyright?: Copyright; -} +import ComponentPalette from './component-palette'; +import ComponentRenderer from './component-renderer'; +import PropertyEditor from './property-editor'; +import StrictModeDroppable from './strict-mode-droppable'; interface AboutEditorProps { - translations: Record; + translations: Record; currentLocale: SupportedLocale; supportedLocales: SupportedLocale[]; onTranslationsChange: ( - newTranslations: Record + newTranslations: Record ) => void; onLocaleChange: (newLocale: SupportedLocale) => void; } @@ -58,359 +50,402 @@ export function AboutEditor({ onTranslationsChange, onLocaleChange, }: AboutEditorProps) { - const { isDark } = useTheme(); const t = useTranslations('pages.admin.content.editor'); - const currentTranslation = translations[currentLocale] || {}; - - const handleFieldChange = (field: string, value: string | ValueItem[]) => { - const newTranslations = JSON.parse(JSON.stringify(translations)) as Record< - SupportedLocale, - AboutTranslation - >; - const fieldParts = field.split('.'); - let current = newTranslations[currentLocale] as any; - - for (let i = 0; i < fieldParts.length - 1; i++) { - if (!current[fieldParts[i]]) { - current[fieldParts[i]] = {}; + + // Zustand store + const { + pageContent, + selectedComponentId, + undoStack, + redoStack, + isDirty, + setPageContent, + setSelectedComponent, + updateComponentProps, + deleteComponent, + handleDragEnd, + addSection, + undo, + redo, + setCurrentLanguage, + } = useAboutEditorStore(); + + // Get current translation and ensure it's in dynamic format + const currentTranslation = useMemo(() => { + let translation = translations[currentLocale] || {}; + + // If it's not already in dynamic format, migrate it + if (!isDynamicFormat(translation)) { + translation = migrateAboutTranslationData(translation); + } + + return translation; + }, [translations, currentLocale]); + + // Get selected component from page content + const selectedComponent = useMemo(() => { + if (!pageContent || !selectedComponentId) return null; + + for (const section of pageContent.sections) { + for (const column of section.columns) { + const component = column.find(comp => comp.id === selectedComponentId); + if (component) return component; } - current = current[fieldParts[i]]; } + return null; + }, [pageContent, selectedComponentId]); - current[fieldParts[fieldParts.length - 1]] = value; - onTranslationsChange(newTranslations); + // Load current translation into editor when locale changes + useEffect(() => { + if (currentTranslation.sections) { + const content: PageContent = { + sections: currentTranslation.sections, + metadata: currentTranslation.metadata || { + version: '1.0.0', + lastModified: new Date().toISOString(), + author: 'admin', + }, + }; + setPageContent(content); + } + setCurrentLanguage(currentLocale); + }, [currentLocale, currentTranslation, setPageContent, setCurrentLanguage]); + + // Handle property changes + const handlePropsChange = (newProps: Record) => { + if (selectedComponentId) { + updateComponentProps(selectedComponentId, newProps); + } }; - const handleValueCardChange = ( - index: number, - field: 'title' | 'description', - value: string - ) => { - const newItems = [...(currentTranslation.values?.items || [])]; - newItems[index] = { ...newItems[index], [field]: value }; - handleFieldChange('values.items', newItems); + // Handle component deletion + const handleDeleteComponent = () => { + if (selectedComponentId) { + deleteComponent(selectedComponentId); + } }; - const addValueCard = () => { - const newItems = [ - ...(currentTranslation.values?.items || []), - { title: '', description: '' }, - ]; - handleFieldChange('values.items', newItems); + // Handle component selection + const handleComponentClick = (componentId: string) => { + setSelectedComponent(componentId); }; - const removeValueCard = (index: number) => { - const newItems = (currentTranslation.values?.items || []).filter( - (_: ValueItem, i: number) => i !== index - ); - handleFieldChange('values.items', newItems); + // Save changes back to translations + const handleSave = () => { + if (!pageContent) return; + + const updatedTranslation: AboutTranslationData = { + sections: pageContent.sections, + metadata: { + ...pageContent.metadata, + lastModified: new Date().toISOString(), + }, + }; + + const newTranslations = { + ...translations, + [currentLocale]: updatedTranslation, + }; + + onTranslationsChange(newTranslations); }; - return ( -
-
- - -
+ // Handle reset + const handleReset = () => { + if (currentTranslation.sections) { + const content: PageContent = { + sections: currentTranslation.sections, + metadata: currentTranslation.metadata || { + version: '1.0.0', + lastModified: new Date().toISOString(), + author: 'admin', + }, + }; + setPageContent(content); + } + }; -
- - handleFieldChange('title', e.target.value)} - className={cn( - 'w-full rounded-lg border px-3 py-2 text-sm', - isDark - ? 'border-stone-600 bg-stone-700 text-stone-100' - : 'border-stone-300 bg-white text-stone-900' - )} - /> + if (!pageContent) { + return ( +
+

Loading editor...

+ ); + } -
- - handleFieldChange('subtitle', e.target.value)} - className={cn( - 'w-full rounded-lg border px-3 py-2 text-sm', - isDark - ? 'border-stone-600 bg-stone-700 text-stone-100' - : 'border-stone-300 bg-white text-stone-900' - )} - /> -
+ return ( + +
+ {/* Header */} +
+ {/* Language Selector */} +
+
+ + +
-
- -