Merge branch 'master_develop' of http://8.148.151.56:3000/tonytech/library-picturebook-activity into master_develop

This commit is contained in:
zhonghua 2026-04-07 13:44:48 +08:00
commit 0252f25acd
38 changed files with 5518 additions and 1409 deletions

180
CLAUDE.md Normal file
View File

@ -0,0 +1,180 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
多租户少儿绘本创作活动/竞赛管理平台,前后端分离架构。
## 目录结构
| 目录 | 说明 |
|------|------|
| `backend-java/` | Spring Boot 后端(实际开发目录) |
| `frontend/` | Vue 3 前端(实际开发目录) |
| `lesingle-aicreate-client/` | AI 绘本创作客户端(独立模块) |
## 常用命令
### 后端 (backend-java/)
```bash
cd backend-java
mvn spring-boot:run -Dspring.profiles.active=dev # 开发启动(端口 8580上下文 /api
mvn flyway:migrate # 执行数据库迁移
mvn clean package # 构建打包
```
### 前端 (frontend/)
```bash
cd frontend
npm run dev # 开发模式(端口 3000代理 /api → localhost:8580
npm run build # 生产构建base: /web/
npm run build:test # 测试环境构建base: /web-test/
npm run lint # ESLint 检查
```
### AI创作客户端 (lesingle-aicreate-client/)
```bash
cd lesingle-aicreate-client
npm install && npm run dev # 独立启动
```
## 技术栈
| 层级 | 技术 |
|------|------|
| 后端框架 | Spring Boot 3.2.5 + Java 17 |
| ORM | MyBatis-Plus 3.5.7 |
| 数据库 | MySQL 8.0 + Flyway 迁移 |
| 认证 | Spring Security + JWT |
| 缓存 | Redis |
| 工具库 | Hutool 5.8 + FastJSON2 + Knife4j 4.4API文档 |
| 前端框架 | Vue 3 + TypeScript + Vite 5 |
| UI | Ant Design Vue 4.1 |
| 状态管理 | Pinia |
| 样式 | Tailwind CSS + SCSS |
| 表单验证 | VeeValidate + Zod |
| 富文本 | WangEditor |
| 图表 | ECharts |
## 后端架构 (backend-java/)
### 基础包: `com.competition`
### 三层架构
| 层级 | 职责 | 规范 |
|------|------|------|
| Controller | HTTP 请求处理、参数校验、Entity↔VO 转换 | 统一返回 `Result<T>` |
| Service | 业务逻辑、事务控制 | 接口 `I{Module}Service`,继承 `IService<T>` |
| Mapper | 数据库 CRUD | 继承 `BaseMapper<T>` |
**核心原则**: Service/Mapper 层使用 EntityVO 转换只在 Controller 层。
### 模块划分
```
com.competition.modules/
├── biz/
│ ├── contest/ # 赛事管理(/contests
│ ├── homework/ # 作业管理(/homework
│ ├── judge/ # 评委管理
│ └── review/ # 评审管理(/contest-reviews, /contest-results
├── sys/ # 系统管理(用户/角色/权限/租户,/sys/*
├── user/ # 用户模块
├── ugc/ # 用户生成内容
├── pub/ # 公开接口(/public/*,无需认证)
└── oss/ # 对象存储(/oss/upload
```
### 实体基类 (BaseEntity)
所有实体继承 `BaseEntity`,包含字段:`id`、`createBy`、`updateBy`、`createTime`、`modifyTime`、`deleted`、`validState`。
表名规范:业务表 `t_biz_*`、系统表 `t_sys_*`、用户表 `t_user_*`
### 多租户
- 所有业务表包含 `tenant_id` 字段
- 获取租户ID: `SecurityUtil.getCurrentTenantId()`
- 超级管理员 `isSuperAdmin()` 可访问所有租户数据
- 请求头通过 `X-Tenant-Code`、`X-Tenant-Id` 传递
### 认证与权限
- JWT Token payload: `{sub: userId, username, tenantId}`
- 公开接口: `@Public` 注解或路径 `/public/**`
- 权限控制: `@RequirePermission` 注解
### 统一响应格式
```java
Result<T> → {code, message, data, timestamp, path}
PageResult<T> → {list, total, page, pageSize}
```
### 数据库迁移
- 位置: `src/main/resources/db/migration/`
- 命名: `V{number}__description.sql`(注意双下划线)
- 不使用外键约束,关联关系通过代码控制
## 前端架构 (frontend/)
### 路由与多租户
- 路由路径包含租户编码: `/:tenantCode/login`、`/:tenantCode/dashboard`
- 动态路由: 根据用户权限菜单动态生成
- 双模式: 管理端(需认证)+ 公众端(无需认证)
### 三种布局
| 布局 | 用途 |
|------|------|
| BasicLayout | 管理端(侧边栏+顶栏+面包屑) |
| PublicLayout | 公众端(简洁导航) |
| EmptyLayout | 全屏页面 |
### API 调用模式
API 模块位于 `src/api/`Axios 实例在 `src/utils/request.ts`
- 请求拦截器自动添加 Authorization token 和租户头
- 响应拦截器统一错误处理401 跳转登录403 提示)
- 函数命名: `getXxx`、`createXxx`、`updateXxx`、`deleteXxx`
### 权限控制
- 路由级: `meta.permissions`
- 组件级: `v-permission` 自定义指令
- 方法级: `hasPermission()`、`hasAnyPermission()`、`isSuperAdmin()`
### 状态管理
- auth Store: 用户信息、token、菜单、权限检查
- Token 存储在 Cookie 中
## 开发规范
- **日志/注释使用中文**
- **Git 提交格式**: `类型: 描述`(如 `feat: 添加XX功能`、`fix: 修复XX问题`
- **组件语法**: `<script setup lang="ts">`
- **文件命名**: 组件 PascalCase其他 kebab-case
- **数据库**: 不使用外键,关联通过代码控制
- **逻辑删除**: `deleted` 字段
## 环境配置
| 环境 | 后端 Profile | 前端 base | 后端端口 |
|------|-------------|-----------|---------|
| 开发 | dev | `/` | 8580 |
| 测试 | test | `/web-test/` | 8580 |
| 生产 | prod | `/web/` | 8580 |
## 注意事项
- `.cursor/rules/` 中部分规范(如 NestJS、Prisma是旧版配置当前后端已迁移至 Spring Boot + MyBatis-Plus以本文件为准
- 当前项目未配置测试框架
- 当前项目未配置 i18n所有文本为中文硬编码

View File

@ -15,10 +15,12 @@
"ant-design-vue": "^4.1.1", "ant-design-vue": "^4.1.1",
"axios": "^1.6.7", "axios": "^1.6.7",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"echarts": "^6.0.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"three": "^0.182.0", "three": "^0.182.0",
"vee-validate": "^4.12.4", "vee-validate": "^4.12.4",
"vue": "^3.4.21", "vue": "^3.4.21",
"vue-echarts": "^8.0.1",
"vue-router": "^4.3.0", "vue-router": "^4.3.0",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
@ -34,7 +36,7 @@
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5.4.3", "typescript": "^5.4.3",
"vite": "^5.1.6", "vite": "^5.1.6",
"vue-tsc": "^1.8.27" "vue-tsc": "^3.2.2"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
@ -1808,34 +1810,29 @@
} }
}, },
"node_modules/@volar/language-core": { "node_modules/@volar/language-core": {
"version": "1.11.1", "version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-1.11.1.tgz", "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.28.tgz",
"integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==", "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@volar/source-map": "1.11.1" "@volar/source-map": "2.4.28"
} }
}, },
"node_modules/@volar/source-map": { "node_modules/@volar/source-map": {
"version": "1.11.1", "version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-1.11.1.tgz", "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.28.tgz",
"integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==", "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
"dev": true, "dev": true
"license": "MIT",
"dependencies": {
"muggle-string": "^0.3.1"
}
}, },
"node_modules/@volar/typescript": { "node_modules/@volar/typescript": {
"version": "1.11.1", "version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-1.11.1.tgz", "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.28.tgz",
"integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==", "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@volar/language-core": "1.11.1", "@volar/language-core": "2.4.28",
"path-browserify": "^1.0.1" "path-browserify": "^1.0.1",
"vscode-uri": "^3.0.8"
} }
}, },
"node_modules/@vue/compiler-core": { "node_modules/@vue/compiler-core": {
@ -1944,29 +1941,30 @@
} }
}, },
"node_modules/@vue/language-core": { "node_modules/@vue/language-core": {
"version": "1.8.27", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-1.8.27.tgz", "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.2.6.tgz",
"integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==", "integrity": "sha512-xYYYX3/aVup576tP/23sEUpgiEnujrENaoNRbaozC1/MA9I6EGFQRJb4xrt/MmUCAGlxTKL2RmT8JLTPqagCkg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@volar/language-core": "~1.11.1", "@volar/language-core": "2.4.28",
"@volar/source-map": "~1.11.1", "@vue/compiler-dom": "^3.5.0",
"@vue/compiler-dom": "^3.3.0", "@vue/shared": "^3.5.0",
"@vue/shared": "^3.3.0", "alien-signals": "^3.0.0",
"computeds": "^0.0.1", "muggle-string": "^0.4.1",
"minimatch": "^9.0.3",
"muggle-string": "^0.3.1",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"vue-template-compiler": "^2.7.14" "picomatch": "^4.0.2"
}
},
"node_modules/@vue/language-core/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"engines": {
"node": ">=12"
}, },
"peerDependencies": { "funding": {
"typescript": "*" "url": "https://github.com/sponsors/jonschlinkert"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
} }
}, },
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
@ -2218,6 +2216,12 @@
"url": "https://github.com/sponsors/epoberezkin" "url": "https://github.com/sponsors/epoberezkin"
} }
}, },
"node_modules/alien-signals": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.1.2.tgz",
"integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==",
"dev": true
},
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@ -2633,13 +2637,6 @@
"integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==", "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/computeds": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz",
"integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==",
"dev": true,
"license": "MIT"
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -2726,13 +2723,6 @@
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
"integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -2856,6 +2846,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.267", "version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
@ -3681,16 +3680,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"dev": true,
"license": "MIT",
"bin": {
"he": "bin/he"
}
},
"node_modules/hookable": { "node_modules/hookable": {
"version": "5.5.3", "version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
@ -4187,11 +4176,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/muggle-string": { "node_modules/muggle-string": {
"version": "0.3.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.3.1.tgz", "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
"integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==", "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
"dev": true, "dev": true
"license": "MIT"
}, },
"node_modules/mz": { "node_modules/mz": {
"version": "2.7.0", "version": "2.7.0",
@ -4381,10 +4369,9 @@
}, },
"node_modules/path-browserify": { "node_modules/path-browserify": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"dev": true, "dev": true
"license": "MIT"
}, },
"node_modules/path-exists": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
@ -5359,6 +5346,11 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
},
"node_modules/type": { "node_modules/type": {
"version": "2.7.3", "version": "2.7.3",
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
@ -5541,6 +5533,12 @@
} }
} }
}, },
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz",
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"dev": true
},
"node_modules/vue": { "node_modules/vue": {
"version": "3.5.26", "version": "3.5.26",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
@ -5588,6 +5586,15 @@
} }
} }
}, },
"node_modules/vue-echarts": {
"version": "8.0.1",
"resolved": "https://registry.npmmirror.com/vue-echarts/-/vue-echarts-8.0.1.tgz",
"integrity": "sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==",
"peerDependencies": {
"echarts": "^6.0.0",
"vue": "^3.3.0"
}
},
"node_modules/vue-eslint-parser": { "node_modules/vue-eslint-parser": {
"version": "9.4.3", "version": "9.4.3",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz",
@ -5628,33 +5635,20 @@
"vue": "^3.5.0" "vue": "^3.5.0"
} }
}, },
"node_modules/vue-template-compiler": {
"version": "2.7.16",
"resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz",
"integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"de-indent": "^1.0.2",
"he": "^1.2.0"
}
},
"node_modules/vue-tsc": { "node_modules/vue-tsc": {
"version": "1.8.27", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-1.8.27.tgz", "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.2.6.tgz",
"integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==", "integrity": "sha512-gYW/kWI0XrwGzd0PKc7tVB/qpdeAkIZLNZb10/InizkQjHjnT8weZ/vBarZoj4kHKbUTZT/bAVgoOr8x4NsQ/Q==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@volar/typescript": "~1.11.1", "@volar/typescript": "2.4.28",
"@vue/language-core": "1.8.27", "@vue/language-core": "3.2.6"
"semver": "^7.5.4"
}, },
"bin": { "bin": {
"vue-tsc": "bin/vue-tsc.js" "vue-tsc": "bin/vue-tsc.js"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "*" "typescript": ">=5.0.0"
} }
}, },
"node_modules/vue-types": { "node_modules/vue-types": {
@ -5751,6 +5745,14 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"dependencies": {
"tslib": "2.3.0"
}
} }
} }
} }

View File

@ -0,0 +1,224 @@
# 乐读派 AI 绘本创作系统 — 企业对接 Demo (Java Spring Boot)
> 配合《AI绘本创作系统_企业后端集成指南_V4.0.pdf》使用。
> 本 Demo 实现了企业对接所需的**全部功能**替换4个配置后可直接运行。
## 两种集成方式
乐读派提供两种集成方式,企业可根据自身需求选择:
| | 方式一dist包交付iframe嵌入 | 方式二:源码交付(企业自集成) |
|---|---|---|
| **交付物** | dist/ 编译产物(不含源码) | src/ 源码23文件/5000行 |
| **企业能否改UI** | 不能只改config.js品牌 | 可以改任何页面 |
| **企业前端改动** | ~50行JSiframe+postMessage | ~20行路由注册+config |
| **无iframe体验** | 否iframe有视觉边界 | 是(创作页面是企业应用的一部分) |
| **我方更新** | 给新dist包替换部署 | 给源码更新,企业合并代码 |
| **对接周期** | 7-10天 | 4-7天需Vue开发能力 |
| **推荐场景** | 快速上线/前端能力弱 | UI定制/追求无缝体验 |
### 方式一用法iframe嵌入
按本文档后续章节操作部署dist包 → 改config.js → 实现后端接口 → iframe嵌入 + postMessage通信。
### 方式二用法源码集成仅3步
```bash
# Step 1: 复制源码到企业项目
cp -r lesingle-aicreate-client/src/ your-project/src/modules/lesingle/
# Step 2: 安装额外依赖vue/axios/vue-router企业项目已有则跳过
npm install ali-oss @stomp/stompjs crypto-js
# Step 3: 注册路由在企业router中约10行
```
```javascript
import { routes } from '@/modules/lesingle/router'
routes.forEach(r => router.addRoute(r))
// 入口跳转(企业页面中)
router.push('/?token=sess_xxx&orgId=ORG001&phone=138xxx')
```
> **方式二不需要改任何创作页面代码**。企业只需改config.js服务地址和注册路由。
> 所有创作交互(上传→识别→画风→故事→创作→编目→配音→阅读)原封不动使用。
> 无第三方UI库依赖不会与企业现有UI框架冲突。
---
## 架构说明
```
企业系统本Demo, 端口9090
├── 企业前端 (/enterprise-sim.html)
│ ├── [广场] [创作] [作品] [我的] 四模块
│ ├── 创作模块: iframe嵌入乐读派H5 (3001端口)
│ └── 作品模块: 调B3查作品列表点击跳转编目/配音
├── 企业后端
│ ├── /leai-auth — 统一认证入口(首次+token失效回调
│ ├── /api/refresh-token — iframe内token刷新
│ ├── /webhook/leai — 接收Webhook回调状态变更推送
│ └── B3定时对账 — 每30分钟兜底同步补偿Webhook遗漏
└── 企业数据库
└── TODO: 替换Demo中的内存Map为你的MySQL/PostgreSQL
```
## 企业需要开发的接口
| 接口 | 方法 | 功能 | 必要性 |
|------|------|------|--------|
| `/leai-auth` | GET | 认证入口换token+重定向H5 | **必须** |
| `/api/refresh-token` | GET | iframe内token刷新返回JSON | **必须**iframe模式 |
| `/webhook/leai` | POST | Webhook接收+签名验证+数据同步 | **强烈推荐** |
| B3定时对账 | 定时 | 兜底同步补偿Webhook遗漏 | **强烈推荐** |
## 快速运行3步
### Step 1: 修改4个配置
打开 `src/main/java/com/example/leaidemo/LeaiDemoApplication.java`,修改顶部常量:
```java
private static final String ORG_ID = "你的机构ID"; // 管理后台 → 机构管理 → 机构ID
private static final String APP_SECRET = "你的机构密钥"; // 管理后台 → 机构管理 → 机构密钥
private static final String LEAI_API_URL = "https://你的API域名"; // 乐读派后端API地址
private static final String LEAI_H5_URL = "https://你的H5域名"; // 乐读派H5前端地址
```
### Step 2: 运行
```bash
# Windows
start.bat
# 或手动
mvnw.cmd clean package -DskipTests
java -jar target/leai-enterprise-demo-1.0.0.jar
```
### Step 3: 管理后台配置
| 配置项 | 位置 | 填写内容 |
|-------|------|---------|
| Webhook URL | 机构管理 → 回调配置 | `https://你的域名/webhook/leai` |
| 认证回调URL | 机构管理 → 认证回调URL | `https://你的域名/leai-auth` |
| 事件订阅 | 机构管理 → 回调配置 → 事件订阅 | 全部勾选 |
## iframe 嵌入模式
企业C端通过 iframe 嵌入乐读派H5创作页面。
### iframe 加载方式
```
新建创作:
iframe.src = "https://H5域名/?token=xxx&orgId=xxx&phone=xxx&embed=1"
续编目(status=3):
iframe.src = "https://H5域名/edit-info/{workId}?token=xxx&orgId=xxx&phone=xxx&embed=1"
续配音(status=4):
iframe.src = "https://H5域名/dubbing/{workId}?token=xxx&orgId=xxx&phone=xxx&embed=1"
查看已完成(status=5):
iframe.src = "https://H5域名/read/{workId}?token=xxx&orgId=xxx&phone=xxx&embed=1&from=works"
```
### postMessage 消息协议
**H5 → 企业(需要监听)**:
| type | 说明 | payload |
|------|------|---------|
| READY | H5加载完毕 | {} |
| TOKEN_EXPIRED | token过期请刷新 | { messageId } |
| WORK_CREATED | 创作已提交 | { workId } |
| WORK_COMPLETED | 创作完成 | { workId } |
| CREATION_ERROR | 创作失败 | { message } |
| NAVIGATE_BACK | 请求返回作品列表 | {} |
**企业 → H5需要发送**:
| type | 说明 | payload |
|------|------|---------|
| TOKEN_REFRESHED | 响应TOKEN_EXPIRED | { messageId, token, orgId, phone } |
### 企业前端核心JS~30行
```javascript
window.addEventListener('message', function(event) {
var msg = event.data;
if (!msg || msg.source !== 'leai-creation') return;
if (msg.type === 'TOKEN_EXPIRED') {
fetch('/api/refresh-token?phone=' + phone)
.then(r => r.json())
.then(data => {
iframe.contentWindow.postMessage({
source: 'leai-creation', version: 1,
type: 'TOKEN_REFRESHED',
payload: { messageId: msg.payload.messageId, token: data.token, orgId: orgId }
}, '*');
});
}
if (msg.type === 'WORK_COMPLETED') {
refreshWorkList(); // 刷新企业作品列表
}
if (msg.type === 'NAVIGATE_BACK') {
switchToWorksTab(); // 切回作品列表
}
});
```
## H5 config.js 配置
H5的 `dist/config.js` 需要设置:
```javascript
authMode: "token",
prod: {
apiBaseUrl: "https://你的API域名",
wsBaseUrl: "wss://你的API域名"
},
embedMode: "iframe",
parentOrigins: ["https://你的企业域名"]
```
## 数据同步说明
企业通过两种方式接收乐读派的作品数据:
### 方式1: Webhook 实时推送(主通道)
- 乐读派在作品状态变更时主动推送到企业的 `/webhook/leai`
- 需要验证 HMAC-SHA256 签名
- 按V4.0同步规则处理数据
- **Demo中的 syncWork() 使用内存Map存储企业需替换为数据库操作见代码中的TODO注释**
### 方式2: B3 定时对账(兜底补偿)
- 每30分钟调用B3接口批量查询最近2小时的变更作品
- 补偿Webhook推送可能的遗漏
- **建议对账周期不低于30分钟避免对乐读派API造成查询压力**
- Demo中已实现完整的B3+B2对账逻辑见代码中的TODO注释
### V4.0 同步规则核心3行
```java
if (remoteStatus == -1) → 强制更新FAILED
if (remoteStatus == 2) → 强制更新PROCESSING进度变化
if (remoteStatus > localStatus) → 全量更新(状态前进)
else → 忽略(旧数据/重复推送)
```
## 注意事项
1. **appSecret 绝不到达浏览器** — 仅在企业服务端使用
2. **H5 使用 history 路由** — iframe src URL格式正确带参数即可
3. **returnPath 必须原样传回**`/leai-auth` 收到的 returnPath 不要修改
4. **Webhook 签名必须验证** — 生产环境不验证=安全漏洞
5. **status 是 INT 类型** — 取值 -1/1/2/3/4/5
6. **B3对账建议30分钟** — 过于频繁会增加API压力
7. **iframe需要allow属性**`allow="camera;microphone"`(录音功能需要)
8. **JDK 1.8 兼容** — 代码无Java 9+语法

