Merge branch 'master_develop' of http://8.148.151.56:3000/tonytech/library-picturebook-activity into master_develop
This commit is contained in:
commit
0252f25acd
180
CLAUDE.md
Normal file
180
CLAUDE.md
Normal 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.4(API文档) |
|
||||
| 前端框架 | 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 层使用 Entity,VO 转换只在 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,所有文本为中文硬编码
|
||||
182
frontend/package-lock.json
generated
182
frontend/package-lock.json
generated
@ -15,10 +15,12 @@
|
||||
"ant-design-vue": "^4.1.1",
|
||||
"axios": "^1.6.7",
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^6.0.0",
|
||||
"pinia": "^2.1.7",
|
||||
"three": "^0.182.0",
|
||||
"vee-validate": "^4.12.4",
|
||||
"vue": "^3.4.21",
|
||||
"vue-echarts": "^8.0.1",
|
||||
"vue-router": "^4.3.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
@ -34,7 +36,7 @@
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.4.3",
|
||||
"vite": "^5.1.6",
|
||||
"vue-tsc": "^1.8.27"
|
||||
"vue-tsc": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@ -1808,34 +1810,29 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@volar/language-core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-1.11.1.tgz",
|
||||
"integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==",
|
||||
"version": "2.4.28",
|
||||
"resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.28.tgz",
|
||||
"integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@volar/source-map": "1.11.1"
|
||||
"@volar/source-map": "2.4.28"
|
||||
}
|
||||
},
|
||||
"node_modules/@volar/source-map": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-1.11.1.tgz",
|
||||
"integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"muggle-string": "^0.3.1"
|
||||
}
|
||||
"version": "2.4.28",
|
||||
"resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.28.tgz",
|
||||
"integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@volar/typescript": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-1.11.1.tgz",
|
||||
"integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==",
|
||||
"version": "2.4.28",
|
||||
"resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.28.tgz",
|
||||
"integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@volar/language-core": "1.11.1",
|
||||
"path-browserify": "^1.0.1"
|
||||
"@volar/language-core": "2.4.28",
|
||||
"path-browserify": "^1.0.1",
|
||||
"vscode-uri": "^3.0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
@ -1944,29 +1941,30 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/language-core": {
|
||||
"version": "1.8.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-1.8.27.tgz",
|
||||
"integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==",
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.2.6.tgz",
|
||||
"integrity": "sha512-xYYYX3/aVup576tP/23sEUpgiEnujrENaoNRbaozC1/MA9I6EGFQRJb4xrt/MmUCAGlxTKL2RmT8JLTPqagCkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@volar/language-core": "~1.11.1",
|
||||
"@volar/source-map": "~1.11.1",
|
||||
"@vue/compiler-dom": "^3.3.0",
|
||||
"@vue/shared": "^3.3.0",
|
||||
"computeds": "^0.0.1",
|
||||
"minimatch": "^9.0.3",
|
||||
"muggle-string": "^0.3.1",
|
||||
"@volar/language-core": "2.4.28",
|
||||
"@vue/compiler-dom": "^3.5.0",
|
||||
"@vue/shared": "^3.5.0",
|
||||
"alien-signals": "^3.0.0",
|
||||
"muggle-string": "^0.4.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": {
|
||||
"typescript": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
@ -2218,6 +2216,12 @@
|
||||
"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": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
@ -2633,13 +2637,6 @@
|
||||
"integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==",
|
||||
"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": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@ -2726,13 +2723,6 @@
|
||||
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
|
||||
"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": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@ -2856,6 +2846,15 @@
|
||||
"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": {
|
||||
"version": "1.5.267",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
|
||||
@ -3681,16 +3680,6 @@
|
||||
"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": {
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
||||
@ -4187,11 +4176,10 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/muggle-string": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.3.1.tgz",
|
||||
"integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/mz": {
|
||||
"version": "2.7.0",
|
||||
@ -4381,10 +4369,9 @@
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
@ -5359,6 +5346,11 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.7.3",
|
||||
"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": {
|
||||
"version": "3.5.26",
|
||||
"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": {
|
||||
"version": "9.4.3",
|
||||
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz",
|
||||
@ -5628,33 +5635,20 @@
|
||||
"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": {
|
||||
"version": "1.8.27",
|
||||
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-1.8.27.tgz",
|
||||
"integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==",
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.2.6.tgz",
|
||||
"integrity": "sha512-gYW/kWI0XrwGzd0PKc7tVB/qpdeAkIZLNZb10/InizkQjHjnT8weZ/vBarZoj4kHKbUTZT/bAVgoOr8x4NsQ/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@volar/typescript": "~1.11.1",
|
||||
"@vue/language-core": "1.8.27",
|
||||
"semver": "^7.5.4"
|
||||
"@volar/typescript": "2.4.28",
|
||||
"@vue/language-core": "3.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"vue-tsc": "bin/vue-tsc.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-types": {
|
||||
@ -5751,6 +5745,14 @@
|
||||
"funding": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
224
lesingle-aicreate-backend-demo/README.md
Normal file
224
lesingle-aicreate-backend-demo/README.md
Normal 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行JS(iframe+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+语法
|
||||
2
lesingle-aicreate-backend-demo/aicreate-demo/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
2
lesingle-aicreate-backend-demo/aicreate-demo/.mvn/wrapper/maven-wrapper.properties
vendored
Normal 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
|
||||
224
lesingle-aicreate-backend-demo/aicreate-demo/README.md
Normal file
224
lesingle-aicreate-backend-demo/aicreate-demo/README.md
Normal 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行JS(iframe+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+语法
|
||||
29
lesingle-aicreate-backend-demo/aicreate-demo/mvnw
vendored
Normal file
29
lesingle-aicreate-backend-demo/aicreate-demo/mvnw
vendored
Normal 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" "$@"
|
||||
15
lesingle-aicreate-backend-demo/aicreate-demo/mvnw.cmd
vendored
Normal file
15
lesingle-aicreate-backend-demo/aicreate-demo/mvnw.cmd
vendored
Normal 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%
|
||||
52
lesingle-aicreate-backend-demo/aicreate-demo/pom.xml
Normal file
52
lesingle-aicreate-backend-demo/aicreate-demo/pom.xml
Normal 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>
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,2 @@
|
||||
server.port=9090
|
||||
spring.application.name=LeAI-Enterprise-Demo
|
||||
@ -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)换取token,APP_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>
|
||||
65
lesingle-aicreate-backend-demo/aicreate-demo/start.bat
Normal file
65
lesingle-aicreate-backend-demo/aicreate-demo/start.bat
Normal 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
|
||||
235
lesingle-aicreate-backend-demo/enterprise-sim.html
Normal file
235
lesingle-aicreate-backend-demo/enterprise-sim.html
Normal 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)换取token,APP_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>
|
||||
113
lesingle-aicreate-backend-demo/test-iframe.html
Normal file
113
lesingle-aicreate-backend-demo/test-iframe.html
Normal 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>
|
||||
Binary file not shown.
BIN
lesingle-aicreate-client/AI绘本创作系统_企业后端集成指南_V4.0.pdf
Normal file
BIN
lesingle-aicreate-client/AI绘本创作系统_企业后端集成指南_V4.0.pdf
Normal file
Binary file not shown.
@ -9,6 +9,17 @@
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
658
lesingle-aicreate-client/package-lock.json
generated
658
lesingle-aicreate-client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -4,7 +4,7 @@
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "vite --port 3001",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
@ -22,4 +22,4 @@
|
||||
"sass": "^1.80.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1863
lesingle-aicreate-client/pnpm-lock.yaml
generated
Normal file
1863
lesingle-aicreate-client/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
lesingle-aicreate-client/public/config.js
Normal file
44
lesingle-aicreate-client/public/config.js
Normal 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: []
|
||||
}
|
||||
@ -2,9 +2,11 @@ import axios from 'axios'
|
||||
import OSS from 'ali-oss'
|
||||
import { signRequest } from '@/utils/hmac'
|
||||
import { store } from '@/utils/store'
|
||||
import config from '@/utils/config'
|
||||
import bridge from '@/utils/bridge'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
baseURL: config.apiBaseUrl ? config.apiBaseUrl + '/api/v1' : '/api/v1',
|
||||
timeout: 120000
|
||||
})
|
||||
|
||||
@ -27,15 +29,96 @@ api.interceptors.request.use(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(
|
||||
res => {
|
||||
const d = res.data
|
||||
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 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) {
|
||||
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配音 ───
|
||||
export function voicePage(data) {
|
||||
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
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<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>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
@ -14,7 +14,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import bridge from '@/utils/bridge'
|
||||
import StepBar from './StepBar.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
defineProps({
|
||||
title: String,
|
||||
subtitle: String,
|
||||
@ -22,6 +27,17 @@ defineProps({
|
||||
step: { type: Number, default: null },
|
||||
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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@ -3,4 +3,9 @@ import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/global.scss'
|
||||
|
||||
import bridge from '@/utils/bridge'
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
|
||||
// 通知父页面 H5 已加载完毕(iframe 模式下生效)
|
||||
bridge.send('READY')
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import config from '@/utils/config'
|
||||
import { store } from '@/utils/store'
|
||||
import bridge from '@/utils/bridge'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@ -62,18 +65,59 @@ const router = createRouter({
|
||||
]
|
||||
})
|
||||
|
||||
// Auth guard: 双模式检查(sessionToken 或 appSecret 任一存在即可)
|
||||
// Auth guard: 全局 token 初始化 + 双模式认证检查
|
||||
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()
|
||||
return
|
||||
}
|
||||
const hasSession = sessionStorage.getItem('le_sessionToken')
|
||||
const hasHmac = localStorage.getItem('le_appSecret')
|
||||
if (!hasSession && !hasHmac) {
|
||||
next({ name: 'Welcome' })
|
||||
} else {
|
||||
|
||||
// ─── 全局 token 初始化:从 URL query 读取 ───
|
||||
// 支持 iframe src 直接带 token 加载任意页面
|
||||
// 如: /edit-info/190xxx?token=xxx&orgId=xxx&phone=xxx&embed=1
|
||||
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()
|
||||
return
|
||||
}
|
||||
|
||||
// 有 sessionToken 或 appSecret 任一即可通过
|
||||
if (store.sessionToken || store.appSecret) {
|
||||
next()
|
||||
} else {
|
||||
next({ name: 'Welcome' })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
71
lesingle-aicreate-client/src/utils/bridge.js
Normal file
71
lesingle-aicreate-client/src/utils/bridge.js
Normal 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 }
|
||||
22
lesingle-aicreate-client/src/utils/config.js
Normal file
22
lesingle-aicreate-client/src/utils/config.js
Normal 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
|
||||
42
lesingle-aicreate-client/src/utils/status.js
Normal file
42
lesingle-aicreate-client/src/utils/status.js
Normal 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
|
||||
}
|
||||
}
|
||||
@ -48,6 +48,7 @@ export const store = reactive({
|
||||
setSession(orgId, sessionToken) {
|
||||
this.orgId = orgId
|
||||
this.sessionToken = sessionToken
|
||||
localStorage.setItem('le_orgId', orgId)
|
||||
sessionStorage.setItem('le_orgId', orgId)
|
||||
sessionStorage.setItem('le_sessionToken', sessionToken)
|
||||
},
|
||||
@ -62,5 +63,46 @@ export const store = reactive({
|
||||
this.workId = ''
|
||||
this.workDetail = null
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -2,6 +2,9 @@
|
||||
<div class="reader-page" @touchstart="onTouchStart" @touchend="onTouchEnd">
|
||||
<!-- 顶栏 -->
|
||||
<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>
|
||||
|
||||
@ -23,7 +26,7 @@
|
||||
<div class="cover-image" v-else>📖</div>
|
||||
<div class="cover-title">{{ currentPage.text }}</div>
|
||||
<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>
|
||||
|
||||
@ -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>
|
||||
重新阅读
|
||||
</div>
|
||||
<div class="back-brand">乐读派 AI 绘本 · 让想象力飞翔</div>
|
||||
<div class="back-brand">{{ brandName }} AI 绘本 · {{ brandSlogan }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -71,8 +74,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:再次创作 -->
|
||||
<div class="reader-bottom safe-bottom">
|
||||
<!-- 底部:再次创作(仅创作流程入口显示,作品列表入口不显示) -->
|
||||
<div v-if="!fromWorks" class="reader-bottom safe-bottom">
|
||||
<button class="btn-primary" @click="goHome">再次创作 →</button>
|
||||
<div class="bottom-hint">本作品可在作品板块中查看</div>
|
||||
</div>
|
||||
@ -84,9 +87,25 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { store } from '@/utils/store'
|
||||
import { getWorkDetail } from '@/api'
|
||||
import config from '@/utils/config'
|
||||
|
||||
const route = useRoute()
|
||||
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 flipDir = ref(0)
|
||||
|
||||
@ -201,6 +220,7 @@ onMounted(async () => {
|
||||
background: rgba(255,255,255,0.85);
|
||||
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; }
|
||||
|
||||
// 书本区域
|
||||
|
||||
@ -59,6 +59,9 @@ import { useRouter } from 'vue-router'
|
||||
import { Client } from '@stomp/stompjs'
|
||||
import { store } from '@/utils/store'
|
||||
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 progress = ref(0)
|
||||
@ -101,20 +104,24 @@ function sanitizeError(msg) {
|
||||
// 将运维级消息转为用户友好消息(隐藏分组/模型/耗时等内部细节)
|
||||
function friendlyStage(pct, msg) {
|
||||
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('故事') && msg.includes('生成完成')) return '📝 故事编写完成,开始绘图...'
|
||||
if (msg.includes('语音合成')) return '🔊 正在合成语音...'
|
||||
if (msg.includes('美化角色') || 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('绘制') || msg.includes('插画')) return '🎨 正在绘制插画...'
|
||||
if (msg.includes('补生成')) return '🎨 正在绘制插画...'
|
||||
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,页面刷新后可恢复轮询
|
||||
@ -136,8 +143,10 @@ function restoreWorkId() {
|
||||
// ─── WebSocket 实时推送 (首次进入使用) ───
|
||||
const startWebSocket = (workId) => {
|
||||
wsDegraded = false
|
||||
const wsScheme = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const wsUrl = `${wsScheme}://${location.host}/ws/websocket?orgId=${encodeURIComponent(store.orgId)}`
|
||||
const wsBase = config.wsBaseUrl
|
||||
? config.wsBaseUrl
|
||||
: `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`
|
||||
const wsUrl = `${wsBase}/ws/websocket?orgId=${encodeURIComponent(store.orgId)}`
|
||||
|
||||
stompClient = new Client({
|
||||
brokerURL: wsUrl,
|
||||
@ -154,10 +163,13 @@ const startWebSocket = (workId) => {
|
||||
stage.value = '🎉 绘本创作完成!'
|
||||
closeWebSocket()
|
||||
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) {
|
||||
closeWebSocket()
|
||||
saveWorkId('')
|
||||
bridge.send('CREATION_ERROR', { message: sanitizeError(data.message) })
|
||||
error.value = sanitizeError(data.message)
|
||||
}
|
||||
} 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.progressMessage) stage.value = friendlyStage(progress.value, work.progressMessage)
|
||||
|
||||
if (work.status === 'COMPLETED') {
|
||||
// 状态 >= COMPLETED(3) 表示创作已结束,根据具体状态导航
|
||||
if (work.status >= STATUS.COMPLETED) {
|
||||
progress.value = 100
|
||||
stage.value = '🎉 绘本创作完成!'
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
saveWorkId('') // 清除已完成的 workId
|
||||
setTimeout(() => router.replace(`/preview/${workId}`), 800)
|
||||
} else if (work.status === 'FAILED') {
|
||||
saveWorkId('')
|
||||
bridge.send('WORK_COMPLETED', { workId })
|
||||
const route = getRouteByStatus(work.status, workId)
|
||||
if (route) setTimeout(() => router.replace(route), 800)
|
||||
} else if (work.status === STATUS.FAILED) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
saveWorkId('') // 清除已失败的 workId
|
||||
saveWorkId('')
|
||||
bridge.send('CREATION_ERROR', { message: sanitizeError(work.failReason) })
|
||||
error.value = sanitizeError(work.failReason)
|
||||
}
|
||||
} catch {
|
||||
@ -272,6 +288,7 @@ const startCreation = async () => {
|
||||
}
|
||||
|
||||
saveWorkId(workId)
|
||||
bridge.send('WORK_CREATED', { workId })
|
||||
progress.value = 10
|
||||
stage.value = '📝 故事构思中...'
|
||||
// 首次提交:优先 WebSocket 实时推送
|
||||
@ -284,6 +301,7 @@ const startCreation = async () => {
|
||||
stage.value = '📝 创作已提交到后台...'
|
||||
startPolling(store.workId)
|
||||
} else {
|
||||
bridge.send('CREATION_ERROR', { message: sanitizeError(e.message) })
|
||||
error.value = sanitizeError(e.message)
|
||||
submitted = false
|
||||
}
|
||||
@ -313,10 +331,15 @@ onMounted(() => {
|
||||
currentTipIdx.value = (currentTipIdx.value + 1) % creatingTips.length
|
||||
}, 3500)
|
||||
|
||||
// 恢复 localStorage 中的 workId(页面完全刷新后 reactive store 会重置)
|
||||
restoreWorkId()
|
||||
// 恢复 workId:优先从URL参数(作品列表跳入),其次从localStorage(页面刷新)
|
||||
const urlWorkId = new URLSearchParams(window.location.search).get('workId')
|
||||
if (urlWorkId) {
|
||||
saveWorkId(urlWorkId)
|
||||
} else {
|
||||
restoreWorkId()
|
||||
}
|
||||
|
||||
// 如果已有进行中的任务(页面刷新/HMR重载),恢复轮询而非重新提交
|
||||
// 如果已有进行中的任务,恢复轮询而非重新提交
|
||||
if (store.workId) {
|
||||
submitted = true
|
||||
progress.value = 10
|
||||
|
||||
@ -132,8 +132,9 @@
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
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 { STATUS, getRouteByStatus } from '@/utils/status'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@ -374,34 +375,47 @@ async function voiceAllConfirm() {
|
||||
// ─── 完成配音 ───
|
||||
|
||||
async function finish() {
|
||||
const pendingLocal = pages.value.filter(p => p.localBlob)
|
||||
|
||||
if (pendingLocal.length === 0) {
|
||||
router.push(`/read/${workId.value}`)
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
// Step 1: 逐页 STS 直传录音到 OSS
|
||||
const audioPages = []
|
||||
for (let i = 0; i < pendingLocal.length; i++) {
|
||||
const p = pendingLocal[i]
|
||||
showToast(`上传录音 ${i + 1}/${pendingLocal.length}...`)
|
||||
const ext = p.localBlob.type?.includes('webm') ? 'webm' : 'm4a'
|
||||
const ossUrl = await ossUpload(p.localBlob, { type: 'aud', ext })
|
||||
audioPages.push({ pageNum: p.pageNum, audioUrl: ossUrl })
|
||||
p.audioUrl = ossUrl
|
||||
p.localBlob = null
|
||||
const pendingLocal = pages.value.filter(p => p.localBlob)
|
||||
|
||||
if (pendingLocal.length > 0) {
|
||||
// 有本地录音:上传到 OSS,然后通过 batchUpdateAudio 更新DB并推进状态
|
||||
const audioPages = []
|
||||
for (let i = 0; i < pendingLocal.length; i++) {
|
||||
const p = pendingLocal[i]
|
||||
showToast(`上传录音 ${i + 1}/${pendingLocal.length}...`)
|
||||
const ext = p.localBlob.type?.includes('webm') ? 'webm' : 'm4a'
|
||||
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并推进状态 CATALOGED→DUBBED
|
||||
await batchUpdateAudio(workId.value, audioPages)
|
||||
} else {
|
||||
// 无本地录音:仍需调用 C2 API 推进状态 CATALOGED(4)→DUBBED(5)
|
||||
// C2 允许空 pages,调用后状态即推进
|
||||
showToast('完成配音...')
|
||||
await finishDubbing(workId.value)
|
||||
}
|
||||
|
||||
// Step 2: 批量通知后端更新 DB
|
||||
showToast('保存配音...')
|
||||
await batchUpdateAudio(workId.value, audioPages)
|
||||
showToast('配音保存成功')
|
||||
store.workDetail = null // 清除缓存
|
||||
showToast('配音完成!')
|
||||
setTimeout(() => router.push(`/read/${workId.value}`), 800)
|
||||
} 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 {
|
||||
submitting.value = false
|
||||
}
|
||||
@ -417,7 +431,15 @@ async function loadWork() {
|
||||
const res = await getWorkDetail(workId.value)
|
||||
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,
|
||||
text: p.text,
|
||||
imageUrl: p.imageUrl,
|
||||
@ -440,10 +462,12 @@ onBeforeUnmount(() => {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dubbing-page {
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
height: 100dvh; /* 动态视口高度,兼容移动端地址栏 */
|
||||
background: linear-gradient(180deg, #F0F8FF 0%, #FFF5F0 40%, #FFF8E7 70%, #FFFDF7 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.loading-state {
|
||||
flex: 1;
|
||||
@ -478,7 +502,8 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 110px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 页面展示 */
|
||||
@ -773,9 +798,9 @@ onBeforeUnmount(() => {
|
||||
|
||||
/* 底部 */
|
||||
.bottom-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
flex-shrink: 0;
|
||||
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%);
|
||||
}
|
||||
.finish-btn {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="edit-page">
|
||||
<div class="edit-page page-fullscreen">
|
||||
<PageHeader title="编辑绘本信息" subtitle="补充信息让绘本更完整" :showBack="true" />
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
@ -7,7 +7,7 @@
|
||||
<div style="color:var(--text-sub);margin-top:8px">加载中...</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="content">
|
||||
<div v-else class="content page-content">
|
||||
<!-- 封面预览 -->
|
||||
<div class="cover-preview card" v-if="coverUrl">
|
||||
<img :src="coverUrl" class="cover-img" />
|
||||
@ -17,9 +17,10 @@
|
||||
<!-- 基本信息 -->
|
||||
<div class="card form-card">
|
||||
<div class="field-item">
|
||||
<div class="field-label"><span>✍️</span> 作者署名 <span class="optional-mark">选填</span></div>
|
||||
<input v-model="form.author" class="text-input" placeholder="如:宝宝的名字" maxlength="16" />
|
||||
<div class="field-label"><span>✍️</span> 作者署名 <span class="required-mark">必填</span></div>
|
||||
<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>
|
||||
<div v-if="authorError" class="field-error">{{ authorError }}</div>
|
||||
</div>
|
||||
|
||||
<div class="field-item">
|
||||
@ -75,7 +76,7 @@
|
||||
</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">
|
||||
{{ saving ? '保存中...' : '保存绘本 →' }}
|
||||
</button>
|
||||
@ -89,6 +90,7 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import { getWorkDetail, updateWork } from '@/api'
|
||||
import { store } from '@/utils/store'
|
||||
import { STATUS, getRouteByStatus } from '@/utils/status'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@ -144,6 +146,13 @@ async function loadWork() {
|
||||
store.workDetail = res.data
|
||||
}
|
||||
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.subtitle = w.subtitle || ''
|
||||
form.value.intro = w.intro || ''
|
||||
@ -156,11 +165,19 @@ async function loadWork() {
|
||||
}
|
||||
}
|
||||
|
||||
const authorError = ref('')
|
||||
|
||||
async function handleSave() {
|
||||
// 作者署名必填校验
|
||||
if (!form.value.author.trim()) {
|
||||
authorError.value = '请填写作者署名'
|
||||
return
|
||||
}
|
||||
authorError.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
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.intro.trim()) data.intro = form.value.intro.trim()
|
||||
|
||||
@ -174,8 +191,19 @@ async function handleSave() {
|
||||
store.workDetail.tags = [...selectedTags.value]
|
||||
}
|
||||
|
||||
router.push(`/save-success/${workId.value}`)
|
||||
// C1 保存后进入配音
|
||||
store.workDetail = null // 清除缓存
|
||||
router.push(`/dubbing/${workId.value}`)
|
||||
} 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 || '保存失败,请重试')
|
||||
} finally {
|
||||
saving.value = false
|
||||
@ -190,10 +218,7 @@ onMounted(() => {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.edit-page {
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.loading-state {
|
||||
flex: 1;
|
||||
@ -203,13 +228,10 @@ onMounted(() => {
|
||||
justify-content: center;
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.cover-preview {
|
||||
@ -248,6 +270,9 @@ onMounted(() => {
|
||||
}
|
||||
.required-mark { color: var(--primary); 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-inline { display: block; text-align: right; font-size: 11px; color: #94A3B8; margin-top: 2px; }
|
||||
|
||||
@ -342,10 +367,4 @@ onMounted(() => {
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding: 12px 20px 20px;
|
||||
background: linear-gradient(transparent, var(--bg) 20%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="preview-page">
|
||||
<div class="preview-page page-fullscreen">
|
||||
<!-- 顶部 -->
|
||||
<div class="top-bar">
|
||||
<div class="top-title">绘本预览</div>
|
||||
@ -22,7 +22,7 @@
|
||||
|
||||
<!-- 主内容 -->
|
||||
<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 完整展示,不裁切 -->
|
||||
<div class="image-section">
|
||||
<div class="page-badge">{{ pageBadge }}</div>
|
||||
@ -43,7 +43,7 @@
|
||||
<button class="nav-btn" :class="{ invisible: idx >= pages.length - 1 }" @click="next">›</button>
|
||||
</div>
|
||||
|
||||
<!-- 4. 横版卡片网格(2列,填满空白) -->
|
||||
<!-- 4. 横版卡片网格(2列) -->
|
||||
<div class="thumb-grid">
|
||||
<div v-for="(p, i) in pages" :key="i"
|
||||
class="thumb-card" :class="{ active: i === idx }"
|
||||
@ -59,7 +59,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<div class="bottom-bar safe-bottom">
|
||||
<div class="page-bottom">
|
||||
<button class="btn-primary" @click="goEditInfo">下一步: 编辑绘本信息 →</button>
|
||||
</div>
|
||||
</template>
|
||||
@ -71,6 +71,7 @@ import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { getWorkDetail } from '@/api'
|
||||
import { store } from '@/utils/store'
|
||||
import { STATUS, getRouteByStatus } from '@/utils/status'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@ -115,6 +116,13 @@ async function loadWork() {
|
||||
const work = res.data
|
||||
store.workDetail = work
|
||||
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 => ({
|
||||
pageNum: p.pageNum,
|
||||
text: p.text,
|
||||
@ -137,10 +145,7 @@ onMounted(loadWork)
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.preview-page {
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
@ -163,13 +168,7 @@ onMounted(loadWork)
|
||||
.loading-text { margin-top: 12px; color: var(--text-sub); }
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 10px 14px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 80px;
|
||||
padding: 10px 14px 14px;
|
||||
}
|
||||
|
||||
/* 1. 图片区:16:9 完整展示 */
|
||||
@ -324,12 +323,6 @@ onMounted(loadWork)
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding: 12px 20px 20px;
|
||||
background: linear-gradient(transparent, var(--bg) 20%);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
|
||||
@ -114,14 +114,8 @@ const quotaOk = ref(true)
|
||||
const quotaMsg = ref('')
|
||||
let selectedFile = null
|
||||
|
||||
// 进入页面时确保凭证存在(sessionToken 或 appSecret 任一即可)
|
||||
// 进入页面时检查额度
|
||||
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 {
|
||||
await checkQuota()
|
||||
quotaOk.value = true
|
||||
@ -376,11 +370,12 @@ const goNext = async () => {
|
||||
.recognizing-emojis {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.recognizing-emoji {
|
||||
font-size: 32px;
|
||||
font-size: 28px;
|
||||
display: inline-block;
|
||||
animation: emojiPop 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@ -10,8 +10,8 @@
|
||||
<span v-for="(b, i) in ['📕','📗','📘','📙']" :key="i" class="book-icon">{{ b }}</span>
|
||||
</div>
|
||||
<div class="hero-text">
|
||||
<div class="hero-title">乐读派</div>
|
||||
<div class="hero-sub">AI智能儿童绘本创作</div>
|
||||
<div class="hero-title">{{ brandTitle }}</div>
|
||||
<div class="hero-sub">{{ brandSubtitle }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-tag">✨ 拍一张画,AI帮你变成绘本</div>
|
||||
@ -66,9 +66,22 @@
|
||||
|
||||
<!-- 底部(固定) -->
|
||||
<div class="bottom-area safe-bottom">
|
||||
<button class="btn-primary start-btn" @click="handleStart">
|
||||
<span class="btn-icon">🚀</span> 开始创作
|
||||
</button>
|
||||
<!-- Token模式无token时 -->
|
||||
<template v-if="isTokenMode && !store.sessionToken">
|
||||
<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>
|
||||
</div>
|
||||
@ -78,12 +91,12 @@
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { store } from '@/utils/store'
|
||||
import config from '@/utils/config'
|
||||
import bridge from '@/utils/bridge'
|
||||
import { getOrgConfig } from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 测试固定号码,上线后替换为企业登录对接
|
||||
const TEST_PHONE = '18911223344'
|
||||
|
||||
const steps = [
|
||||
{ emoji: '📸', title: '拍照上传', desc: '拍下孩子的画作', color: '#FF6B35' },
|
||||
{ emoji: '🎭', title: '角色提取', desc: 'AI智能识别角色', color: '#6C63FF' },
|
||||
@ -92,39 +105,90 @@ const steps = [
|
||||
]
|
||||
|
||||
// 从 URL 读取会话令牌(企业重定向入口: ?token=sess_xxx&phone=138xxx&orgId=ORG001)
|
||||
onMounted(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const urlToken = params.get('token')
|
||||
const urlOrgId = params.get('orgId')
|
||||
const urlPhone = params.get('phone')
|
||||
onMounted(async () => {
|
||||
// 双来源参数解析:search(iframe src)+ hash(hash路由)
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
const hashQuery = window.location.hash.split('?')[1] || ''
|
||||
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 (!urlOrgId) {
|
||||
console.error('[Welcome] URL缺少orgId参数,企业重定向应包含 ?token=xxx&orgId=yyy&phone=zzz')
|
||||
console.error('[Welcome] URL缺少orgId参数')
|
||||
alert('入口链接缺少机构信息(orgId),请联系管理员')
|
||||
return
|
||||
}
|
||||
// Session token flow: 企业后端已换取token,直接使用
|
||||
store.setSession(urlOrgId, urlToken)
|
||||
if (urlPhone) store.setPhone(urlPhone)
|
||||
// 清理 URL,防止 token 泄露到浏览器历史和 Referer
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
// 清理URL中的token参数(安全)
|
||||
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 + '#/')
|
||||
}
|
||||
}
|
||||
|
||||
// Token模式下无token:尝试获取企业认证URL
|
||||
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 { /* 超时:继续显示提示 */ }
|
||||
}
|
||||
// standalone模式或iframe超时:获取企业认证URL
|
||||
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 = () => {
|
||||
// 测试阶段用固定号码,上线后由企业登录页设置
|
||||
if (!store.phone) store.setPhone(TEST_PHONE)
|
||||
// 无 sessionToken 时用 HMAC 模式(开发调试默认)
|
||||
if (!store.sessionToken && !store.appSecret) {
|
||||
store.setOrg('LESINGLE888888888', 'leai_test_secret_2026_abc123xyz')
|
||||
if (config.isTokenMode) {
|
||||
if (!store.sessionToken) return
|
||||
} else {
|
||||
if (!store.phone && config.dev?.phone) store.setPhone(config.dev.phone)
|
||||
if (!store.sessionToken && !store.appSecret && config.dev) {
|
||||
store.setOrg(config.dev.orgId, config.dev.appSecret)
|
||||
}
|
||||
}
|
||||
store.reset() // 每次开始创作都清空上一次的缓存
|
||||
store.reset()
|
||||
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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@ -25,12 +25,11 @@ export default defineConfig({
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://192.168.1.72:8080',
|
||||
// rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/ws': {
|
||||
target: 'http://192.168.1.72:8080',
|
||||
target: 'http://localhost:8080',
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 前端 URL(iframe src 用) |
|
||||
| API 服务地址 | `__________` | 乐读派后端 API 基地址 |
|
||||
| Android APK | 另行交付 | 已内置上述配置的签名发布包 |
|
||||
| 创作额度 | `__________` 次/周期 | 机构总创作额度(管理后台可调整) |
|
||||
|
||||
> **重要**:以上所有 `__________` 空白项将在正式开通机构后由乐读派填入并发送给客户。请勿使用测试值上线。
|
||||
|
||||
### 2.2 客户提供
|
||||
|
||||
| 项目 | 内容 | 说明 |
|
||||
|------|------|------|
|
||||
| Webhook 接收 URL | `https://客户域名/webhook/leai` | HTTPS,5 秒内返回 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
|
||||
│
|
||||
├── 创作流程 → 调乐读派API(orgId + 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 时重新获取 Token(2小时有效)。创作过程中 Token 过期不影响已提交的创作任务。
|
||||
|
||||
**Q: 客户想修改创作 UI 怎么办?**
|
||||
A: 联系乐读派,我们修改 H5 代码后重新部署。客户不需要改任何代码,iframe 自动加载最新版本。
|
||||
|
||||
**Q: OSS 图片 URL 会过期吗?**
|
||||
A: 不会。图片存储在乐读派 OSS,URL 永久有效(除非作品被删除)。客户可以直接在广场/作品库中使用这些 URL。
|
||||
|
||||
**Q: Android 端需要热更新怎么办?**
|
||||
A: 目前需要重新打包 APK。创作流程的 UI/逻辑更新需乐读派重新打包后交付。
|
||||
|
||||
---
|
||||
|
||||
## 附录: 错误码速查
|
||||
|
||||
| 错误码 | 说明 | 处理 |
|
||||
|--------|------|------|
|
||||
| 200 | 成功 | - |
|
||||
| 10006 | 请求过于频繁 | 降低频率 |
|
||||
| 20002 | 账号锁定(5次密钥错误) | 等10分钟 |
|
||||
| 20010 | 会话令牌无效/过期 | 重新换取 token |
|
||||
| 30001 | 机构不存在 | 检查 orgId |
|
||||
| 30002 | 机构未授权 | 联系乐读派 |
|
||||
| 30003 | 创作额度不足 | 联系乐读派充值 |
|
||||
|
||||
---
|
||||
|
||||
> 乐读派 AI 绘本创作系统 | 企业定制对接指南 V3.1 | 2026-04-03
|
||||
Loading…
Reference in New Issue
Block a user