View File

@ -0,0 +1,2 @@
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar

View File

@ -0,0 +1,224 @@
# 乐读派 AI 绘本创作系统 — 企业对接 Demo (Java Spring Boot)
> 配合《AI绘本创作系统_企业后端集成指南_V4.0.pdf》使用。
> 本 Demo 实现了企业对接所需的**全部功能**替换4个配置后可直接运行。
## 两种集成方式
乐读派提供两种集成方式,企业可根据自身需求选择:
| | 方式一dist包交付iframe嵌入 | 方式二:源码交付(企业自集成) |
|---|---|---|
| **交付物** | dist/ 编译产物(不含源码) | src/ 源码23文件/5000行 |
| **企业能否改UI** | 不能只改config.js品牌 | 可以改任何页面 |
| **企业前端改动** | ~50行JSiframe+postMessage | ~20行路由注册+config |
| **无iframe体验** | 否iframe有视觉边界 | 是(创作页面是企业应用的一部分) |
| **我方更新** | 给新dist包替换部署 | 给源码更新,企业合并代码 |
| **对接周期** | 7-10天 | 4-7天需Vue开发能力 |
| **推荐场景** | 快速上线/前端能力弱 | UI定制/追求无缝体验 |
### 方式一用法iframe嵌入
按本文档后续章节操作部署dist包 → 改config.js → 实现后端接口 → iframe嵌入 + postMessage通信。
### 方式二用法源码集成仅3步
```bash
# Step 1: 复制源码到企业项目
cp -r lesingle-aicreate-client/src/ your-project/src/modules/lesingle/
# Step 2: 安装额外依赖vue/axios/vue-router企业项目已有则跳过
npm install ali-oss @stomp/stompjs crypto-js
# Step 3: 注册路由在企业router中约10行
```
```javascript
import { routes } from '@/modules/lesingle/router'
routes.forEach(r => router.addRoute(r))
// 入口跳转(企业页面中)
router.push('/?token=sess_xxx&orgId=ORG001&phone=138xxx')
```
> **方式二不需要改任何创作页面代码**。企业只需改config.js服务地址和注册路由。
> 所有创作交互(上传→识别→画风→故事→创作→编目→配音→阅读)原封不动使用。
> 无第三方UI库依赖不会与企业现有UI框架冲突。
---
## 架构说明
```
企业系统本Demo, 端口9090
├── 企业前端 (/enterprise-sim.html)
│ ├── [广场] [创作] [作品] [我的] 四模块
│ ├── 创作模块: iframe嵌入乐读派H5 (3001端口)
│ └── 作品模块: 调B3查作品列表点击跳转编目/配音
├── 企业后端
│ ├── /leai-auth — 统一认证入口(首次+token失效回调
│ ├── /api/refresh-token — iframe内token刷新
│ ├── /webhook/leai — 接收Webhook回调状态变更推送
│ └── B3定时对账 — 每30分钟兜底同步补偿Webhook遗漏
└── 企业数据库
└── TODO: 替换Demo中的内存Map为你的MySQL/PostgreSQL
```
## 企业需要开发的接口
| 接口 | 方法 | 功能 | 必要性 |
|------|------|------|--------|
| `/leai-auth` | GET | 认证入口换token+重定向H5 | **必须** |
| `/api/refresh-token` | GET | iframe内token刷新返回JSON | **必须**iframe模式 |
| `/webhook/leai` | POST | Webhook接收+签名验证+数据同步 | **强烈推荐** |
| B3定时对账 | 定时 | 兜底同步补偿Webhook遗漏 | **强烈推荐** |
## 快速运行3步
### Step 1: 修改4个配置
打开 `src/main/java/com/example/leaidemo/LeaiDemoApplication.java`,修改顶部常量:
```java
private static final String ORG_ID = "你的机构ID"; // 管理后台 → 机构管理 → 机构ID
private static final String APP_SECRET = "你的机构密钥"; // 管理后台 → 机构管理 → 机构密钥
private static final String LEAI_API_URL = "https://你的API域名"; // 乐读派后端API地址
private static final String LEAI_H5_URL = "https://你的H5域名"; // 乐读派H5前端地址
```
### Step 2: 运行
```bash
# Windows
start.bat
# 或手动
mvnw.cmd clean package -DskipTests
java -jar target/leai-enterprise-demo-1.0.0.jar
```
### Step 3: 管理后台配置
| 配置项 | 位置 | 填写内容 |
|-------|------|---------|
| Webhook URL | 机构管理 → 回调配置 | `https://你的域名/webhook/leai` |
| 认证回调URL | 机构管理 → 认证回调URL | `https://你的域名/leai-auth` |
| 事件订阅 | 机构管理 → 回调配置 → 事件订阅 | 全部勾选 |
## iframe 嵌入模式
企业C端通过 iframe 嵌入乐读派H5创作页面。
### iframe 加载方式
```
新建创作:
iframe.src = "https://H5域名/?token=xxx&orgId=xxx&phone=xxx&embed=1"
续编目(status=3):
iframe.src = "https://H5域名/edit-info/{workId}?token=xxx&orgId=xxx&phone=xxx&embed=1"
续配音(status=4):
iframe.src = "https://H5域名/dubbing/{workId}?token=xxx&orgId=xxx&phone=xxx&embed=1"
查看已完成(status=5):
iframe.src = "https://H5域名/read/{workId}?token=xxx&orgId=xxx&phone=xxx&embed=1&from=works"
```
### postMessage 消息协议
**H5 → 企业(需要监听)**:
| type | 说明 | payload |
|------|------|---------|
| READY | H5加载完毕 | {} |
| TOKEN_EXPIRED | token过期请刷新 | { messageId } |
| WORK_CREATED | 创作已提交 | { workId } |
| WORK_COMPLETED | 创作完成 | { workId } |
| CREATION_ERROR | 创作失败 | { message } |
| NAVIGATE_BACK | 请求返回作品列表 | {} |
**企业 → H5需要发送**:
| type | 说明 | payload |
|------|------|---------|
| TOKEN_REFRESHED | 响应TOKEN_EXPIRED | { messageId, token, orgId, phone } |
### 企业前端核心JS~30行
```javascript
window.addEventListener('message', function(event) {
var msg = event.data;
if (!msg || msg.source !== 'leai-creation') return;
if (msg.type === 'TOKEN_EXPIRED') {
fetch('/api/refresh-token?phone=' + phone)
.then(r => r.json())
.then(data => {
iframe.contentWindow.postMessage({
source: 'leai-creation', version: 1,
type: 'TOKEN_REFRESHED',
payload: { messageId: msg.payload.messageId, token: data.token, orgId: orgId }
}, '*');
});
}
if (msg.type === 'WORK_COMPLETED') {
refreshWorkList(); // 刷新企业作品列表
}
if (msg.type === 'NAVIGATE_BACK') {
switchToWorksTab(); // 切回作品列表
}
});
```
## H5 config.js 配置
H5的 `dist/config.js` 需要设置:
```javascript
authMode: "token",
prod: {
apiBaseUrl: "https://你的API域名",
wsBaseUrl: "wss://你的API域名"
},
embedMode: "iframe",
parentOrigins: ["https://你的企业域名"]
```
## 数据同步说明
企业通过两种方式接收乐读派的作品数据:
### 方式1: Webhook 实时推送(主通道)
- 乐读派在作品状态变更时主动推送到企业的 `/webhook/leai`
- 需要验证 HMAC-SHA256 签名
- 按V4.0同步规则处理数据
- **Demo中的 syncWork() 使用内存Map存储企业需替换为数据库操作见代码中的TODO注释**
### 方式2: B3 定时对账(兜底补偿)
- 每30分钟调用B3接口批量查询最近2小时的变更作品
- 补偿Webhook推送可能的遗漏
- **建议对账周期不低于30分钟避免对乐读派API造成查询压力**
- Demo中已实现完整的B3+B2对账逻辑见代码中的TODO注释
### V4.0 同步规则核心3行
```java
if (remoteStatus == -1) → 强制更新FAILED
if (remoteStatus == 2) → 强制更新PROCESSING进度变化
if (remoteStatus > localStatus) → 全量更新(状态前进)
else → 忽略(旧数据/重复推送)
```
## 注意事项
1. **appSecret 绝不到达浏览器** — 仅在企业服务端使用
2. **H5 使用 history 路由** — iframe src URL格式正确带参数即可
3. **returnPath 必须原样传回**`/leai-auth` 收到的 returnPath 不要修改
4. **Webhook 签名必须验证** — 生产环境不验证=安全漏洞
5. **status 是 INT 类型** — 取值 -1/1/2/3/4/5
6. **B3对账建议30分钟** — 过于频繁会增加API压力
7. **iframe需要allow属性**`allow="camera;microphone"`(录音功能需要)
8. **JDK 1.8 兼容** — 代码无Java 9+语法

View File

@ -0,0 +1,29 @@
#!/bin/sh
# Maven Wrapper script
# Downloads and runs Maven if not already installed
set -e
MAVEN_WRAPPER_JAR=".mvn/wrapper/maven-wrapper.jar"
WRAPPER_PROPERTIES=".mvn/wrapper/maven-wrapper.properties"
if [ -f "$WRAPPER_PROPERTIES" ]; then
DIST_URL=$(grep "distributionUrl" "$WRAPPER_PROPERTIES" | cut -d'=' -f2- | tr -d '\r')
fi
if [ -z "$DIST_URL" ]; then
DIST_URL="https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip"
fi
MAVEN_HOME="${HOME}/.m2/wrapper/dists/apache-maven-3.9.6"
if [ ! -f "${MAVEN_HOME}/bin/mvn" ]; then
echo "Downloading Maven..."
mkdir -p "${MAVEN_HOME}"
TMPFILE=$(mktemp)
curl -fsSL "$DIST_URL" -o "$TMPFILE"
unzip -qo "$TMPFILE" -d "${MAVEN_HOME}/.."
rm -f "$TMPFILE"
fi
exec "${MAVEN_HOME}/bin/mvn" "$@"

View File

@ -0,0 +1,15 @@
@REM Maven Wrapper for Windows
@echo off
setlocal
set MAVEN_HOME=%USERPROFILE%\.m2\wrapper\dists\apache-maven-3.9.6
if exist "%MAVEN_HOME%\bin\mvn.cmd" goto execute
echo Downloading Maven...
powershell -Command "& { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri 'https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip' -OutFile '%TEMP%\maven.zip' }"
powershell -Command "& { Expand-Archive -Path '%TEMP%\maven.zip' -DestinationPath '%USERPROFILE%\.m2\wrapper\dists' -Force }"
del "%TEMP%\maven.zip"
:execute
call "%MAVEN_HOME%\bin\mvn.cmd" %*
exit /b %errorlevel%

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>leai-enterprise-demo</artifactId>
<version>1.0.0</version>
<name>LeAI Enterprise Demo</name>
<description>乐读派AI绘本创作系统 — 企业对接Demo含认证回调+Webhook+iframe嵌入</description>
<properties>
<java.version>1.8</java.version>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,2 @@
server.port=9090
spring.application.name=LeAI-Enterprise-Demo

View File

@ -0,0 +1,236 @@
<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>企业模拟C端</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, 'Segoe UI', sans-serif; background: #f5f5f5; height: 100vh; display: flex; flex-direction: column; max-width: 450px; margin: 0 auto; position: relative; }
/* 顶部信息栏 */
.header { background: white; padding: 8px 16px; border-bottom: 1px solid #eee; font-size: 12px; color: #666; line-height: 1.6; flex-shrink: 0; }
.header .phone { color: #7C3AED; font-weight: 600; }
.header .org { color: #999; font-size: 11px; }
/* 内容区 */
.content { flex: 1; overflow: hidden; position: relative; }
/* 广场/我的 占位 */
.placeholder { display: flex; align-items: center; justify-content: center; height: 100%; color: #bbb; font-size: 16px; flex-direction: column; gap: 8px; }
.placeholder .icon { font-size: 40px; }
/* 创作模块 iframe */
#creation-iframe { width: 100%; height: 100%; border: none; }
/* 作品列表 */
.works { padding: 16px; overflow-y: auto; height: 100%; }
.works h3 { color: #333; margin-bottom: 12px; font-size: 16px; }
.work-card { background: white; border-radius: 12px; padding: 12px; margin-bottom: 10px; display: flex; align-items: center; gap: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.work-thumb { width: 56px; height: 56px; border-radius: 8px; background: #f3f4f6; object-fit: cover; flex-shrink: 0; }
.work-info { flex: 1; min-width: 0; }
.work-title { font-size: 14px; font-weight: 600; color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.work-meta { font-size: 11px; color: #bbb; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.work-status { padding: 3px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; white-space: nowrap; flex-shrink: 0; }
.status-3 { background: #FEF3C7; color: #D97706; }
.status-4 { background: #DBEAFE; color: #2563EB; }
.status-5 { background: #D1FAE5; color: #059669; }
.status-2 { background: #FFF7ED; color: #EA580C; }
.work-btn { padding: 5px 12px; background: #7C3AED; color: white; border: none; border-radius: 14px; cursor: pointer; font-size: 11px; font-weight: 600; flex-shrink: 0; }
/* 底部TabBar */
.tabbar { display: flex; background: white; border-top: 1px solid #eee; flex-shrink: 0; padding-bottom: env(safe-area-inset-bottom, 0); }
.tabbar .tab-item { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 6px 0 4px; cursor: pointer; transition: color 0.2s; color: #999; }
.tabbar .tab-item.active { color: #7C3AED; }
.tabbar .tab-icon { font-size: 22px; line-height: 1; }
.tabbar .tab-label { font-size: 10px; margin-top: 2px; font-weight: 500; }
/* 日志面板(可折叠) */
#log-panel { position: fixed; bottom: 56px; left: 50%; transform: translateX(-50%); width: 440px; max-width: 100%; max-height: 180px; background: rgba(0,0,0,0.92); color: #0f0; font-size: 10px; font-family: monospace; padding: 6px 10px; overflow-y: auto; z-index: 999; border-radius: 8px 8px 0 0; display: none; }
#log-toggle { position: fixed; bottom: 60px; right: 8px; z-index: 1000; background: rgba(0,0,0,0.6); color: #0f0; border: none; padding: 4px 8px; border-radius: 4px; font-size: 10px; font-family: monospace; cursor: pointer; }
</style>
</head>
<body>
<!-- 顶部用户信息 -->
<div class="header">
<div>用户: <span class="phone" id="header-phone">加载中...</span></div>
<div class="org">机构: LESINGLE888888888</div>
</div>
<!-- 内容区 -->
<div class="content" id="content"></div>
<!-- 底部TabBar -->
<div class="tabbar">
<div class="tab-item" data-tab="square" onclick="switchTab('square')">
<div class="tab-icon">🏠</div>
<div class="tab-label">广场</div>
</div>
<div class="tab-item active" data-tab="create" onclick="switchTab('create')">
<div class="tab-icon"></div>
<div class="tab-label">创作</div>
</div>
<div class="tab-item" data-tab="works" onclick="switchTab('works')">
<div class="tab-icon">📚</div>
<div class="tab-label">作品</div>
</div>
<div class="tab-item" data-tab="mine" onclick="switchTab('mine')">
<div class="tab-icon">👤</div>
<div class="tab-label">我的</div>
</div>
</div>
<!-- 日志 -->
<button id="log-toggle" onclick="toggleLog()">LOG</button>
<div id="log-panel"><div id="log"></div></div>
<script>
const urlPhone = new URLSearchParams(window.location.search).get('phone')
const CONFIG = {
ORG_ID: 'LESINGLE888888888',
PHONE: urlPhone || '18911223344',
ENTERPRISE_URL: 'http://192.168.1.72:9090',
API_URL: 'http://192.168.1.72:8080',
H5_URL: 'http://192.168.1.72:3001'
}
let currentToken = ''
let currentTab = 'create'
let logVisible = false
function toggleLog() {
logVisible = !logVisible
document.getElementById('log-panel').style.display = logVisible ? 'block' : 'none'
}
function log(msg, type) {
const t = new Date().toLocaleTimeString()
const color = type === 'recv' ? '#0f0' : type === 'send' ? '#0af' : type === 'warn' ? '#fa0' : '#999'
document.getElementById('log').innerHTML =
`<div style="color:${color}">[${t}] ${msg}</div>` + document.getElementById('log').innerHTML
}
async function exchangeToken() {
// 通过企业后端(9090)换取tokenAPP_SECRET不暴露到前端
const res = await fetch(CONFIG.ENTERPRISE_URL + '/api/refresh-token?phone=' + CONFIG.PHONE)
const data = await res.json()
if (!data.token) throw new Error('token交换失败')
currentToken = data.token
log('Token: ' + currentToken.substring(0, 16) + '...', 'send')
return currentToken
}
function switchTab(tab) {
currentTab = tab
document.querySelectorAll('.tab-item').forEach(t => t.classList.toggle('active', t.dataset.tab === tab))
if (tab === 'create') showCreation()
else if (tab === 'works') showWorks()
else {
const icons = { square: '🏠', mine: '👤' }
const names = { square: '广场', mine: '我的' }
document.getElementById('content').innerHTML =
`<div class="placeholder"><div class="icon">${icons[tab]}</div>${names[tab]}模块(企业自建)</div>`
}
}
async function showCreation(path, from, workId) {
const content = document.getElementById('content')
content.innerHTML = '<div class="placeholder"><div class="icon"></div>加载中...</div>'
try {
const token = await exchangeToken()
const src = CONFIG.H5_URL + (path || '') + '?token=' + encodeURIComponent(token)
+ '&orgId=' + encodeURIComponent(CONFIG.ORG_ID)
+ '&phone=' + encodeURIComponent(CONFIG.PHONE) + '&embed=1'
+ (from ? '&from=' + from : '')
+ (workId ? '&workId=' + encodeURIComponent(workId) : '')
content.innerHTML = `<iframe id="creation-iframe" src="${src}" allow="camera;microphone"></iframe>`
log('iframe: ' + (path || '新建创作'), 'send')
} catch (e) {
content.innerHTML = `<div class="placeholder" style="color:#e55">加载失败: ${e.message}</div>`
}
}
async function showWorks() {
const content = document.getElementById('content')
content.innerHTML = '<div class="placeholder"><div class="icon"></div>加载作品...</div>'
try {
const token = await exchangeToken()
const res = await fetch(CONFIG.API_URL + '/api/v1/query/works?orgId=' + CONFIG.ORG_ID + '&pageSize=50', {
headers: { 'Authorization': 'Bearer ' + token }
})
const data = await res.json()
const records = data.data?.records || []
log('作品: ' + records.length + '个', 'recv')
const statusMap = { '-1': '失败', 1: '排队中', 2: '创作中', 3: '待编目', 4: '待配音', 5: '已完成' }
const actionMap = { 1: '创作中', 2: '创作中', 3: '去编目', 4: '去配音', 5: '查看' }
let html = '<div class="works"><h3>📚 我的作品</h3>'
if (!records.length) {
html += '<div class="placeholder" style="height:auto;padding:40px 0;"><div class="icon">📭</div>暂无作品<br><span style="font-size:12px;">去「创作」模块创建第一个绘本</span></div>'
}
for (const w of records) {
const s = w.status || 0
const sc = 'status-' + (s >= 5 ? 5 : s <= 2 ? 2 : s)
html += `<div class="work-card" onclick="openWork('${w.workId}',${s})">
<img class="work-thumb" src="${w.coverImageUrl || ''}" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2256%22 height=%2256%22><rect fill=%22%23f3f4f6%22 width=%2256%22 height=%2256%22 rx=%228%22/><text x=%2228%22 y=%2232%22 text-anchor=%22middle%22 fill=%22%23ccc%22 font-size=%2220%22>📖</text></svg>'" />
<div class="work-info">
<div class="work-title">${w.title || '未命名'}</div>
<div class="work-meta">${w.workId}</div>
</div>
${s >= 1 && s <= 4
? `<button class="work-btn" onclick="event.stopPropagation();openWork('${w.workId}',${s})">${actionMap[s]}</button>`
: `<div class="work-status ${sc}">${statusMap[s] || '?'}</div>`}
</div>`
}
html += '</div>'
content.innerHTML = html
} catch (e) {
content.innerHTML = `<div class="placeholder" style="color:#e55">加载失败: ${e.message}</div>`
}
}
function openWork(workId, status) {
log('跳转: ' + workId + ' status=' + status, 'send')
const pathMap = { 3: '/edit-info/', 4: '/dubbing/', 5: '/read/' }
if (!pathMap[status] && status !== 2 && status !== 1) return
// status=1/2: 创作进度页通过query传workId让Creating.vue恢复轮询
if (status === 1 || status === 2) {
// 创作中跳creating页额外传workId让H5恢复轮询
showCreation('/creating', 'works', workId)
} else {
showCreation(pathMap[status] + workId, 'works')
}
}
// postMessage 监听
window.addEventListener('message', function(event) {
const msg = event.data
if (!msg || msg.source !== 'leai-creation') return
log('[PM] ' + msg.type + ' ' + JSON.stringify(msg.payload || {}).substring(0, 80), 'recv')
if (msg.type === 'TOKEN_EXPIRED') {
log('Token过期, 刷新中...', 'warn')
exchangeToken().then(function(t) {
const f = document.getElementById('creation-iframe')
if (f && f.contentWindow) {
f.contentWindow.postMessage({
source: 'leai-creation', version: 1, type: 'TOKEN_REFRESHED',
payload: { messageId: msg.payload.messageId, token: t, orgId: CONFIG.ORG_ID, phone: CONFIG.PHONE }
}, '*')
log('Token已刷新', 'send')
}
}).catch(e => log('刷新失败: ' + e, 'warn'))
}
if (msg.type === 'NAVIGATE_BACK') {
log('H5请求返回 → 切到作品列表', 'recv')
switchTab('works')
}
})
document.getElementById('header-phone').textContent = CONFIG.PHONE
switchTab('create')
log('启动 | ' + CONFIG.ORG_ID + ' | ' + CONFIG.PHONE, 'info')
</script>
</body></html>

View File

@ -0,0 +1,65 @@
@echo off
setlocal enabledelayedexpansion
title LeAI Enterprise Demo (Port 9090)
echo ========================================================
echo LeAI Enterprise Demo - Port 9090
echo Auth callback + Webhook + iframe embed
echo ========================================================
echo.
:: --- JDK ---
set "JAVA_HOME=M:\SDK\jdk8u482-b08"
if not exist "%JAVA_HOME%\bin\java.exe" (
echo [ERROR] JDK not found: %JAVA_HOME%
echo Please edit JAVA_HOME in this script
pause
exit /b 1
)
set "PATH=%JAVA_HOME%\bin;%PATH%"
echo [OK] JDK: %JAVA_HOME%
:: --- Kill port 9090 ---
for /f "tokens=5" %%a in ('netstat -aon ^| findstr ":9090 " ^| findstr "LISTENING"') do (
echo [!] Killing PID %%a on port 9090
taskkill /F /PID %%a >nul 2>&1
)
:: --- Maven build ---
echo.
echo [*] Building with Maven...
if exist "mvnw.cmd" (
call mvnw.cmd clean package -DskipTests -q
) else (
call mvn clean package -DskipTests -q
)
:: --- Find JAR ---
set "JAR_FILE="
for /f "delims=" %%f in ('dir /b /s target\*.jar 2^>nul ^| findstr /v "original"') do (
set "JAR_FILE=%%f"
)
if "!JAR_FILE!"=="" (
echo [ERROR] JAR file not found. Build may have failed.
pause
exit /b 1
)
echo [OK] JAR: !JAR_FILE!
:: --- Start ---
echo.
echo ========================================================
echo Starting Enterprise Demo...
echo.
echo Home: http://192.168.1.72:9090
echo iframe: http://192.168.1.72:9090/enterprise-sim.html
echo Auth: http://192.168.1.72:9090/leai-auth
echo Webhook: http://192.168.1.72:9090/webhook/leai
echo.
echo Requires: LeAI backend(8080) + H5 frontend(3001)
echo ========================================================
echo.
java -jar -Xms128m -Xmx256m "!JAR_FILE!"
pause

View File

@ -0,0 +1,235 @@
<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>企业模拟C端</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, 'Segoe UI', sans-serif; background: #f5f5f5; height: 100vh; display: flex; flex-direction: column; max-width: 450px; margin: 0 auto; position: relative; }
/* 顶部信息栏 */
.header { background: white; padding: 8px 16px; border-bottom: 1px solid #eee; font-size: 12px; color: #666; line-height: 1.6; flex-shrink: 0; }
.header .phone { color: #7C3AED; font-weight: 600; }
.header .org { color: #999; font-size: 11px; }
/* 内容区 */
.content { flex: 1; overflow: hidden; position: relative; }
/* 广场/我的 占位 */
.placeholder { display: flex; align-items: center; justify-content: center; height: 100%; color: #bbb; font-size: 16px; flex-direction: column; gap: 8px; }
.placeholder .icon { font-size: 40px; }
/* 创作模块 iframe */
#creation-iframe { width: 100%; height: 100%; border: none; }
/* 作品列表 */
.works { padding: 16px; overflow-y: auto; height: 100%; }
.works h3 { color: #333; margin-bottom: 12px; font-size: 16px; }
.work-card { background: white; border-radius: 12px; padding: 12px; margin-bottom: 10px; display: flex; align-items: center; gap: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.work-thumb { width: 56px; height: 56px; border-radius: 8px; background: #f3f4f6; object-fit: cover; flex-shrink: 0; }
.work-info { flex: 1; min-width: 0; }
.work-title { font-size: 14px; font-weight: 600; color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.work-meta { font-size: 11px; color: #bbb; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.work-status { padding: 3px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; white-space: nowrap; flex-shrink: 0; }
.status-3 { background: #FEF3C7; color: #D97706; }
.status-4 { background: #DBEAFE; color: #2563EB; }
.status-5 { background: #D1FAE5; color: #059669; }
.status-2 { background: #FFF7ED; color: #EA580C; }
.work-btn { padding: 5px 12px; background: #7C3AED; color: white; border: none; border-radius: 14px; cursor: pointer; font-size: 11px; font-weight: 600; flex-shrink: 0; }
/* 底部TabBar */
.tabbar { display: flex; background: white; border-top: 1px solid #eee; flex-shrink: 0; padding-bottom: env(safe-area-inset-bottom, 0); }
.tabbar .tab-item { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 6px 0 4px; cursor: pointer; transition: color 0.2s; color: #999; }
.tabbar .tab-item.active { color: #7C3AED; }
.tabbar .tab-icon { font-size: 22px; line-height: 1; }
.tabbar .tab-label { font-size: 10px; margin-top: 2px; font-weight: 500; }
/* 日志面板(可折叠) */
#log-panel { position: fixed; bottom: 56px; left: 50%; transform: translateX(-50%); width: 440px; max-width: 100%; max-height: 180px; background: rgba(0,0,0,0.92); color: #0f0; font-size: 10px; font-family: monospace; padding: 6px 10px; overflow-y: auto; z-index: 999; border-radius: 8px 8px 0 0; display: none; }
#log-toggle { position: fixed; bottom: 60px; right: 8px; z-index: 1000; background: rgba(0,0,0,0.6); color: #0f0; border: none; padding: 4px 8px; border-radius: 4px; font-size: 10px; font-family: monospace; cursor: pointer; }
</style>
</head>
<body>
<!-- 顶部用户信息 -->
<div class="header">
<div>用户: <span class="phone">18911223344</span></div>
<div class="org">机构: LESINGLE888888888</div>
</div>
<!-- 内容区 -->
<div class="content" id="content"></div>
<!-- 底部TabBar -->
<div class="tabbar">
<div class="tab-item" data-tab="square" onclick="switchTab('square')">
<div class="tab-icon">🏠</div>
<div class="tab-label">广场</div>
</div>
<div class="tab-item active" data-tab="create" onclick="switchTab('create')">
<div class="tab-icon"></div>
<div class="tab-label">创作</div>
</div>
<div class="tab-item" data-tab="works" onclick="switchTab('works')">
<div class="tab-icon">📚</div>
<div class="tab-label">作品</div>
</div>
<div class="tab-item" data-tab="mine" onclick="switchTab('mine')">
<div class="tab-icon">👤</div>
<div class="tab-label">我的</div>
</div>
</div>
<!-- 日志 -->
<button id="log-toggle" onclick="toggleLog()">LOG</button>
<div id="log-panel"><div id="log"></div></div>
<script>
const CONFIG = {
ORG_ID: 'LESINGLE888888888',
PHONE: '18911223344',
ENTERPRISE_URL: 'http://192.168.1.72:9090', // 企业后端本Demo
API_URL: 'http://192.168.1.72:8080', // 乐读派后端(仅作品列表查询用)
H5_URL: 'http://192.168.1.72:3001' // 乐读派H5前端
// 注意: APP_SECRET 不再出现在前端,仅在企业后端(9090)中使用
}
let currentToken = ''
let currentTab = 'create'
let logVisible = false
function toggleLog() {
logVisible = !logVisible
document.getElementById('log-panel').style.display = logVisible ? 'block' : 'none'
}
function log(msg, type) {
const t = new Date().toLocaleTimeString()
const color = type === 'recv' ? '#0f0' : type === 'send' ? '#0af' : type === 'warn' ? '#fa0' : '#999'
document.getElementById('log').innerHTML =
`<div style="color:${color}">[${t}] ${msg}</div>` + document.getElementById('log').innerHTML
}
async function exchangeToken() {
// 通过企业后端(9090)换取tokenAPP_SECRET不暴露到前端
const res = await fetch(CONFIG.ENTERPRISE_URL + '/api/refresh-token?phone=' + CONFIG.PHONE)
const data = await res.json()
if (!data.token) throw new Error('token交换失败')
currentToken = data.token
log('Token: ' + currentToken.substring(0, 16) + '...', 'send')
return currentToken
}
function switchTab(tab) {
currentTab = tab
document.querySelectorAll('.tab-item').forEach(t => t.classList.toggle('active', t.dataset.tab === tab))
if (tab === 'create') showCreation()
else if (tab === 'works') showWorks()
else {
const icons = { square: '🏠', mine: '👤' }
const names = { square: '广场', mine: '我的' }
document.getElementById('content').innerHTML =
`<div class="placeholder"><div class="icon">${icons[tab]}</div>${names[tab]}模块(企业自建)</div>`
}
}
async function showCreation(path, from, workId) {
const content = document.getElementById('content')
content.innerHTML = '<div class="placeholder"><div class="icon"></div>加载中...</div>'
try {
const token = await exchangeToken()
const src = CONFIG.H5_URL + (path || '') + '?token=' + encodeURIComponent(token)
+ '&orgId=' + encodeURIComponent(CONFIG.ORG_ID)
+ '&phone=' + encodeURIComponent(CONFIG.PHONE) + '&embed=1'
+ (from ? '&from=' + from : '')
+ (workId ? '&workId=' + encodeURIComponent(workId) : '')
content.innerHTML = `<iframe id="creation-iframe" src="${src}" allow="camera;microphone"></iframe>`
log('iframe: ' + (path || '新建创作'), 'send')
} catch (e) {
content.innerHTML = `<div class="placeholder" style="color:#e55">加载失败: ${e.message}</div>`
}
}
async function showWorks() {
const content = document.getElementById('content')
content.innerHTML = '<div class="placeholder"><div class="icon"></div>加载作品...</div>'
try {
const token = await exchangeToken()
const res = await fetch(CONFIG.API_URL + '/api/v1/query/works?orgId=' + CONFIG.ORG_ID + '&pageSize=50', {
headers: { 'Authorization': 'Bearer ' + token }
})
const data = await res.json()
const records = data.data?.records || []
log('作品: ' + records.length + '个', 'recv')
const statusMap = { '-1': '失败', 1: '排队中', 2: '创作中', 3: '待编目', 4: '待配音', 5: '已完成' }
const actionMap = { 1: '创作中', 2: '创作中', 3: '去编目', 4: '去配音', 5: '查看' }
let html = '<div class="works"><h3>📚 我的作品</h3>'
if (!records.length) {
html += '<div class="placeholder" style="height:auto;padding:40px 0;"><div class="icon">📭</div>暂无作品<br><span style="font-size:12px;">去「创作」模块创建第一个绘本</span></div>'
}
for (const w of records) {
const s = w.status || 0
const sc = 'status-' + (s >= 5 ? 5 : s <= 2 ? 2 : s)
html += `<div class="work-card" onclick="openWork('${w.workId}',${s})">
<img class="work-thumb" src="${w.coverImageUrl || ''}" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2256%22 height=%2256%22><rect fill=%22%23f3f4f6%22 width=%2256%22 height=%2256%22 rx=%228%22/><text x=%2228%22 y=%2232%22 text-anchor=%22middle%22 fill=%22%23ccc%22 font-size=%2220%22>📖</text></svg>'" />
<div class="work-info">
<div class="work-title">${w.title || '未命名'}</div>
<div class="work-meta">${w.workId}</div>
</div>
${s >= 1 && s <= 4
? `<button class="work-btn" onclick="event.stopPropagation();openWork('${w.workId}',${s})">${actionMap[s]}</button>`
: `<div class="work-status ${sc}">${statusMap[s] || '?'}</div>`}
</div>`
}
html += '</div>'
content.innerHTML = html
} catch (e) {
content.innerHTML = `<div class="placeholder" style="color:#e55">加载失败: ${e.message}</div>`
}
}
function openWork(workId, status) {
log('跳转: ' + workId + ' status=' + status, 'send')
const pathMap = { 3: '/edit-info/', 4: '/dubbing/', 5: '/read/' }
if (!pathMap[status] && status !== 2 && status !== 1) return
// status=1/2: 创作进度页通过query传workId让Creating.vue恢复轮询
if (status === 1 || status === 2) {
// 创作中跳creating页额外传workId让H5恢复轮询
showCreation('/creating', 'works', workId)
} else {
showCreation(pathMap[status] + workId, 'works')
}
}
// postMessage 监听
window.addEventListener('message', function(event) {
const msg = event.data
if (!msg || msg.source !== 'leai-creation') return
log('[PM] ' + msg.type + ' ' + JSON.stringify(msg.payload || {}).substring(0, 80), 'recv')
if (msg.type === 'TOKEN_EXPIRED') {
log('Token过期, 刷新中...', 'warn')
exchangeToken().then(function(t) {
const f = document.getElementById('creation-iframe')
if (f && f.contentWindow) {
f.contentWindow.postMessage({
source: 'leai-creation', version: 1, type: 'TOKEN_REFRESHED',
payload: { messageId: msg.payload.messageId, token: t, orgId: CONFIG.ORG_ID, phone: CONFIG.PHONE }
}, '*')
log('Token已刷新', 'send')
}
}).catch(e => log('刷新失败: ' + e, 'warn'))
}
if (msg.type === 'NAVIGATE_BACK') {
log('H5请求返回 → 切到作品列表', 'recv')
switchTab('works')
}
})
switchTab('create')
log('启动 | ' + CONFIG.ORG_ID + ' | ' + CONFIG.PHONE, 'info')
</script>
</body></html>

View File

@ -0,0 +1,113 @@
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>iframe嵌入测试</title>
<style>
body { margin: 0; font-family: sans-serif; }
.bar { background: #7C3AED; color: white; padding: 10px 20px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.bar button { background: white; color: #7C3AED; border: none; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 13px; }
.bar button:hover { background: #f0f0f0; }
.bar .label { font-size: 12px; opacity: 0.8; }
#status { font-size: 12px; margin-left: auto; }
iframe { width: 100%; height: calc(100vh - 46px); border: none; }
#log { position: fixed; bottom: 0; right: 0; width: 450px; max-height: 220px; overflow-y: auto; background: rgba(0,0,0,0.9); color: #0f0; font-size: 11px; padding: 8px; font-family: monospace; z-index: 999; border-radius: 8px 0 0 0; }
</style></head>
<body>
<div class="bar">
<span class="label">企业iframe测试:</span>
<button onclick="loadPage('')">新建创作</button>
<button onclick="loadPage('/edit-info/2041081353944043520')">编目(status=3)</button>
<button onclick="loadPage('/dubbing/2040041094040915968')">配音(status=4)</button>
<button onclick="expireToken()">模拟token过期</button>
<span id="status">等待加载...</span>
</div>
<iframe id="leai" allow="camera;microphone"></iframe>
<div id="log"></div>
<script>
const H5 = 'http://localhost:3001';
const API = 'http://localhost:8080';
const ORG_ID = 'LESINGLE888888888';
const APP_SECRET = 'leai_test_secret_2026_abc123xyz';
const PHONE = '18911223344';
let currentToken = '';
function log(msg) {
const t = new Date().toLocaleTimeString();
document.getElementById('log').innerHTML = '<div>[' + t + '] ' + msg + '</div>' + document.getElementById('log').innerHTML;
}
// 换取token模拟企业后端
async function getToken() {
const res = await fetch(API + '/api/v1/auth/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orgId: ORG_ID, appSecret: APP_SECRET, phone: PHONE })
});
const data = await res.json();
currentToken = data.data.sessionToken;
log('Token获取: ' + currentToken.substring(0, 20) + '...');
return currentToken;
}
// 加载iframe页面
async function loadPage(path) {
const token = await getToken();
const url = H5 + path + '?token=' + token + '&orgId=' + ORG_ID + '&phone=' + PHONE + '&embed=1';
document.getElementById('leai').src = url;
document.getElementById('status').textContent = '加载: ' + (path || '新建创作');
log('iframe.src = ' + path + '?token=...');
}
// 模拟token过期删除Redis中的token
async function expireToken() {
if (!currentToken) { log('还没有token'); return; }
try {
// 直接调一个需要认证的接口用一个假token触发过期
// 实际是通过Redis DEL但这里我们通过修改store中的token来模拟
document.getElementById('leai').contentWindow.postMessage({
source: 'test', type: 'SIMULATE_EXPIRE'
}, '*');
log('已发送模拟过期指令需手动redis-cli DEL session:token:' + currentToken + '');
document.getElementById('status').textContent = '已模拟过期';
} catch(e) { log('模拟过期失败: ' + e); }
}
// 监听H5的postMessage
window.addEventListener('message', function(event) {
var msg = event.data;
if (!msg || msg.source !== 'leai-creation') return;
log('<b>收到: ' + msg.type + '</b> ' + JSON.stringify(msg.payload || {}));
switch (msg.type) {
case 'READY':
document.getElementById('status').textContent = 'H5已就绪';
break;
case 'TOKEN_EXPIRED':
document.getElementById('status').textContent = '刷新Token...';
log('★ Token过期! 正在刷新...');
getToken().then(function(newToken) {
document.getElementById('leai').contentWindow.postMessage({
source: 'leai-creation', version: 1, type: 'TOKEN_REFRESHED',
payload: { messageId: msg.payload.messageId, token: newToken, orgId: ORG_ID, phone: PHONE }
}, '*');
document.getElementById('status').textContent = 'Token已刷新';
log('★ Token刷新成功已回传');
}).catch(function(e) { log('Token刷新失败: ' + e); });
break;
case 'WORK_CREATED':
document.getElementById('status').textContent = '创作中: ' + msg.payload.workId;
break;
case 'WORK_COMPLETED':
document.getElementById('status').textContent = '完成: ' + msg.payload.workId;
log('★★★ 创作完成! workId=' + msg.payload.workId);
break;
case 'CREATION_ERROR':
document.getElementById('status').textContent = '失败';
log('创作失败: ' + msg.payload.message);
break;
}
});
// 初始加载新建创作
loadPage('');
</script>
</body></html>

View File

@ -9,6 +9,17 @@
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script src="./config.js"></script>
<script>
(function() {
var c = (window.__LEAI_CONFIG__ || {}).brand || {};
if (c.title) document.title = c.title + (c.subtitle ? ' - ' + c.subtitle : '');
if (c.favicon) {
var link = document.querySelector("link[rel='icon']");
if (link) link.href = c.favicon;
}
})();
</script>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --port 3001",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview"
}, },

1863
lesingle-aicreate-client/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,44 @@
/**
* 乐读派 AI 创作系统 运行时配置
* 企业上线只需修改此文件无需重新编译
*/
window.__LEAI_CONFIG__ = {
// ━━━ 认证模式(决定使用哪组服务地址)━━━━━━━━━
// "hmac" = 开发调试HMAC签名 + 局域网地址) ← 默认
// "token" = 生产环境Bearer Token + 正式地址)
authMode: "token",
// ━━━ 开发环境配置authMode=hmac 时生效)━━━━━
dev: {
apiBaseUrl: "http://192.168.1.72:8080",
wsBaseUrl: "ws://192.168.1.72:8080",
orgId: "LESINGLE888888888",
appSecret: "", // 开发时填入生产环境不需要token模式不使用
phone: "18911223344"
},
// ━━━ 生产环境配置authMode=token 时生效)━━━━
// orgId/phone 由企业重定向URL参数动态传入无需在此配置
// appSecret 仅在企业服务端使用,绝不到达浏览器
prod: {
apiBaseUrl: "http://192.168.1.72:8080",
wsBaseUrl: "ws://192.168.1.72:8080"
},
// ━━━ 品牌定制(两种模式通用)━━━━━━━━━━━━━━━━━
brand: {
title: "乐读派",
subtitle: "AI智能儿童绘本创作",
slogan: "让想象力飞翔",
favicon: "/favicon.ico"
},
// ━━━ 嵌入模式iframe集成时启用━━━━━━━━━━━━━
// "standalone" = 独立页面模式默认redirect认证
// "iframe" = iframe嵌入模式postMessage通信
embedMode: "iframe",
// iframe模式下允许的父页面域名安全校验用
// 例: ["https://enterprise.com", "https://admin.enterprise.com"]
parentOrigins: []
}

View File

@ -2,9 +2,11 @@ import axios from 'axios'
import OSS from 'ali-oss' import OSS from 'ali-oss'
import { signRequest } from '@/utils/hmac' import { signRequest } from '@/utils/hmac'
import { store } from '@/utils/store' import { store } from '@/utils/store'
import config from '@/utils/config'
import bridge from '@/utils/bridge'
const api = axios.create({ const api = axios.create({
baseURL: '/api/v1', baseURL: config.apiBaseUrl ? config.apiBaseUrl + '/api/v1' : '/api/v1',
timeout: 120000 timeout: 120000
}) })
@ -27,15 +29,96 @@ api.interceptors.request.use(config => {
return config return config
}) })
// ─── Token 刷新状态管理(双模式)───
let isRefreshing = false
let pendingRequests = []
function handleTokenExpired_standalone() {
if (isRefreshing) return
isRefreshing = true
store.saveRecoveryState()
store.clearSession()
const redirectUrl = store.authRedirectUrl
if (redirectUrl) {
const returnPath = encodeURIComponent(window.location.pathname || '/')
window.location.href = redirectUrl + (redirectUrl.includes('?') ? '&' : '?')
+ 'returnPath=' + returnPath + '&orgId=' + encodeURIComponent(store.orgId)
} else {
window.location.href = '/'
}
setTimeout(() => { isRefreshing = false }, 3000)
}
function handleTokenExpired_iframe(failedConfig) {
if (!isRefreshing) {
isRefreshing = true
bridge.requestTokenRefresh()
.then(({ token, orgId, phone }) => {
store.setSession(orgId || store.orgId, token)
if (phone) store.setPhone(phone)
isRefreshing = false
pendingRequests.forEach(cb => cb(token))
pendingRequests = []
})
.catch(() => {
isRefreshing = false
pendingRequests.forEach(cb => cb(null))
pendingRequests = []
})
}
return new Promise((resolve, reject) => {
// 队列上限防止内存泄漏
if (pendingRequests.length >= 20) {
reject(new Error('TOO_MANY_PENDING_REQUESTS'))
return
}
pendingRequests.push(newToken => {
if (newToken) {
// 防止无限重试标记已重试最多重试1次
if (failedConfig.__retried) {
reject(new Error('TOKEN_REFRESH_FAILED'))
return
}
failedConfig.__retried = true
failedConfig.headers['Authorization'] = 'Bearer ' + newToken
// 清除可能残留的HMAC头
delete failedConfig.headers['X-App-Key']
delete failedConfig.headers['X-Timestamp']
delete failedConfig.headers['X-Nonce']
delete failedConfig.headers['X-Signature']
resolve(api(failedConfig))
} else {
reject(new Error('TOKEN_REFRESH_FAILED'))
}
})
})
}
api.interceptors.response.use( api.interceptors.response.use(
res => { res => {
const d = res.data const d = res.data
if (d?.code !== 0 && d?.code !== 200) { if (d?.code !== 0 && d?.code !== 200) {
if (config.isTokenMode && (d?.code === 20010 || d?.code === 20009)) {
if (bridge.isEmbedded) {
return handleTokenExpired_iframe(res.config)
}
handleTokenExpired_standalone()
return Promise.reject(new Error('TOKEN_EXPIRED'))
}
return Promise.reject(new Error(d?.msg || '请求失败')) return Promise.reject(new Error(d?.msg || '请求失败'))
} }
return d return d
}, },
err => Promise.reject(err) err => {
if (config.isTokenMode && err.response?.status === 401) {
if (bridge.isEmbedded) {
return handleTokenExpired_iframe(err.config)
}
handleTokenExpired_standalone()
return Promise.reject(new Error('TOKEN_EXPIRED'))
}
return Promise.reject(err)
}
) )
// ─── 图片上传 ─── // ─── 图片上传 ───
@ -104,11 +187,21 @@ export function checkQuota() {
}) })
} }
// ─── C1 编辑绘本信息 ─── // ─── C1 编辑绘本信息(推进状态 COMPLETED→CATALOGED ───
export function updateWork(workId, data) { export function updateWork(workId, data) {
return api.put(`/update/work/${workId}`, data) return api.put(`/update/work/${workId}`, data)
} }
// ─── C2 完成配音(推进状态 CATALOGED→DUBBED允许空 pages ───
export function finishDubbing(workId) {
return api.post('/update/batch-audio', {
orgId: store.orgId,
phone: store.phone,
workId,
pages: []
})
}
// ─── A20 AI配音 ─── // ─── A20 AI配音 ───
export function voicePage(data) { export function voicePage(data) {
return api.post('/creation/voice', { return api.post('/creation/voice', {
@ -203,4 +296,10 @@ export async function ossListFiles() {
})) }))
} }
export async function getOrgConfig(orgId) {
const baseUrl = config.apiBaseUrl || ''
const res = await axios.get(baseUrl + '/api/v1/query/org-config', { params: { orgId } })
return res.data
}
export default api export default api

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="page-header"> <div class="page-header">
<div class="header-row"> <div class="header-row">
<div v-if="showBack" class="back-btn" @click="$router.back()"> <div v-if="showBack" class="back-btn" @click="handleBack">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M15 18l-6-6 6-6"/></svg> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M15 18l-6-6 6-6"/></svg>
</div> </div>
<div class="header-text"> <div class="header-text">
@ -14,7 +14,12 @@
</template> </template>
<script setup> <script setup>
import { useRouter } from 'vue-router'
import bridge from '@/utils/bridge'
import StepBar from './StepBar.vue' import StepBar from './StepBar.vue'
const router = useRouter()
defineProps({ defineProps({
title: String, title: String,
subtitle: String, subtitle: String,
@ -22,6 +27,17 @@ defineProps({
step: { type: Number, default: null }, step: { type: Number, default: null },
totalSteps: { type: Number, default: 6 } totalSteps: { type: Number, default: 6 }
}) })
function handleBack() {
// iframe
const from = new URLSearchParams(window.location.search).get('from')
|| sessionStorage.getItem('le_from')
if (bridge.isEmbedded && from === 'works') {
bridge.send('NAVIGATE_BACK')
return
}
router.back()
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -3,4 +3,9 @@ import App from './App.vue'
import router from './router' import router from './router'
import './assets/global.scss' import './assets/global.scss'
import bridge from '@/utils/bridge'
createApp(App).use(router).mount('#app') createApp(App).use(router).mount('#app')
// 通知父页面 H5 已加载完毕iframe 模式下生效)
bridge.send('READY')

View File

@ -1,4 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import config from '@/utils/config'
import { store } from '@/utils/store'
import bridge from '@/utils/bridge'
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
@ -62,18 +65,59 @@ const router = createRouter({
] ]
}) })
// Auth guard: 双模式检查sessionToken 或 appSecret 任一存在即可) // Auth guard: 全局 token 初始化 + 双模式认证检查
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
if (to.meta.noAuth || to.name === 'Welcome') { // 持久化 from 参数(所有页面通用,含 noAuth 页面)
const sp = new URLSearchParams(window.location.search)
const urlFrom = sp.get('from')
if (urlFrom) sessionStorage.setItem('le_from', urlFrom)
if (to.meta.noAuth) {
next() next()
return return
} }
const hasSession = sessionStorage.getItem('le_sessionToken')
const hasHmac = localStorage.getItem('le_appSecret') // ─── 全局 token 初始化:从 URL query 读取 ───
if (!hasSession && !hasHmac) { // 支持 iframe src 直接带 token 加载任意页面
next({ name: 'Welcome' }) // 如: /edit-info/190xxx?token=xxx&orgId=xxx&phone=xxx&embed=1
} else { const searchParams = new URLSearchParams(window.location.search)
const urlToken = searchParams.get('token')
const urlOrgId = searchParams.get('orgId')
const urlPhone = searchParams.get('phone')
if (urlToken && urlOrgId && !store.sessionToken) {
store.setSession(urlOrgId, urlToken)
if (urlPhone) store.setPhone(urlPhone)
window.history.replaceState({}, '', window.location.pathname + window.location.hash)
}
// hash 路由参数兼容hash模式下参数在 #/?token=xxx 中)
if (!store.sessionToken && !store.appSecret) {
const hashQuery = window.location.hash.split('?')[1] || ''
if (hashQuery) {
const hp = new URLSearchParams(hashQuery)
const hToken = hp.get('token')
const hOrgId = hp.get('orgId')
const hPhone = hp.get('phone')
if (hToken && hOrgId) {
store.setSession(hOrgId, hToken)
if (hPhone) store.setPhone(hPhone)
window.history.replaceState({}, '', window.location.pathname + '#' + to.path)
}
}
}
// ─── 认证检查 ───
if (to.name === 'Welcome') {
next() next()
return
}
// 有 sessionToken 或 appSecret 任一即可通过
if (store.sessionToken || store.appSecret) {
next()
} else {
next({ name: 'Welcome' })
} }
}) })

View File

@ -0,0 +1,71 @@
/**
* iframe postMessage 通信桥
* 封装 H5 与企业父页面的双向通信
*/
import config from './config'
const SOURCE = 'leai-creation'
const VERSION = 1
const TIMEOUT = 30000
const pendingCallbacks = new Map()
export const isEmbedded = config.isEmbedded
// 目标 origin配置了白名单用第一个未配置用 '*'
const targetOrigin = config.parentOrigins.length > 0 ? config.parentOrigins[0] : '*'
export function send(type, payload = {}) {
if (!isEmbedded) return
window.parent.postMessage({ source: SOURCE, version: VERSION, type, payload }, targetOrigin)
}
export function request(type, payload = {}) {
if (!isEmbedded) return Promise.reject(new Error('Not in iframe'))
const messageId = 'msg_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8)
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pendingCallbacks.delete(messageId)
reject(new Error('TIMEOUT'))
}, TIMEOUT)
pendingCallbacks.set(messageId, { resolve, reject, timer })
send(type, { ...payload, messageId })
})
}
export function requestTokenRefresh() {
return request('TOKEN_EXPIRED')
}
function onMessage(event) {
if (config.parentOrigins.length > 0 && !config.parentOrigins.includes(event.origin)) {
return
}
const msg = event.data
if (!msg || msg.source !== SOURCE) return
switch (msg.type) {
case 'TOKEN_REFRESHED': {
const messageId = msg.payload?.messageId
const cb = pendingCallbacks.get(messageId)
if (cb) {
clearTimeout(cb.timer)
pendingCallbacks.delete(messageId)
cb.resolve(msg.payload)
}
break
}
case 'INIT': {
if (msg.payload?.token && window.__LEAI_INIT_CALLBACK__) {
window.__LEAI_INIT_CALLBACK__(msg.payload)
}
break
}
}
}
if (isEmbedded) {
window.addEventListener('message', onMessage)
}
export default { isEmbedded, send, request, requestTokenRefresh }

View File

@ -0,0 +1,22 @@
/**
* 运行时配置读取工具
* window.__LEAI_CONFIG__public/config.js读取配置
*/
const raw = window.__LEAI_CONFIG__ || {}
const authMode = raw.authMode || 'hmac'
const isTokenMode = authMode === 'token'
const env = isTokenMode ? (raw.prod || {}) : (raw.dev || {})
const config = {
authMode,
isTokenMode,
apiBaseUrl: env.apiBaseUrl || '',
wsBaseUrl: env.wsBaseUrl || '',
brand: raw.brand || { title: '乐读派', subtitle: 'AI智能儿童绘本创作', slogan: '让想象力飞翔' },
dev: isTokenMode ? null : (raw.dev || {}),
embedMode: raw.embedMode || 'standalone',
parentOrigins: raw.parentOrigins || [],
isEmbedded: (raw.embedMode === 'iframe') || (window.self !== window.top)
}
export default config

View File

@ -0,0 +1,42 @@
/**
* V4.0 作品状态常量数值型
*
* 状态流转: PENDING(1) -> PROCESSING(2) -> COMPLETED(3) -> CATALOGED(4) -> DUBBED(5)
* 任意阶段可能 -> FAILED(-1)
*
* COMPLETED(3) = 图片生成完成需要进入编目(EditInfo)
* CATALOGED(4) = 编目完成需要进入配音(Dubbing)
* DUBBED(5) = 配音完成最终状态可阅读
*/
export const STATUS = {
FAILED: -1,
PENDING: 1,
PROCESSING: 2,
COMPLETED: 3, // 图片完成,待编目
CATALOGED: 4, // 编目完成,待配音
DUBBED: 5 // 配音完成(最终态)
}
/**
* 根据作品状态决定应导航到的路由
* @param {number} status - 作品状态值
* @param {string} workId - 作品ID
* @returns {{ name: string, params?: object } | null} 路由对象null 表示状态异常
*/
export function getRouteByStatus(status, workId) {
switch (status) {
case STATUS.PENDING:
case STATUS.PROCESSING:
return { name: 'Creating' }
case STATUS.COMPLETED:
return { name: 'Preview', params: { workId } }
case STATUS.CATALOGED:
return { name: 'Dubbing', params: { workId } }
case STATUS.DUBBED:
return { name: 'Read', params: { workId } }
case STATUS.FAILED:
return null // 调用方自行处理失败提示
default:
return null
}
}

View File

@ -48,6 +48,7 @@ export const store = reactive({
setSession(orgId, sessionToken) { setSession(orgId, sessionToken) {
this.orgId = orgId this.orgId = orgId
this.sessionToken = sessionToken this.sessionToken = sessionToken
localStorage.setItem('le_orgId', orgId)
sessionStorage.setItem('le_orgId', orgId) sessionStorage.setItem('le_orgId', orgId)
sessionStorage.setItem('le_sessionToken', sessionToken) sessionStorage.setItem('le_sessionToken', sessionToken)
}, },
@ -62,5 +63,46 @@ export const store = reactive({
this.workId = '' this.workId = ''
this.workDetail = null this.workDetail = null
localStorage.removeItem('le_workId') localStorage.removeItem('le_workId')
},
// 企业认证回调URL
authRedirectUrl: '',
clearSession() {
this.sessionToken = ''
sessionStorage.removeItem('le_sessionToken')
},
saveRecoveryState() {
const recovery = {
path: window.location.pathname || '/',
workId: this.workId || localStorage.getItem('le_workId') || '',
imageUrl: this.imageUrl || '',
extractId: this.extractId || '',
selectedStyle: this.selectedStyle || '',
savedAt: Date.now()
}
sessionStorage.setItem('le_recovery', JSON.stringify(recovery))
},
restoreRecoveryState() {
const raw = sessionStorage.getItem('le_recovery')
if (!raw) return null
try {
const recovery = JSON.parse(raw)
if (Date.now() - recovery.savedAt > 30 * 60 * 1000) {
sessionStorage.removeItem('le_recovery')
return null
}
if (recovery.workId) this.workId = recovery.workId
if (recovery.imageUrl) this.imageUrl = recovery.imageUrl
if (recovery.extractId) this.extractId = recovery.extractId
if (recovery.selectedStyle) this.selectedStyle = recovery.selectedStyle
sessionStorage.removeItem('le_recovery')
return recovery
} catch {
sessionStorage.removeItem('le_recovery')
return null
}
} }
}) })

View File

@ -2,6 +2,9 @@
<div class="reader-page" @touchstart="onTouchStart" @touchend="onTouchEnd"> <div class="reader-page" @touchstart="onTouchStart" @touchend="onTouchEnd">
<!-- 顶栏 --> <!-- 顶栏 -->
<div class="reader-top"> <div class="reader-top">
<div v-if="fromWorks" class="back-btn" @click="handleBack">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round"><path d="M15 18l-6-6 6-6"/></svg>
</div>
<div class="top-title">{{ title }}</div> <div class="top-title">{{ title }}</div>
</div> </div>
@ -23,7 +26,7 @@
<div class="cover-image" v-else>📖</div> <div class="cover-image" v-else>📖</div>
<div class="cover-title">{{ currentPage.text }}</div> <div class="cover-title">{{ currentPage.text }}</div>
<div class="cover-divider" /> <div class="cover-divider" />
<div class="cover-brand">乐读派 AI 绘本</div> <div class="cover-brand">{{ brandName }} AI 绘本</div>
<div v-if="authorDisplay" class="cover-author"> {{ authorDisplay }}</div> <div v-if="authorDisplay" class="cover-author"> {{ authorDisplay }}</div>
</div> </div>
@ -50,7 +53,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 105.64-8.36L1 10"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 105.64-8.36L1 10"/></svg>
重新阅读 重新阅读
</div> </div>
<div class="back-brand">乐读派 AI 绘本 · 让想象力飞翔</div> <div class="back-brand">{{ brandName }} AI 绘本 · {{ brandSlogan }}</div>
</div> </div>
</div> </div>
@ -71,8 +74,8 @@
</div> </div>
</div> </div>
<!-- 底部再次创作 --> <!-- 底部再次创作仅创作流程入口显示作品列表入口不显示 -->
<div class="reader-bottom safe-bottom"> <div v-if="!fromWorks" class="reader-bottom safe-bottom">
<button class="btn-primary" @click="goHome">再次创作 </button> <button class="btn-primary" @click="goHome">再次创作 </button>
<div class="bottom-hint">本作品可在作品板块中查看</div> <div class="bottom-hint">本作品可在作品板块中查看</div>
</div> </div>
@ -84,9 +87,25 @@ import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { store } from '@/utils/store' import { store } from '@/utils/store'
import { getWorkDetail } from '@/api' import { getWorkDetail } from '@/api'
import config from '@/utils/config'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const fromWorks = new URLSearchParams(window.location.search).get('from') === 'works'
|| sessionStorage.getItem('le_from') === 'works'
import bridge from '@/utils/bridge'
function handleBack() {
if (bridge.isEmbedded) {
bridge.send('NAVIGATE_BACK')
} else {
router.back()
}
}
const brandName = config.brand.title || '乐读派'
const brandSlogan = config.brand.slogan || '让想象力飞翔'
const idx = ref(0) const idx = ref(0)
const flipDir = ref(0) const flipDir = ref(0)
@ -201,6 +220,7 @@ onMounted(async () => {
background: rgba(255,255,255,0.85); background: rgba(255,255,255,0.85);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }
.back-btn { padding: 4px; cursor: pointer; background: rgba(0,0,0,0.2); border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.top-title { font-size: 15px; font-weight: 700; color: var(--text); flex: 1; text-align: center; } .top-title { font-size: 15px; font-weight: 700; color: var(--text); flex: 1; text-align: center; }
// //

View File

@ -59,6 +59,9 @@ import { useRouter } from 'vue-router'
import { Client } from '@stomp/stompjs' import { Client } from '@stomp/stompjs'
import { store } from '@/utils/store' import { store } from '@/utils/store'
import { createStory, getWorkDetail } from '@/api' import { createStory, getWorkDetail } from '@/api'
import { STATUS, getRouteByStatus } from '@/utils/status'
import config from '@/utils/config'
import bridge from '@/utils/bridge'
const router = useRouter() const router = useRouter()
const progress = ref(0) const progress = ref(0)
@ -101,20 +104,24 @@ function sanitizeError(msg) {
// // // //
function friendlyStage(pct, msg) { function friendlyStage(pct, msg) {
if (!msg) return '创作中...' if (!msg) return '创作中...'
if (msg.includes('第') && msg.includes('组')) { //
// "1 4/4 (12)" "..."
if (pct < 68) return '🎨 正在绘制插画...'
return '🎨 插画绘制完成'
}
if (msg.startsWith('开始绘图')) return '🎨 正在绘制插画...'
if (msg.includes('补生成')) return '🎨 正在绘制插画...'
if (msg.includes('绘图完成')) return '🎨 插画绘制完成'
if (msg.includes('创作完成')) return '🎉 绘本创作完成!' if (msg.includes('创作完成')) return '🎉 绘本创作完成!'
if (msg.includes('故事') && msg.includes('生成完成')) return '📝 故事编写完成,开始绘图...' if (msg.includes('绘图完成') || msg.includes('绘制完成')) return '🎨 插画绘制完成'
if (msg.includes('语音合成')) return '🔊 正在合成语音...' if (msg.includes('第') && msg.includes('组')) return '🎨 正在绘制插画...'
if (msg.includes('美化角色') || msg.includes('适配角色')) return '🎨 正在准备绘图...' if (msg.includes('绘图') || msg.includes('绘制') || msg.includes('插画')) return '🎨 正在绘制插画...'
if (msg.includes('创作故事')) return '📝 正在编写故事...' if (msg.includes('补生成')) return '🎨 正在绘制插画...'
return msg if (msg.includes('语音合成') || msg.includes('配音')) return '🔊 正在合成语音...'
if (msg.includes('故事') && msg.includes('完成')) return '📝 故事编写完成,开始绘图...'
if (msg.includes('故事') || msg.includes('创<><E5889B><EFBFBD>故事')) return '📝 正在编写故事...'
if (msg.includes('适配') || msg.includes('角色')) return '🎨 正在准备绘图...'
if (msg.includes('重试')) return '✨ 遇到小问题,正在重新创作...'
if (msg.includes('失败')) return '⏳ 处理中,请稍候...'
// <EFBFBD><EFBFBD>
if (pct < 20) return '✨ 正在提交创作...'
if (pct < 50) return '📝 正在编写故事...'
if (pct < 80) return '🎨 正在绘制插画...'
if (pct < 100) return '🔊 即将完成...'
return '🎉 绘本创作完成!'
} }
// workId localStorage // workId localStorage
@ -136,8 +143,10 @@ function restoreWorkId() {
// WebSocket (使) // WebSocket (使)
const startWebSocket = (workId) => { const startWebSocket = (workId) => {
wsDegraded = false wsDegraded = false
const wsScheme = location.protocol === 'https:' ? 'wss' : 'ws' const wsBase = config.wsBaseUrl
const wsUrl = `${wsScheme}://${location.host}/ws/websocket?orgId=${encodeURIComponent(store.orgId)}` ? config.wsBaseUrl
: `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`
const wsUrl = `${wsBase}/ws/websocket?orgId=${encodeURIComponent(store.orgId)}`
stompClient = new Client({ stompClient = new Client({
brokerURL: wsUrl, brokerURL: wsUrl,
@ -154,10 +163,13 @@ const startWebSocket = (workId) => {
stage.value = '🎉 绘本创作完成!' stage.value = '🎉 绘本创作完成!'
closeWebSocket() closeWebSocket()
saveWorkId('') saveWorkId('')
setTimeout(() => router.replace(`/preview/${workId}`), 800) bridge.send('WORK_COMPLETED', { workId })
const route = getRouteByStatus(STATUS.COMPLETED, workId)
if (route) setTimeout(() => router.replace(route), 800)
} else if (data.progress < 0) { } else if (data.progress < 0) {
closeWebSocket() closeWebSocket()
saveWorkId('') saveWorkId('')
bridge.send('CREATION_ERROR', { message: sanitizeError(data.message) })
error.value = sanitizeError(data.message) error.value = sanitizeError(data.message)
} }
} catch { /* ignore parse error */ } } catch { /* ignore parse error */ }
@ -215,17 +227,21 @@ const startPolling = (workId) => {
if (work.progress != null && work.progress > progress.value) progress.value = work.progress if (work.progress != null && work.progress > progress.value) progress.value = work.progress
if (work.progressMessage) stage.value = friendlyStage(progress.value, work.progressMessage) if (work.progressMessage) stage.value = friendlyStage(progress.value, work.progressMessage)
if (work.status === 'COMPLETED') { // >= COMPLETED(3)
if (work.status >= STATUS.COMPLETED) {
progress.value = 100 progress.value = 100
stage.value = '🎉 绘本创作完成!' stage.value = '🎉 绘本创作完成!'
clearInterval(pollTimer) clearInterval(pollTimer)
pollTimer = null pollTimer = null
saveWorkId('') // workId saveWorkId('')
setTimeout(() => router.replace(`/preview/${workId}`), 800) bridge.send('WORK_COMPLETED', { workId })
} else if (work.status === 'FAILED') { const route = getRouteByStatus(work.status, workId)
if (route) setTimeout(() => router.replace(route), 800)
} else if (work.status === STATUS.FAILED) {
clearInterval(pollTimer) clearInterval(pollTimer)
pollTimer = null pollTimer = null
saveWorkId('') // workId saveWorkId('')
bridge.send('CREATION_ERROR', { message: sanitizeError(work.failReason) })
error.value = sanitizeError(work.failReason) error.value = sanitizeError(work.failReason)
} }
} catch { } catch {
@ -272,6 +288,7 @@ const startCreation = async () => {
} }
saveWorkId(workId) saveWorkId(workId)
bridge.send('WORK_CREATED', { workId })
progress.value = 10 progress.value = 10
stage.value = '📝 故事构思中...' stage.value = '📝 故事构思中...'
// WebSocket // WebSocket
@ -284,6 +301,7 @@ const startCreation = async () => {
stage.value = '📝 创作已提交到后台...' stage.value = '📝 创作已提交到后台...'
startPolling(store.workId) startPolling(store.workId)
} else { } else {
bridge.send('CREATION_ERROR', { message: sanitizeError(e.message) })
error.value = sanitizeError(e.message) error.value = sanitizeError(e.message)
submitted = false submitted = false
} }
@ -313,10 +331,15 @@ onMounted(() => {
currentTipIdx.value = (currentTipIdx.value + 1) % creatingTips.length currentTipIdx.value = (currentTipIdx.value + 1) % creatingTips.length
}, 3500) }, 3500)
// localStorage workId reactive store // workIdURLlocalStorage
restoreWorkId() const urlWorkId = new URLSearchParams(window.location.search).get('workId')
if (urlWorkId) {
saveWorkId(urlWorkId)
} else {
restoreWorkId()
}
// /HMR //
if (store.workId) { if (store.workId) {
submitted = true submitted = true
progress.value = 10 progress.value = 10

View File

@ -132,8 +132,9 @@
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import PageHeader from '@/components/PageHeader.vue' import PageHeader from '@/components/PageHeader.vue'
import { getWorkDetail, voicePage, ossUpload, batchUpdateAudio } from '@/api' import { getWorkDetail, voicePage, ossUpload, batchUpdateAudio, finishDubbing } from '@/api'
import { store } from '@/utils/store' import { store } from '@/utils/store'
import { STATUS, getRouteByStatus } from '@/utils/status'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@ -374,34 +375,47 @@ async function voiceAllConfirm() {
// //
async function finish() { async function finish() {
const pendingLocal = pages.value.filter(p => p.localBlob)
if (pendingLocal.length === 0) {
router.push(`/read/${workId.value}`)
return
}
submitting.value = true submitting.value = true
try { try {
// Step 1: STS OSS const pendingLocal = pages.value.filter(p => p.localBlob)
const audioPages = []
for (let i = 0; i < pendingLocal.length; i++) { if (pendingLocal.length > 0) {
const p = pendingLocal[i] // OSS batchUpdateAudio DB
showToast(`上传录音 ${i + 1}/${pendingLocal.length}...`) const audioPages = []
const ext = p.localBlob.type?.includes('webm') ? 'webm' : 'm4a' for (let i = 0; i < pendingLocal.length; i++) {
const ossUrl = await ossUpload(p.localBlob, { type: 'aud', ext }) const p = pendingLocal[i]
audioPages.push({ pageNum: p.pageNum, audioUrl: ossUrl }) showToast(`上传录音 ${i + 1}/${pendingLocal.length}...`)
p.audioUrl = ossUrl const ext = p.localBlob.type?.includes('webm') ? 'webm' : 'm4a'
p.localBlob = null const ossUrl = await ossUpload(p.localBlob, { type: 'aud', ext })
audioPages.push({ pageNum: p.pageNum, audioUrl: ossUrl })
p.audioUrl = ossUrl
p.localBlob = null
}
showToast('保存配音...')
// batchUpdateAudio (C2) URL CATALOGEDDUBBED
await batchUpdateAudio(workId.value, audioPages)
} else {
// C2 API CATALOGED(4)DUBBED(5)
// C2 pages
showToast('完成配音...')
await finishDubbing(workId.value)
} }
// Step 2: DB store.workDetail = null //
showToast('保存配音...') showToast('配音完成!')
await batchUpdateAudio(workId.value, audioPages)
showToast('配音保存成功')
setTimeout(() => router.push(`/read/${workId.value}`), 800) setTimeout(() => router.push(`/read/${workId.value}`), 800)
} catch (e) { } catch (e) {
showToast('上传失败:' + (e.message || '请重试')) // 使CAS
try {
const check = await getWorkDetail(workId.value)
if (check?.data?.status >= 5) {
store.workDetail = null
showToast('配音已完成')
setTimeout(() => router.push(`/read/${workId.value}`), 800)
return
}
} catch { /* ignore check error */ }
showToast('提交失败:' + (e.message || '请重试'))
} finally { } finally {
submitting.value = false submitting.value = false
} }
@ -417,7 +431,15 @@ async function loadWork() {
const res = await getWorkDetail(workId.value) const res = await getWorkDetail(workId.value)
store.workDetail = res.data store.workDetail = res.data
} }
pages.value = (store.workDetail.pageList || []).map(p => ({ const w = store.workDetail
// (DUBBED)
if (w.status >= STATUS.DUBBED) {
const route = getRouteByStatus(w.status, w.workId)
if (route) { router.replace(route); return }
}
pages.value = (w.pageList || []).map(p => ({
pageNum: p.pageNum, pageNum: p.pageNum,
text: p.text, text: p.text,
imageUrl: p.imageUrl, imageUrl: p.imageUrl,
@ -440,10 +462,12 @@ onBeforeUnmount(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
.dubbing-page { .dubbing-page {
min-height: 100vh; height: 100vh;
height: 100dvh; /* 动态视口高度,兼容移动端地址栏 */
background: linear-gradient(180deg, #F0F8FF 0%, #FFF5F0 40%, #FFF8E7 70%, #FFFDF7 100%); background: linear-gradient(180deg, #F0F8FF 0%, #FFF5F0 40%, #FFF8E7 70%, #FFFDF7 100%);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden;
} }
.loading-state { .loading-state {
flex: 1; flex: 1;
@ -478,7 +502,8 @@ onBeforeUnmount(() => {
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
overflow-y: auto; overflow-y: auto;
padding-bottom: 110px; -webkit-overflow-scrolling: touch;
padding-bottom: 16px;
} }
/* 页面展示 */ /* 页面展示 */
@ -773,9 +798,9 @@ onBeforeUnmount(() => {
/* 底部 */ /* 底部 */
.bottom-bar { .bottom-bar {
position: sticky; flex-shrink: 0;
bottom: 0;
padding: 14px 20px 20px; padding: 14px 20px 20px;
padding-bottom: calc(20px + env(safe-area-inset-bottom, 0px));
background: linear-gradient(transparent, rgba(255,253,247,0.95) 25%); background: linear-gradient(transparent, rgba(255,253,247,0.95) 25%);
} }
.finish-btn { .finish-btn {

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="edit-page"> <div class="edit-page page-fullscreen">
<PageHeader title="编辑绘本信息" subtitle="补充信息让绘本更完整" :showBack="true" /> <PageHeader title="编辑绘本信息" subtitle="补充信息让绘本更完整" :showBack="true" />
<div v-if="loading" class="loading-state"> <div v-if="loading" class="loading-state">
@ -7,7 +7,7 @@
<div style="color:var(--text-sub);margin-top:8px">加载中...</div> <div style="color:var(--text-sub);margin-top:8px">加载中...</div>
</div> </div>
<div v-else class="content"> <div v-else class="content page-content">
<!-- 封面预览 --> <!-- 封面预览 -->
<div class="cover-preview card" v-if="coverUrl"> <div class="cover-preview card" v-if="coverUrl">
<img :src="coverUrl" class="cover-img" /> <img :src="coverUrl" class="cover-img" />
@ -17,9 +17,10 @@
<!-- 基本信息 --> <!-- 基本信息 -->
<div class="card form-card"> <div class="card form-card">
<div class="field-item"> <div class="field-item">
<div class="field-label"><span></span> 作者署名 <span class="optional-mark"></span></div> <div class="field-label"><span></span> 作者署名 <span class="required-mark"></span></div>
<input v-model="form.author" class="text-input" placeholder="如:宝宝的名字" maxlength="16" /> <input v-model="form.author" class="text-input" :class="{ 'input-error': authorError }" placeholder="如:宝宝的名字" maxlength="16" @input="authorError = ''" />
<span class="char-count-inline">{{ form.author.length }}/16</span> <span class="char-count-inline">{{ form.author.length }}/16</span>
<div v-if="authorError" class="field-error">{{ authorError }}</div>
</div> </div>
<div class="field-item"> <div class="field-item">
@ -75,7 +76,7 @@
</div> </div>
<!-- 底部 --> <!-- 底部 -->
<div v-if="!loading" class="bottom-bar safe-bottom"> <div v-if="!loading" class="page-bottom">
<button class="btn-primary" :disabled="saving" @click="handleSave"> <button class="btn-primary" :disabled="saving" @click="handleSave">
{{ saving ? '保存中...' : '保存绘本 →' }} {{ saving ? '保存中...' : '保存绘本 →' }}
</button> </button>
@ -89,6 +90,7 @@ import { useRouter, useRoute } from 'vue-router'
import PageHeader from '@/components/PageHeader.vue' import PageHeader from '@/components/PageHeader.vue'
import { getWorkDetail, updateWork } from '@/api' import { getWorkDetail, updateWork } from '@/api'
import { store } from '@/utils/store' import { store } from '@/utils/store'
import { STATUS, getRouteByStatus } from '@/utils/status'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@ -144,6 +146,13 @@ async function loadWork() {
store.workDetail = res.data store.workDetail = res.data
} }
const w = store.workDetail const w = store.workDetail
// CATALOGED
if (w.status > STATUS.CATALOGED) {
const route = getRouteByStatus(w.status, w.workId)
if (route) { router.replace(route); return }
}
form.value.author = w.author || '' form.value.author = w.author || ''
form.value.subtitle = w.subtitle || '' form.value.subtitle = w.subtitle || ''
form.value.intro = w.intro || '' form.value.intro = w.intro || ''
@ -156,11 +165,19 @@ async function loadWork() {
} }
} }
const authorError = ref('')
async function handleSave() { async function handleSave() {
//
if (!form.value.author.trim()) {
authorError.value = '请填写作者署名'
return
}
authorError.value = ''
saving.value = true saving.value = true
try { try {
const data = { tags: selectedTags.value } const data = { tags: selectedTags.value }
if (form.value.author.trim()) data.author = form.value.author.trim() data.author = form.value.author.trim()
if (form.value.subtitle.trim()) data.subtitle = form.value.subtitle.trim() if (form.value.subtitle.trim()) data.subtitle = form.value.subtitle.trim()
if (form.value.intro.trim()) data.intro = form.value.intro.trim() if (form.value.intro.trim()) data.intro = form.value.intro.trim()
@ -174,8 +191,19 @@ async function handleSave() {
store.workDetail.tags = [...selectedTags.value] store.workDetail.tags = [...selectedTags.value]
} }
router.push(`/save-success/${workId.value}`) // C1
store.workDetail = null //
router.push(`/dubbing/${workId.value}`)
} catch (e) { } catch (e) {
// CAS
try {
const check = await getWorkDetail(workId.value)
if (check?.data?.status >= 4) {
store.workDetail = null
router.push(`/dubbing/${workId.value}`)
return
}
} catch { /* ignore */ }
alert(e.message || '保存失败,请重试') alert(e.message || '保存失败,请重试')
} finally { } finally {
saving.value = false saving.value = false
@ -190,10 +218,7 @@ onMounted(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
.edit-page { .edit-page {
min-height: 100vh;
background: var(--bg); background: var(--bg);
display: flex;
flex-direction: column;
} }
.loading-state { .loading-state {
flex: 1; flex: 1;
@ -203,13 +228,10 @@ onMounted(() => {
justify-content: center; justify-content: center;
} }
.content { .content {
flex: 1;
padding: 16px 20px; padding: 16px 20px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
overflow-y: auto;
padding-bottom: 80px;
} }
.cover-preview { .cover-preview {
@ -248,6 +270,9 @@ onMounted(() => {
} }
.required-mark { color: var(--primary); font-size: 11px; } .required-mark { color: var(--primary); font-size: 11px; }
.optional-mark { color: #94A3B8; font-size: 11px; } .optional-mark { color: #94A3B8; font-size: 11px; }
.required-mark { color: #EF4444; font-size: 11px; font-weight: 600; }
.input-error { border-color: #EF4444 !important; }
.field-error { color: #EF4444; font-size: 12px; margin-top: 4px; }
.char-count { margin-left: auto; font-size: 11px; color: #94A3B8; } .char-count { margin-left: auto; font-size: 11px; color: #94A3B8; }
.char-count-inline { display: block; text-align: right; font-size: 11px; color: #94A3B8; margin-top: 2px; } .char-count-inline { display: block; text-align: right; font-size: 11px; color: #94A3B8; margin-top: 2px; }
@ -342,10 +367,4 @@ onMounted(() => {
padding: 4px 12px; padding: 4px 12px;
} }
.bottom-bar {
position: sticky;
bottom: 0;
padding: 12px 20px 20px;
background: linear-gradient(transparent, var(--bg) 20%);
}
</style> </style>

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="preview-page"> <div class="preview-page page-fullscreen">
<!-- 顶部 --> <!-- 顶部 -->
<div class="top-bar"> <div class="top-bar">
<div class="top-title">绘本预览</div> <div class="top-title">绘本预览</div>
@ -22,7 +22,7 @@
<!-- 主内容 --> <!-- 主内容 -->
<template v-else-if="pages.length"> <template v-else-if="pages.length">
<div class="content" @touchstart="onTouchStart" @touchend="onTouchEnd"> <div class="content page-content" @touchstart="onTouchStart" @touchend="onTouchEnd">
<!-- 1. 图片区16:9 完整展示不裁切 --> <!-- 1. 图片区16:9 完整展示不裁切 -->
<div class="image-section"> <div class="image-section">
<div class="page-badge">{{ pageBadge }}</div> <div class="page-badge">{{ pageBadge }}</div>
@ -43,7 +43,7 @@
<button class="nav-btn" :class="{ invisible: idx >= pages.length - 1 }" @click="next"></button> <button class="nav-btn" :class="{ invisible: idx >= pages.length - 1 }" @click="next"></button>
</div> </div>
<!-- 4. 横版卡片网格2填满空白 --> <!-- 4. 横版卡片网格2 -->
<div class="thumb-grid"> <div class="thumb-grid">
<div v-for="(p, i) in pages" :key="i" <div v-for="(p, i) in pages" :key="i"
class="thumb-card" :class="{ active: i === idx }" class="thumb-card" :class="{ active: i === idx }"
@ -59,7 +59,7 @@
</div> </div>
<!-- 底部按钮 --> <!-- 底部按钮 -->
<div class="bottom-bar safe-bottom"> <div class="page-bottom">
<button class="btn-primary" @click="goEditInfo">下一步: 编辑绘本信息 </button> <button class="btn-primary" @click="goEditInfo">下一步: 编辑绘本信息 </button>
</div> </div>
</template> </template>
@ -71,6 +71,7 @@ import { ref, computed, onMounted, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { getWorkDetail } from '@/api' import { getWorkDetail } from '@/api'
import { store } from '@/utils/store' import { store } from '@/utils/store'
import { STATUS, getRouteByStatus } from '@/utils/status'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@ -115,6 +116,13 @@ async function loadWork() {
const work = res.data const work = res.data
store.workDetail = work store.workDetail = work
store.workId = work.workId store.workId = work.workId
// COMPLETED
if (work.status > STATUS.COMPLETED) {
const route = getRouteByStatus(work.status, work.workId)
if (route) { router.replace(route); return }
}
pages.value = (work.pageList || []).map(p => ({ pages.value = (work.pageList || []).map(p => ({
pageNum: p.pageNum, pageNum: p.pageNum,
text: p.text, text: p.text,
@ -137,10 +145,7 @@ onMounted(loadWork)
<style lang="scss" scoped> <style lang="scss" scoped>
.preview-page { .preview-page {
min-height: 100vh;
background: var(--bg); background: var(--bg);
display: flex;
flex-direction: column;
} }
.top-bar { .top-bar {
@ -163,13 +168,7 @@ onMounted(loadWork)
.loading-text { margin-top: 12px; color: var(--text-sub); } .loading-text { margin-top: 12px; color: var(--text-sub); }
.content { .content {
flex: 1; padding: 10px 14px 14px;
padding: 10px 14px 0;
display: flex;
flex-direction: column;
gap: 0;
overflow-y: auto;
padding-bottom: 80px;
} }
/* 1. 图片区16:9 完整展示 */ /* 1. 图片区16:9 完整展示 */
@ -324,12 +323,6 @@ onMounted(loadWork)
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.bottom-bar {
position: sticky;
bottom: 0;
padding: 12px 20px 20px;
background: linear-gradient(transparent, var(--bg) 20%);
}
@keyframes pulse { @keyframes pulse {
0%, 100% { transform: scale(1); } 0%, 100% { transform: scale(1); }

View File

@ -114,14 +114,8 @@ const quotaOk = ref(true)
const quotaMsg = ref('') const quotaMsg = ref('')
let selectedFile = null let selectedFile = null
// sessionToken appSecret //
onMounted(async () => { onMounted(async () => {
if (!store.sessionToken && (!store.orgId || !store.appSecret || store.appSecret.length < 5)) {
store.setOrg('LESINGLE888888888', 'leai_test_secret_2026_abc123xyz')
}
if (!store.phone) {
store.setPhone('13800138000')
}
try { try {
await checkQuota() await checkQuota()
quotaOk.value = true quotaOk.value = true
@ -376,11 +370,12 @@ const goNext = async () => {
.recognizing-emojis { .recognizing-emojis {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 16px; gap: 12px;
margin-bottom: 12px; margin-bottom: 12px;
padding: 8px 0;
} }
.recognizing-emoji { .recognizing-emoji {
font-size: 32px; font-size: 28px;
display: inline-block; display: inline-block;
animation: emojiPop 1.8s ease-in-out infinite; animation: emojiPop 1.8s ease-in-out infinite;
} }

View File

@ -10,8 +10,8 @@
<span v-for="(b, i) in ['📕','📗','📘','📙']" :key="i" class="book-icon">{{ b }}</span> <span v-for="(b, i) in ['📕','📗','📘','📙']" :key="i" class="book-icon">{{ b }}</span>
</div> </div>
<div class="hero-text"> <div class="hero-text">
<div class="hero-title">乐读派</div> <div class="hero-title">{{ brandTitle }}</div>
<div class="hero-sub">AI智能儿童绘本创作</div> <div class="hero-sub">{{ brandSubtitle }}</div>
</div> </div>
</div> </div>
<div class="hero-tag"> 拍一张画AI帮你变成绘本</div> <div class="hero-tag"> 拍一张画AI帮你变成绘本</div>
@ -66,9 +66,22 @@
<!-- 底部固定 --> <!-- 底部固定 -->
<div class="bottom-area safe-bottom"> <div class="bottom-area safe-bottom">
<button class="btn-primary start-btn" @click="handleStart"> <!-- Token模式无token时 -->
<span class="btn-icon">🚀</span> 开始创作 <template v-if="isTokenMode && !store.sessionToken">
</button> <div class="auth-prompt" style="text-align:center;padding:20px;">
<p style="font-size:16px;color:#666;margin-bottom:8px;">本应用需要通过企业入口访问</p>
<p style="font-size:14px;color:#999;margin-bottom:20px;">请联系您的管理员获取访问链接</p>
<button v-if="store.authRedirectUrl" class="btn-primary start-btn" @click="goToEnterprise">
<span class="btn-icon">🔑</span> 前往企业认证
</button>
</div>
</template>
<!-- 有token或HMAC模式 -->
<template v-else>
<button class="btn-primary start-btn" @click="handleStart">
<span class="btn-icon">🚀</span> 开始创作
</button>
</template>
<div class="slogan">让每个孩子都是小画家 </div> <div class="slogan">让每个孩子都是小画家 </div>
</div> </div>
</div> </div>
@ -78,12 +91,12 @@
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { store } from '@/utils/store' import { store } from '@/utils/store'
import config from '@/utils/config'
import bridge from '@/utils/bridge'
import { getOrgConfig } from '@/api/index'
const router = useRouter() const router = useRouter()
// 线
const TEST_PHONE = '18911223344'
const steps = [ const steps = [
{ emoji: '📸', title: '拍照上传', desc: '拍下孩子的画作', color: '#FF6B35' }, { emoji: '📸', title: '拍照上传', desc: '拍下孩子的画作', color: '#FF6B35' },
{ emoji: '🎭', title: '角色提取', desc: 'AI智能识别角色', color: '#6C63FF' }, { emoji: '🎭', title: '角色提取', desc: 'AI智能识别角色', color: '#6C63FF' },
@ -92,39 +105,90 @@ const steps = [
] ]
// URL : ?token=sess_xxx&phone=138xxx&orgId=ORG001 // URL : ?token=sess_xxx&phone=138xxx&orgId=ORG001
onMounted(() => { onMounted(async () => {
const params = new URLSearchParams(window.location.search) // searchiframe src+ hashhash
const urlToken = params.get('token') const searchParams = new URLSearchParams(window.location.search)
const urlOrgId = params.get('orgId') const hashQuery = window.location.hash.split('?')[1] || ''
const urlPhone = params.get('phone') const hashParams = new URLSearchParams(hashQuery)
const urlToken = searchParams.get('token') || hashParams.get('token')
const urlOrgId = searchParams.get('orgId') || hashParams.get('orgId')
const urlPhone = searchParams.get('phone') || hashParams.get('phone')
const urlReturnPath = searchParams.get('returnPath') || hashParams.get('returnPath')
if (urlToken) { if (urlToken) {
if (!urlOrgId) { if (!urlOrgId) {
console.error('[Welcome] URL缺少orgId参数企业重定向应包含 ?token=xxx&orgId=yyy&phone=zzz') console.error('[Welcome] URL缺少orgId参数')
alert('入口链接缺少机构信息(orgId),请联系管理员') alert('入口链接缺少机构信息(orgId),请联系管理员')
return return
} }
// Session token flow: token使
store.setSession(urlOrgId, urlToken) store.setSession(urlOrgId, urlToken)
if (urlPhone) store.setPhone(urlPhone) if (urlPhone) store.setPhone(urlPhone)
// URL token Referer // URLtoken
window.history.replaceState({}, '', window.location.pathname) window.history.replaceState({}, '', window.location.pathname + '#/')
//
const recovery = store.restoreRecoveryState()
const targetPath = urlReturnPath || (recovery ? recovery.path : null)
if (targetPath && targetPath !== '/') {
router.push(targetPath)
return
}
} else {
// URL使token
if (hashQuery) {
window.history.replaceState({}, '', window.location.pathname + '#/')
}
}
// TokentokenURL
if (config.isTokenMode && !store.sessionToken) {
// iframe token
if (bridge.isEmbedded) {
try {
const data = await bridge.requestTokenRefresh()
if (data?.token) {
store.setSession(data.orgId || '', data.token)
if (data.phone) store.setPhone(data.phone)
router.push('/upload')
return
}
} catch { /* 超时:继续显示提示 */ }
}
// standaloneiframeURL
const savedOrgId = sessionStorage.getItem('le_orgId') || urlOrgId
if (savedOrgId) {
try {
const res = await getOrgConfig(savedOrgId)
if (res?.data?.authRedirectUrl) {
store.authRedirectUrl = res.data.authRedirectUrl
}
} catch { /* ignore - will show text-only prompt */ }
}
} }
}) })
//
// : auth/session sessionToken URL Bearer Token
// : orgId + appSecret HMAC
const handleStart = () => { const handleStart = () => {
// 线 if (config.isTokenMode) {
if (!store.phone) store.setPhone(TEST_PHONE) if (!store.sessionToken) return
// sessionToken HMAC } else {
if (!store.sessionToken && !store.appSecret) { if (!store.phone && config.dev?.phone) store.setPhone(config.dev.phone)
store.setOrg('LESINGLE888888888', 'leai_test_secret_2026_abc123xyz') if (!store.sessionToken && !store.appSecret && config.dev) {
store.setOrg(config.dev.orgId, config.dev.appSecret)
}
} }
store.reset() // store.reset()
router.push('/upload') router.push('/upload')
} }
const goToEnterprise = () => {
window.location.href = store.authRedirectUrl
}
const brandTitle = config.brand.title || '乐读派'
const brandSubtitle = config.brand.subtitle || 'AI智能儿童绘本创作'
// expose config to template for v-if usage
const isTokenMode = config.isTokenMode
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -25,12 +25,11 @@ export default defineConfig({
host: '0.0.0.0', host: '0.0.0.0',
proxy: { proxy: {
'/api': { '/api': {
target: 'http://192.168.1.72:8080', target: 'http://localhost:8080',
// rewrite: (path) => path.replace(/^\/api/, ''),
changeOrigin: true changeOrigin: true
}, },
'/ws': { '/ws': {
target: 'http://192.168.1.72:8080', target: 'http://localhost:8080',
ws: true ws: true
} }
} }

View File

@ -1,802 +0,0 @@
# AI 绘本创作系统 — 企业定制对接指南 V3.1
> 版本: V3.1 | 更新日期: 2026-04-03
> 适用客户: 自有 C端 H5 + 管理后台,需嵌入乐读派 AI 创作能力
---
## 一、整体架构
```
┌─────────────────────────────────────────────────────────────┐
│ 客户自有 C端 H5 │
│ ┌──────┐ ┌──────────────┐ ┌──────┐ ┌──────┐ │
│ │ 广场 │ │ 创作(iframe)│ │ 作品 │ │ 我的 │ │
│ │ │ │ ┌──────────┐ │ │ │ │ │ │
│ │ 优秀 │ │ │乐读派H5 │ │ │ AI作品│ │ 个人 │ │
│ │ 作品 │ │ │上传→提取 │ │ │ + │ │ 设置 │ │
│ │ 展示 │ │ │→画风→创作│ │ │ 自有 │ │ │ │
│ │ │ │ │→预览→配音│ │ │ 作品 │ │ │ │
│ │ │ │ └──────────┘ │ │ │ │ │ │
│ └──────┘ └──────────────┘ └──────┘ └──────┘ │
│ ↑ ↑ │
│ │ 读取客户DB 读取客户DB │
└─────┼─────────────────────────────────┼────────────────────┘
│ │
┌─────┼─────────────────────────────────┼────────────────────┐
│ │ 客户后端 │ │
│ │ │ │
│ 客户DB ←──── Webhook回调 ←──── 乐读派后端 │
│ (AI作品 (创作完成后实时推送) │
│ + 自有作品) │
└────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────┐
│ Android APK乐读派打包提供
│ 创作流程 → 完成 → Webhook回调 → 客户后端 │
│ "我的作品" → 调客户提供的API │
└────────────────────────────────────────────────────────────┘
```
### 职责划分
| 功能 | 负责方 | 说明 |
|------|--------|------|
| AI 创作 H5 页面 | **乐读派** | 提供完整创作流程,客户 iframe 嵌入 |
| AI 创作后端 API | **乐读派** | A6 角色提取、A3 故事创作、A20 配音等 |
| Android APK | **乐读派** | 打包发布,客户提供"我的作品"接口即可 |
| 广场 / 作品库 / 我的 | **客户** | 客户自有 H5 + 后端 |
| 作品管理后台 | **客户** | 客户自有管理后台 |
| Webhook 接收 | **客户** | 接收乐读派推送的创作结果 |
| 数据存储 | **客户** | AI 作品数据存入客户自己的 DB |
---
## 二、对接前准备
### 2.1 乐读派提供
以下信息由乐读派管理后台创建机构后生成,正式对接时填入:
| 项目 | 值 | 说明 |
|------|------|------|
| orgId机构ID | `__________` | 机构唯一标识,所有 API 调用和数据归属依据此 ID |
| appSecret机构密钥 | `__________` | API 认证密钥,**严禁泄露**,仅存于客户服务端 |
| H5 创作页地址 | `__________` | 乐读派 H5 前端 URLiframe src 用) |
| API 服务地址 | `__________` | 乐读派后端 API 基地址 |
| Android APK | 另行交付 | 已内置上述配置的签名发布包 |
| 创作额度 | `__________` 次/周期 | 机构总创作额度(管理后台可调整) |
> **重要**:以上所有 `__________` 空白项将在正式开通机构后由乐读派填入并发送给客户。请勿使用测试值上线。
### 2.2 客户提供
| 项目 | 内容 | 说明 |
|------|------|------|
| Webhook 接收 URL | `https://客户域名/webhook/leai` | HTTPS5 秒内返回 200 |
| H5 嵌入域名 | `https://客户h5域名` | 用于 CORS 和 iframe 白名单 |
| 机构查询接口Android用 | `GET /api/org/by-device?mac=xx` | 根据设备MAC返回orgId见 6.2 |
| 我的作品接口Android用 | `GET /api/my-works?orgId=xx&phone=xx` | 返回作品列表+详情(见 6.3 |
---
## 三、C端 H5 嵌入iframe 方案)
### 3.1 嵌入原理
客户的"创作"Tab 内放一个 iframe加载乐读派 H5 创作页面。创作完成后,乐读派 H5 通过 `postMessage` 通知客户父页面。
```
客户H5页面 乐读派H5(iframe内)
│ │
│ 1. 客户后端换取 sessionToken │
│ │
│ 2. iframe.src = 乐读派H5 │
│ + token + orgId + phone │
│ ──────────────────────────────→ │
│ │
│ 用户在iframe内创作... │
│ │
│ 3. 创作完成: postMessage │
│ ←────────────────────────────── │
│ {type:'WORK_CREATED', │
│ workId:'xxx'} │
│ │
│ 4. 同时: Webhook推送到客户后端 │
│ │
│ 5. 客户刷新作品列表 │
```
### 3.2 客户 H5 嵌入代码(完整示例,可直接使用)
```html
<!-- 客户的"创作"Tab页面 -->
<template>
<div class="create-tab">
<!-- 乐读派创作iframe -->
<iframe
v-if="iframeSrc"
:src="iframeSrc"
ref="creationFrame"
class="creation-iframe"
allow="camera;microphone"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
/>
<div v-else class="loading">正在加载创作工具...</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import axios from 'axios'
// ★★★ 替换为乐读派提供的值(见第二章配置表)★★★
const LEAI_H5_URL = '__________ /* 乐读派H5创作页地址 */'
const CREATE_TOKEN_API = '/api/create-token' // 客户自己的后端接口见3.3
// ★★★ 替换结束 ★★★
const iframeSrc = ref('')
onMounted(async () => {
try {
// 1. 调客户自己的后端,获取 sessionToken
// 客户后端内部会调乐读派的 /api/v1/auth/session
const { data } = await axios.post(CREATE_TOKEN_API, {
phone: getCurrentUserPhone() // 从客户登录态获取当前用户手机号
})
// 2. 拼接 iframe URL
iframeSrc.value = `${LEAI_H5_URL}/?token=${data.token}&orgId=${data.orgId}&phone=${data.phone}&embed=1`
} catch (e) {
console.error('获取创作令牌失败', e)
}
// 3. 监听 postMessage创作完成通知
window.addEventListener('message', onCreationMessage)
})
onBeforeUnmount(() => {
window.removeEventListener('message', onCreationMessage)
})
function onCreationMessage(event) {
// 安全校验只处理来自乐读派H5的消息
if (!event.origin.includes('leai')) return
const msg = event.data
if (msg?.type === 'WORK_CREATED') {
// 创作完成workId 可用于跳转到作品详情
console.log('新作品创建成功:', msg.workId)
// 客户可以:刷新作品列表 / 跳转到作品Tab / 显示成功提示
refreshMyWorks()
}
}
function getCurrentUserPhone() {
// ★ 替换为客户自己的获取当前登录用户手机号的逻辑
return '13800001111'
}
function refreshMyWorks() {
// ★ 替换为客户自己的刷新作品列表逻辑
}
</script>
<style scoped>
.create-tab {
width: 100%;
height: 100%;
}
.creation-iframe {
width: 100%;
height: 100%;
border: none;
}
</style>
```
### 3.3 客户后端:令牌交换接口(完整示例)
客户后端需要实现一个接口,内部调用乐读派的令牌交换 API
**Java (Spring Boot):**
```java
@RestController
public class LeAiController {
// ★★★ 替换为乐读派提供的值(见第二章配置表)★★★
private static final String LEAI_API = "__________"; // API服务地址
private static final String ORG_ID = "__________"; // 机构ID
private static final String APP_SECRET = "__________"; // 机构密钥
private final RestTemplate restTemplate = new RestTemplate();
/**
* 客户前端调这个接口获取创作令牌
* POST /api/create-token
* Body: { "phone": "13800001111" }
*/
@PostMapping("/api/create-token")
public Map<String, String> createToken(@RequestBody Map<String, String> req) {
String phone = req.get("phone");
// 调乐读派令牌交换接口
Map<String, String> body = new HashMap<>();
body.put("orgId", ORG_ID);
body.put("appSecret", APP_SECRET);
body.put("phone", phone);
Map response = restTemplate.postForObject(
LEAI_API + "/api/v1/auth/session", body, Map.class);
Map data = (Map) response.get("data");
// 返回给前端
Map<String, String> result = new HashMap<>();
result.put("token", (String) data.get("sessionToken"));
result.put("orgId", ORG_ID);
result.put("phone", phone);
return result;
}
}
```
**Python (Flask):**
```python
import requests
from flask import Flask, request, jsonify
LEAI_API = "__________" # ★ API服务地址见第二章配置表
ORG_ID = "__________" # ★ 机构ID
APP_SECRET = "__________" # ★ 机构密钥
app = Flask(__name__)
@app.route("/api/create-token", methods=["POST"])
def create_token():
phone = request.json["phone"]
res = requests.post(f"{LEAI_API}/api/v1/auth/session", json={
"orgId": ORG_ID, "appSecret": APP_SECRET, "phone": phone
})
token = res.json()["data"]["sessionToken"]
return jsonify({"token": token, "orgId": ORG_ID, "phone": phone})
```
**Node.js (Express):**
```javascript
const axios = require('axios')
const LEAI_API = '__________' // ★ API服务地址见第二章配置表
const ORG_ID = '__________' // ★ 机构ID
const APP_SECRET = '__________' // ★ 机构密钥
app.post('/api/create-token', async (req, res) => {
const { phone } = req.body
const { data } = await axios.post(`${LEAI_API}/api/v1/auth/session`, {
orgId: ORG_ID, appSecret: APP_SECRET, phone
})
res.json({ token: data.data.sessionToken, orgId: ORG_ID, phone })
})
```
### 3.4 iframe 嵌入注意事项
| 事项 | 说明 |
|------|------|
| CORS 白名单 | 联系乐读派将客户 H5 域名加入 `allowed_origins` |
| HTTPS 必须 | iframe 父页面和乐读派 H5 都必须是 HTTPS |
| `embed=1` 参数 | 告诉乐读派 H5 处于嵌入模式(隐藏返回按钮等) |
| Token 有效期 | 2 小时建议每次打开创作Tab时重新获取 |
| 相机权限 | iframe 需要 `allow="camera"` 属性才能拍照上传 |
---
## 四、Webhook 数据同步
### 4.1 同步机制全景图
```
用户在iframe中创作
乐读派后端完成AI生成
├──→ postMessage通知iframe父页面即时用于前端刷新
└──→ Webhook POST到客户后端1-3秒用于数据持久化
客户后端接收
├── 验签(确认来自乐读派)
├── 解析作品数据(标题/图片/音频/文字)
├── 存入客户DB供 广场/作品库 使用)
└── 返回200
```
### 4.2 客户需要实现的 Webhook 接口
**只需要一个 POST 端点:**
```
POST https://客户域名/webhook/leai
Content-Type: application/json
```
**完整实现示例 (Java Spring Boot):**
```java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.apache.commons.codec.binary.Hex;
import java.security.MessageDigest;
@RestController
public class WebhookController {
private static final String APP_SECRET = "__________"; // ★ 机构密钥(见第二章配置表)
/**
* 接收乐读派Webhook回调
* 所有事件类型都走这一个接口
*/
@PostMapping("/webhook/leai")
public Map<String, String> handleWebhook(
@RequestBody String rawBody,
@RequestHeader("X-Webhook-Id") String webhookId,
@RequestHeader("X-Webhook-Timestamp") String timestamp,
@RequestHeader("X-Webhook-Signature") String signatureHeader) {
// 1. 时间窗口检查防重放5分钟有效
long ts = Long.parseLong(timestamp);
if (Math.abs(System.currentTimeMillis() - ts) > 300_000) {
return Map.of("error", "expired");
}
// 2. 验证签名
String signData = webhookId + "." + timestamp + "." + rawBody;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(APP_SECRET.getBytes("UTF-8"), "HmacSHA256"));
String expected = "HMAC-SHA256=" + Hex.encodeHexString(
mac.doFinal(signData.getBytes("UTF-8")));
if (!MessageDigest.isEqual(expected.getBytes(), signatureHeader.getBytes())) {
return Map.of("error", "invalid signature");
}
// 3. 解析事件
JSONObject payload = JSON.parseObject(rawBody);
String eventId = payload.getString("id");
String event = payload.getString("event");
JSONObject data = payload.getJSONObject("data");
// 4. 幂等去重用eventId判断是否已处理
if (isProcessed(eventId)) {
return Map.of("status", "duplicate");
}
// 5. 按事件类型处理
switch (event) {
case "work.completed":
handleWorkCompleted(data);
break;
case "work.updated":
handleWorkUpdated(data);
break;
case "work.audio_updated":
handleAudioUpdated(data);
break;
case "work.failed":
handleWorkFailed(data);
break;
// 其他事件按需处理
}
markProcessed(eventId);
return Map.of("status", "ok");
}
/**
* 作品创作完成 — 最重要的事件
* 存入客户DB供广场和作品库使用
*/
private void handleWorkCompleted(JSONObject data) {
String workId = data.getString("work_id");
String title = data.getString("title");
String author = data.getString("author");
String phone = data.getString("phone"); // 创作者手机号
String style = data.getString("style");
int completionStep = data.getIntValue("completion_step");
int dataVersion = data.getIntValue("data_version");
JSONArray pageList = data.getJSONArray("page_list");
// ★ 存入客户自己的作品表
// dataVersion门卫只有新版本才更新
MyWork local = myWorkRepository.findByWorkId(workId);
if (local != null && dataVersion <= local.getDataVersion()) {
return; // 旧数据,跳过
}
MyWork work = local != null ? local : new MyWork();
work.setWorkId(workId);
work.setTitle(title);
work.setAuthor(author);
work.setPhone(phone);
work.setStyle(style);
work.setStatus("COMPLETED");
work.setCompletionStep(completionStep);
work.setDataVersion(dataVersion);
work.setSource("AI_CREATION"); // 标记来源AI创作区别于客户自有作品
// 存储页面数据
if (pageList != null) {
work.setPageListJson(pageList.toJSONString());
// 封面图第一页的图片URL
if (pageList.size() > 0) {
work.setCoverUrl(pageList.getJSONObject(0).getString("image_url"));
}
}
myWorkRepository.save(work);
}
}
```
### 4.3 Webhook 回调数据格式
**请求头:**
```
X-Webhook-Id: evt_1912345678901234567
X-Webhook-Timestamp: 1712000000000
X-Webhook-Signature: HMAC-SHA256=a3f8c2d1e5b7...
```
**请求体work.completed 事件):**
```json
{
"id": "evt_1912345678901234567",
"event": "work.completed",
"created_at": 1712000000000,
"data": {
"work_id": "1912345678901234567",
"org_id": "ORG001",
"status": "COMPLETED",
"title": "小兔子的冒险",
"author": "小明",
"phone": "13800001111",
"style": "watercolor",
"pages": 6,
"completion_step": 0,
"data_version": 1,
"page_list": [
{"page_num": 0, "text": "小兔子的冒险", "image_url": "https://oss.../p0.png", "audio_url": null},
{"page_num": 1, "text": "在一个阳光明媚的早晨...", "image_url": "https://oss.../p1.png", "audio_url": null}
]
}
}
```
### 4.4 客户 DB 建表参考
```sql
CREATE TABLE my_works (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
work_id VARCHAR(50) UNIQUE NOT NULL, -- 乐读派作品ID
-- 乐读派同步字段Webhook写入不要手动改
title VARCHAR(200),
author VARCHAR(50),
phone VARCHAR(20), -- 创作者
status VARCHAR(20), -- COMPLETED/FAILED
style VARCHAR(50),
completion_step INT DEFAULT 0,
data_version INT NOT NULL DEFAULT 0, -- ★ 同步对比用
page_list_json MEDIUMTEXT, -- 页面JSON
cover_url VARCHAR(500), -- 封面图URL
-- 客户自有字段
source VARCHAR(20) DEFAULT 'AI_CREATION', -- AI_CREATION=AI创作 / USER_UPLOAD=用户自传
is_featured TINYINT DEFAULT 0, -- 是否精选(广场展示)
review_status VARCHAR(20) DEFAULT 'PENDING', -- 审核状态
user_id BIGINT, -- 客户系统的用户ID通过phone关联
created_at DATETIME DEFAULT NOW(),
updated_at DATETIME DEFAULT NOW() ON UPDATE NOW(),
INDEX idx_phone (phone),
INDEX idx_source (source),
INDEX idx_featured (is_featured)
);
```
### 4.5 签名验证依赖
Java 项目需要添加 Apache Commons Codec
```xml
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.16.0</version>
</dependency>
```
---
## 五、postMessage 通信协议
### 5.1 乐读派 H5 → 客户父页面
当用户在 iframe 中完成创作流程,乐读派 H5 会通过 `window.parent.postMessage` 发送以下消息:
| 事件 | 触发时机 | 数据 |
|------|---------|------|
| `WORK_CREATED` | 作品创建成功A3提交后 | `{type:'WORK_CREATED', workId:'xxx'}` |
| `WORK_COMPLETED` | 创作完成(图文生成完毕) | `{type:'WORK_COMPLETED', workId:'xxx'}` |
| `CREATION_ERROR` | 创作失败 | `{type:'CREATION_ERROR', message:'xxx'}` |
### 5.2 客户父页面监听示例
```javascript
window.addEventListener('message', (event) => {
// 安全校验
if (!event.origin.includes('leai域名')) return
const { type, workId } = event.data
switch (type) {
case 'WORK_COMPLETED':
// 创作完成,可以:
// 1. 切换到"作品"Tab
// 2. 刷新作品列表
// 3. 显示成功提示
showToast('创作完成!')
switchToWorksTab()
break
case 'CREATION_ERROR':
showToast('创作失败:' + event.data.message)
break
}
})
```
> **注意**: postMessage 用于前端即时通知告诉客户H5"创作完了")。完整的作品数据通过 Webhook 异步推送到客户后端,客户前端从自己的后端获取。
---
## 六、Android APK 对接
### 6.1 交付方式
乐读派打包签名的 APK 交付给客户。客户**不需要**源代码。
APK 中**不写死机构ID**,而是通过客户提供的接口动态获取(见 6.2)。
打包时,客户需提供以下信息(乐读派代入配置):
| 配置项 | 示例 | 说明 |
|--------|------|------|
| 乐读派 API 地址 | `__________` | 乐读派后端(见第二章) |
| 机构密钥 | `__________` | 客户的 appSecret见第二章 |
| 客户 API 基地址 | `https://客户域名/api` | 用于调 6.2/6.3 的接口 |
### 6.2 客户需提供的接口①获取机构ID
Android 端启动时,通过设备 MAC 地址向客户后端查询所属机构。**机构ID 不写死在 APK 中**,支持同一 APK 部署到不同机构的设备。
```
GET https://客户域名/api/org/by-device?mac={设备MAC地址}
```
响应格式:
```json
{
"code": 200,
"data": {
"orgId": "ORG001",
"orgName": "XX教育机构"
}
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| orgId | String | 乐读派分配的机构ID必须与第二章配置表一致 |
| orgName | String | 机构名称(可选,用于 Android 端显示) |
> **流程**Android 启动 → 读取设备 MAC → 调客户接口获取 orgId → 后续所有 API 调用使用该 orgId。
### 6.3 客户需提供的接口②:我的作品
Android 端"作品"Tab 展示当前用户在该机构下的作品列表。使用 **orgId + phone** 组合查询。
**作品列表:**
```
GET https://客户域名/api/my-works?orgId={orgId}&phone={phone}&page=1&size=20
```
响应格式(**字段名固定,乐读派 Android 端直接解析**
```json
{
"code": 200,
"data": {
"total": 42,
"records": [
{
"workId": "1912345678901234567",
"title": "小兔子的冒险",
"coverUrl": "https://oss.../p0.png",
"status": "COMPLETED",
"createdAt": "2026-04-03 10:30:00"
}
]
}
}
```
**作品详情:**
```
GET https://客户域名/api/my-works/{workId}?orgId={orgId}
```
响应格式:
```json
{
"code": 200,
"data": {
"workId": "1912345678901234567",
"title": "小兔子的冒险",
"author": "小明",
"pageList": [
{"pageNum": 0, "text": "封面", "imageUrl": "https://...", "audioUrl": null},
{"pageNum": 1, "text": "故事内容...", "imageUrl": "https://...", "audioUrl": "https://..."}
]
}
}
```
> **注意**
> - 字段名使用 **camelCase**(如果客户 DB 存的是 Webhook 的 snake_case需在接口层转换
> - `orgId` 必填,用于隔离不同机构的数据
> - `phone` 来自用户登录Android 端自动携带
### 6.4 Android 端数据流
```
Android 启动
├── 读取设备MAC地址
├── GET /api/org/by-device?mac=xx:xx:xx → 获取 orgId
├── 用户登录 → 获取 phone
├── 创作流程 → 调乐读派APIorgId + appSecret + phone
│ │
│ └── 创作完成 → Webhook推送到客户后端
└── "我的作品" → GET /api/my-works?orgId=xx&phone=xx → 客户后端返回
```
---
## 七、数据流全景与同步时序
### 7.1 用户创作一个作品的完整数据流
```
时间轴 →
用户操作 客户H5 乐读派H5(iframe) 乐读派后端 客户后端
│ │ │ │ │
│ 点击"创作" │ │ │ │
│ ──────────→ │ │ │ │
│ │ 换取token │ │ │
│ │ ──────────────────────────────→ │ │
│ │ ←─ sessionToken ────────────── │ │
│ │ │ │ │
│ │ 加载iframe │ │ │
│ │ ──────────→ │ │ │
│ │ │ │ │
│ 拍照上传 │ │ A6角色提取 │ │
│ ──────────────────────────→ │ ────────────→ │ │
│ │ │ ←── 角色列表 ── │ │
│ │ │ │ │
│ 选画风+写故事 │ │ A3创作 │ │
│ ──────────────────────────→ │ ────────────→ │ │
│ │ │ │ AI生成中... │
│ │ │ ←── 进度更新 ── │ │
│ │ │ │ │
│ │ │ ←── 创作完成 ── │ │
│ │ ← postMessage │ │ │
│ │ WORK_COMPLETED │ │ │
│ │ │ │ Webhook POST │
│ │ │ │ ────────────→ │
│ │ │ │ │ 验签+存DB
│ │ │ │ ← 200 ────── │
│ 看到"创作完成" │ │ │ │
│ │ 刷新作品列表 │ │ │
│ │ (从客户后端取) │ │ │
```
### 7.2 数据同步保障
| 层级 | 机制 | 说明 |
|------|------|------|
| 实时通知 | postMessage | iframe 创作完成后立即通知客户 H5 前端 |
| 数据同步 | Webhook | 创作完成后 1-3 秒推送到客户后端 |
| 重试保障 | 自动重试 5 次 | 10s/30s/2m/10m/30m确保数据不丢 |
| 兜底对账 | B3 定时查询 | 建议每 5 分钟查一次,对比 data_version |
---
## 八、对接验证清单
按顺序逐步验证,每步都通过后再进行下一步:
### Phase 1: 后端连通1天
- [ ] 收到乐读派提供的 orgId + appSecret
- [ ] 调用令牌交换接口成功:`POST /api/v1/auth/session`
- [ ] 实现 Webhook 接收端点:`POST /webhook/leai`
- [ ] 管理后台配置回调 URL + 测试连通
### Phase 2: iframe 嵌入1天
- [ ] 客户 H5 域名加入 CORS 白名单(联系乐读派)
- [ ] iframe 加载乐读派 H5 正常显示
- [ ] iframe 内可拍照/选图上传
- [ ] iframe 内完整创作流程走通(上传→提取→画风→创作→预览)
### Phase 3: 数据同步1天
- [ ] Webhook 收到 `work.completed` 事件
- [ ] 签名验证通过
- [ ] 作品数据正确写入客户 DB
- [ ] 客户"作品库"能展示 AI 创作的作品
- [ ] postMessage 通知正常接收
### Phase 4: Android 交付1天
- [ ] 客户提供"我的作品"API 接口文档
- [ ] 乐读派打包 APK 配置客户参数
- [ ] APK 安装后创作流程正常
- [ ] "我的作品"展示客户接口返回的数据
- [ ] Webhook 正常推送
---
## 九、常见问题
**Q: iframe 内创作完成后,客户怎么知道?**
A: 两个通道同时通知:① postMessage 即时通知客户前端用于刷新UI② Webhook 异步推送到客户后端(用于持久化数据)。
**Q: 客户的"广场"数据怎么来?**
A: 所有 AI 作品通过 Webhook 同步到客户 DB 后,客户在管理后台标记"精选",广场从客户 DB 读取 `is_featured=1` 的作品展示。
**Q: 用户在 iframe 创作时网络断了怎么办?**
A: 创作请求已提交到乐读派后端的不受影响后端异步生成。Webhook 会在创作完成后推送。如果用户关闭了页面,下次打开"作品库"也能看到已完成的作品。
**Q: Token 过期了怎么办?**
A: 每次用户打开"创作"Tab 时重新获取 Token2小时有效。创作过程中 Token 过期不影响已提交的创作任务。
**Q: 客户想修改创作 UI 怎么办?**
A: 联系乐读派,我们修改 H5 代码后重新部署。客户不需要改任何代码iframe 自动加载最新版本。
**Q: OSS 图片 URL 会过期吗?**
A: 不会。图片存储在乐读派 OSSURL 永久有效(除非作品被删除)。客户可以直接在广场/作品库中使用这些 URL。
**Q: Android 端需要热更新怎么办?**
A: 目前需要重新打包 APK。创作流程的 UI/逻辑更新需乐读派重新打包后交付。
---
## 附录: 错误码速查
| 错误码 | 说明 | 处理 |
|--------|------|------|
| 200 | 成功 | - |
| 10006 | 请求过于频繁 | 降低频率 |
| 20002 | 账号锁定5次密钥错误 | 等10分钟 |
| 20010 | 会话令牌无效/过期 | 重新换取 token |
| 30001 | 机构不存在 | 检查 orgId |
| 30002 | 机构未授权 | 联系乐读派 |
| 30003 | 创作额度不足 | 联系乐读派充值 |
---
> 乐读派 AI 绘本创作系统 | 企业定制对接指南 V3.1 | 2026-04-03