添加C端AI绘本创作
This commit is contained in:
parent
1003776dd3
commit
15581e04ae
BIN
lesingle-aicreate-client/AI绘本创作系统_企业后端集成指南_V3.1.pdf
Normal file
BIN
lesingle-aicreate-client/AI绘本创作系统_企业后端集成指南_V3.1.pdf
Normal file
Binary file not shown.
175
lesingle-aicreate-client/demo/CreationApi.js
Normal file
175
lesingle-aicreate-client/demo/CreationApi.js
Normal file
@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 乐读派 AI 创作系统 — C 端 API 封装
|
||||
*
|
||||
* 调用流程:
|
||||
* 1. B1 校验额度 → 2. A1/A2/A3 创作 → 3. 进度追踪 → 4. B2 获取结果
|
||||
*
|
||||
* 所有 API 均需 HMAC-SHA256 签名(除 B6 画风列表)
|
||||
*/
|
||||
|
||||
import { generateSignHeaders } from './HmacSigner.js'
|
||||
|
||||
export default class CreationApi {
|
||||
/**
|
||||
* @param {string} serverUrl - 服务器地址
|
||||
* @param {string} appKey - 机构 App Key (orgId)
|
||||
* @param {string} appSecret - 机构密钥
|
||||
*/
|
||||
constructor(serverUrl, appKey, appSecret) {
|
||||
this.serverUrl = serverUrl
|
||||
this.appKey = appKey
|
||||
this.appSecret = appSecret
|
||||
}
|
||||
|
||||
// ─── 通用请求方法 ─────────────────────────────────────────────────
|
||||
|
||||
async _get(path, queryParams = {}) {
|
||||
const signHeaders = generateSignHeaders(this.appKey, this.appSecret, queryParams)
|
||||
const qs = Object.entries(queryParams).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')
|
||||
const url = `${this.serverUrl}${path}${qs ? '?' + qs : ''}`
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { ...signHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
return this._handleResponse(res)
|
||||
}
|
||||
|
||||
async _post(path, body, queryParams = {}) {
|
||||
// POST 的 JSON body 不参与签名,只有 query params 参与
|
||||
const signHeaders = generateSignHeaders(this.appKey, this.appSecret, queryParams)
|
||||
const qs = Object.entries(queryParams).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')
|
||||
const url = `${this.serverUrl}${path}${qs ? '?' + qs : ''}`
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...signHeaders, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
return this._handleResponse(res)
|
||||
}
|
||||
|
||||
async _handleResponse(res) {
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
const json = await res.json()
|
||||
if (json.code !== 200) {
|
||||
const err = new Error(json.msg || 'API Error')
|
||||
err.code = json.code
|
||||
throw err
|
||||
}
|
||||
return json.data
|
||||
}
|
||||
|
||||
// ─── B 类接口(查询) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* B1 - 创作请求校验(检查额度是否充足)
|
||||
* @param {string} orgId
|
||||
* @param {string} phone
|
||||
* @returns {Promise<{valid: boolean, reason?: string, remainQuota?: number}>}
|
||||
*/
|
||||
async validate(orgId, phone) {
|
||||
return this._post('/api/v1/query/validate', { orgId, phone })
|
||||
}
|
||||
|
||||
/**
|
||||
* B2 - 查询作品详情(含实时进度)
|
||||
* @param {string} workId
|
||||
* @returns {Promise<CreationResultVO>}
|
||||
*
|
||||
* CreationResultVO 结构:
|
||||
* {
|
||||
* workId, status, title, author, pages, style,
|
||||
* videoStatus, videoUrl, failReason, durationMs, retryCount,
|
||||
* progress, // 0-100 进度百分比,-1 表示失败
|
||||
* progressMessage, // "正在绘制第3/7页..."
|
||||
* pageList: [{pageNum, text, imageUrl, audioUrl}] // 仅 COMPLETED 时有值
|
||||
* }
|
||||
*/
|
||||
async getWork(workId) {
|
||||
return this._get(`/api/v1/query/work/${workId}`, { orgId: this.appKey })
|
||||
}
|
||||
|
||||
/**
|
||||
* B3 - 作品列表
|
||||
*/
|
||||
async listWorks(phone, page = 1, pageSize = 20) {
|
||||
return this._get('/api/v1/query/works', {
|
||||
orgId: this.appKey, phone, page, pageSize
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* B6 - 画风列表(无需签名)
|
||||
*/
|
||||
async getStyles() {
|
||||
const res = await fetch(`${this.serverUrl}/api/v1/query/styles`)
|
||||
return this._handleResponse(res)
|
||||
}
|
||||
|
||||
// ─── A 类接口(创作) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A1 - 一句话创作
|
||||
* @param {Object} params
|
||||
* @param {string} params.orgId
|
||||
* @param {string} params.phone
|
||||
* @param {string} params.sentence - 故事描述
|
||||
* @param {string} params.style - 画风标识 (如 style_cartoon)
|
||||
* @param {boolean} [params.enableVoice=false] - 是否配音
|
||||
* @param {string} [params.author] - 作者名
|
||||
* @param {number} [params.age] - 适龄
|
||||
* @returns {Promise<{workId: string, status: string}>}
|
||||
*/
|
||||
async createBySentence(params) {
|
||||
return this._post('/api/v1/creation/one-sentence', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* A2 - 四要素创作
|
||||
* @param {Object} params
|
||||
* @param {string} params.orgId
|
||||
* @param {string} params.phone
|
||||
* @param {string} params.time - 时间
|
||||
* @param {string} params.place - 地点
|
||||
* @param {string} params.character - 角色
|
||||
* @param {string} params.event - 事件
|
||||
* @param {string} params.style
|
||||
* @param {boolean} [params.enableVoice=false]
|
||||
* @returns {Promise<{workId: string, status: string}>}
|
||||
*/
|
||||
async createByElements(params) {
|
||||
return this._post('/api/v1/creation/scene-elements', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* A3 - 图片故事创作
|
||||
* @param {Object} params
|
||||
* @param {string} params.orgId
|
||||
* @param {string} params.phone
|
||||
* @param {string} params.imageUrl - 参考图片 URL
|
||||
* @param {string} [params.storyHint] - 故事方向提示
|
||||
* @param {string} params.style
|
||||
* @param {boolean} [params.enableVoice=false]
|
||||
* @param {string} [params.heroCharId] - 主角 charId (来自 A5/A6)
|
||||
* @param {Array<{charId, imageUrl, name}>} [params.characterRefs] - 角色引用列表
|
||||
* @returns {Promise<{workId: string, status: string}>}
|
||||
*/
|
||||
async createByImage(params) {
|
||||
return this._post('/api/v1/creation/image-story', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* A4 - 视频合成
|
||||
* @param {Object} params
|
||||
* @param {string} params.orgId
|
||||
* @param {string} params.phone
|
||||
* @param {string} params.workId
|
||||
* @returns {Promise<{workId: string}>}
|
||||
*/
|
||||
async createVideo(params) {
|
||||
return this._post('/api/v1/creation/video', params)
|
||||
}
|
||||
}
|
||||
280
lesingle-aicreate-client/demo/CreationProgressTracker.js
Normal file
280
lesingle-aicreate-client/demo/CreationProgressTracker.js
Normal file
@ -0,0 +1,280 @@
|
||||
/**
|
||||
* 创作进度追踪器
|
||||
*
|
||||
* 三级通信策略:
|
||||
* Level 1: WebSocket (STOMP) — 首选,实时推送,自动重连
|
||||
* Level 2: B2 HTTP 轮询 — WebSocket 断连期间临时补位
|
||||
* Level 3: Redis 进度数据 — 断连期间进度不丢失,重连后立刻获取最新状态
|
||||
*
|
||||
* 使用示例:
|
||||
* const tracker = new CreationProgressTracker({
|
||||
* serverUrl: 'https://your-server.com',
|
||||
* orgId: 'ORG001',
|
||||
* appKey: 'ORG001',
|
||||
* appSecret: 'your-secret',
|
||||
* workId: '2034987994917769216',
|
||||
* onProgress: (progress, message) => {
|
||||
* console.log(`${progress}% - ${message}`)
|
||||
* },
|
||||
* onComplete: (result) => {
|
||||
* // result.status === 'COMPLETED' => result.pageList 包含完整作品
|
||||
* // result.status === 'FAILED' => result.failReason 包含失败原因
|
||||
* },
|
||||
* onError: (error) => {
|
||||
* console.error('Tracker error:', error)
|
||||
* }
|
||||
* })
|
||||
* tracker.start()
|
||||
*
|
||||
* // 用户退出页面时
|
||||
* tracker.stop()
|
||||
*/
|
||||
|
||||
import SockJS from 'sockjs-client/dist/sockjs.min.js'
|
||||
import { Client } from '@stomp/stompjs'
|
||||
import { generateSignHeaders } from './HmacSigner.js'
|
||||
|
||||
export default class CreationProgressTracker {
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {string} options.serverUrl - 服务器地址 (如 https://your-server.com)
|
||||
* @param {string} options.orgId - 机构ID
|
||||
* @param {string} options.appKey - HMAC 签名 App Key (通常等于 orgId)
|
||||
* @param {string} options.appSecret - HMAC 签名密钥
|
||||
* @param {string} options.workId - 作品ID
|
||||
* @param {Function} options.onProgress - 进度回调 (progress: number, message: string)
|
||||
* @param {Function} options.onComplete - 完成回调 (result: CreationResultVO)
|
||||
* @param {Function} [options.onError] - 错误回调 (error: Error)
|
||||
* @param {number} [options.pollInterval=5000] - B2 轮询间隔 (ms)
|
||||
* @param {number} [options.reconnectDelay=3000] - WebSocket 重连延迟 (ms)
|
||||
* @param {number} [options.maxPollTimeout=600000] - 最大轮询时间 (ms),默认10分钟
|
||||
*/
|
||||
constructor(options) {
|
||||
this.serverUrl = options.serverUrl
|
||||
this.orgId = options.orgId
|
||||
this.appKey = options.appKey
|
||||
this.appSecret = options.appSecret
|
||||
this.workId = options.workId
|
||||
this.onProgress = options.onProgress || (() => {})
|
||||
this.onComplete = options.onComplete || (() => {})
|
||||
this.onError = options.onError || (() => {})
|
||||
this.pollInterval = options.pollInterval || 5000
|
||||
this.reconnectDelay = options.reconnectDelay || 3000
|
||||
this.maxPollTimeout = options.maxPollTimeout || 600000
|
||||
|
||||
this._stompClient = null
|
||||
this._pollTimer = null
|
||||
this._wsConnected = false
|
||||
this._stopped = false
|
||||
this._startTime = 0
|
||||
this._lastProgress = -1
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动进度追踪
|
||||
*/
|
||||
start() {
|
||||
this._stopped = false
|
||||
this._startTime = Date.now()
|
||||
|
||||
// 尝试 WebSocket 连接
|
||||
this._connectWebSocket()
|
||||
|
||||
// 安全网:5秒内 WS 未连上则启动轮询
|
||||
setTimeout(() => {
|
||||
if (!this._wsConnected && !this._stopped) {
|
||||
this._startPolling()
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止追踪(用户离开页面时调用)
|
||||
*/
|
||||
stop() {
|
||||
this._stopped = true
|
||||
this._stopPolling()
|
||||
this._disconnectWebSocket()
|
||||
}
|
||||
|
||||
// ─── WebSocket (Level 1) ──────────────────────────────────────────
|
||||
|
||||
_connectWebSocket() {
|
||||
if (this._stopped) return
|
||||
|
||||
try {
|
||||
const wsUrl = `${this.serverUrl}/ws?orgId=${this.orgId}`
|
||||
|
||||
this._stompClient = new Client({
|
||||
webSocketFactory: () => new SockJS(wsUrl),
|
||||
reconnectDelay: this.reconnectDelay,
|
||||
heartbeatIncoming: 10000,
|
||||
heartbeatOutgoing: 10000,
|
||||
|
||||
onConnect: () => {
|
||||
this._wsConnected = true
|
||||
this._stopPolling() // WS 连上了,停止轮询
|
||||
|
||||
// 订阅进度 topic
|
||||
this._stompClient.subscribe(
|
||||
`/topic/progress/${this.workId}`,
|
||||
(msg) => {
|
||||
try {
|
||||
const data = JSON.parse(msg.body)
|
||||
this._handleProgressData(data)
|
||||
} catch (e) {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 重连成功后,立即查一次 B2 获取断连期间可能错过的最终状态
|
||||
this._pollOnce()
|
||||
},
|
||||
|
||||
onDisconnect: () => {
|
||||
this._wsConnected = false
|
||||
if (!this._stopped) {
|
||||
this._startPolling() // WS 断了,启动轮询补位
|
||||
}
|
||||
},
|
||||
|
||||
onStompError: (frame) => {
|
||||
this._wsConnected = false
|
||||
if (!this._stopped) {
|
||||
this._startPolling()
|
||||
}
|
||||
},
|
||||
|
||||
onWebSocketError: () => {
|
||||
this._wsConnected = false
|
||||
if (!this._stopped) {
|
||||
this._startPolling()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this._stompClient.activate()
|
||||
} catch (e) {
|
||||
// WebSocket 不可用(如被防火墙阻断),降级为纯轮询
|
||||
this._startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
_disconnectWebSocket() {
|
||||
if (this._stompClient) {
|
||||
try {
|
||||
this._stompClient.deactivate()
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
this._stompClient = null
|
||||
}
|
||||
this._wsConnected = false
|
||||
}
|
||||
|
||||
// ─── B2 HTTP 轮询 (Level 2) ───────────────────────────────────────
|
||||
|
||||
_startPolling() {
|
||||
if (this._pollTimer || this._stopped) return
|
||||
this._pollTimer = setInterval(() => this._pollOnce(), this.pollInterval)
|
||||
}
|
||||
|
||||
_stopPolling() {
|
||||
if (this._pollTimer) {
|
||||
clearInterval(this._pollTimer)
|
||||
this._pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async _pollOnce() {
|
||||
if (this._stopped) return
|
||||
|
||||
// 超时保护
|
||||
if (Date.now() - this._startTime > this.maxPollTimeout) {
|
||||
this.stop()
|
||||
this.onError(new Error('进度查询超时'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const queryParams = { orgId: this.orgId }
|
||||
const signHeaders = generateSignHeaders(this.appKey, this.appSecret, queryParams)
|
||||
|
||||
const res = await fetch(
|
||||
`${this.serverUrl}/api/v1/query/work/${this.workId}?orgId=${this.orgId}`,
|
||||
{ headers: { ...signHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
|
||||
if (!res.ok) return
|
||||
|
||||
const json = await res.json()
|
||||
if (json.code !== 200 || !json.data) return
|
||||
|
||||
const data = json.data
|
||||
|
||||
// 推送进度
|
||||
if (data.progress != null && data.progress !== this._lastProgress) {
|
||||
this._handleProgressData({
|
||||
progress: data.progress,
|
||||
message: data.progressMessage || ''
|
||||
})
|
||||
}
|
||||
|
||||
// 检查是否终态
|
||||
if (data.status === 'COMPLETED' || data.status === 'FAILED') {
|
||||
this.stop()
|
||||
this.onComplete(data)
|
||||
}
|
||||
} catch (e) {
|
||||
// 网络错误,不中断轮询
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 进度处理 ─────────────────────────────────────────────────────
|
||||
|
||||
_handleProgressData(data) {
|
||||
if (this._stopped) return
|
||||
|
||||
const progress = data.progress != null ? data.progress : 0
|
||||
const message = data.message || ''
|
||||
|
||||
// 去重:相同进度不重复回调
|
||||
if (progress === this._lastProgress) return
|
||||
this._lastProgress = progress
|
||||
|
||||
// 回调
|
||||
this.onProgress(progress, message)
|
||||
|
||||
// 终态检测(WebSocket 推送的 progress=100 或 -1)
|
||||
if (progress === 100) {
|
||||
// 完成,再查一次 B2 获取完整数据(含 pageList)
|
||||
setTimeout(() => this._fetchFinalResult(), 1000)
|
||||
} else if (progress === -1) {
|
||||
// 失败,查一次 B2 获取 failReason
|
||||
setTimeout(() => this._fetchFinalResult(), 1000)
|
||||
}
|
||||
}
|
||||
|
||||
async _fetchFinalResult() {
|
||||
try {
|
||||
const queryParams = { orgId: this.orgId }
|
||||
const signHeaders = generateSignHeaders(this.appKey, this.appSecret, queryParams)
|
||||
|
||||
const res = await fetch(
|
||||
`${this.serverUrl}/api/v1/query/work/${this.workId}?orgId=${this.orgId}`,
|
||||
{ headers: { ...signHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
|
||||
if (!res.ok) return
|
||||
|
||||
const json = await res.json()
|
||||
if (json.code === 200 && json.data) {
|
||||
this.stop()
|
||||
this.onComplete(json.data)
|
||||
}
|
||||
} catch (e) {
|
||||
this.onError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
31
lesingle-aicreate-client/demo/HmacSigner.js
Normal file
31
lesingle-aicreate-client/demo/HmacSigner.js
Normal file
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* HMAC-SHA256 签名工具
|
||||
*
|
||||
* 签名规则:
|
||||
* 1. 将 GET 的 Query 参数 + nonce + timestamp 按 key 字母序排列
|
||||
* 2. 格式: key1=value1&key2=value2&...
|
||||
* 3. POST/PUT 的 JSON body 不参与签名
|
||||
* 4. 使用 HMAC-SHA256 算法,以 appSecret 为密钥,输出 hex 小写
|
||||
*/
|
||||
|
||||
import CryptoJS from 'crypto-js'
|
||||
|
||||
export function generateSignHeaders(appKey, appSecret, queryParams = {}) {
|
||||
const timestamp = Date.now().toString()
|
||||
const nonce = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)
|
||||
|
||||
// Build signature string: sorted query params + nonce + timestamp
|
||||
const signParams = { ...queryParams, nonce, timestamp }
|
||||
const sortedKeys = Object.keys(signParams).sort()
|
||||
const signString = sortedKeys.map(k => `${k}=${signParams[k]}`).join('&')
|
||||
|
||||
// HMAC-SHA256
|
||||
const signature = CryptoJS.HmacSHA256(signString, appSecret).toString(CryptoJS.enc.Hex)
|
||||
|
||||
return {
|
||||
'X-App-Key': appKey,
|
||||
'X-Timestamp': timestamp,
|
||||
'X-Nonce': nonce,
|
||||
'X-Signature': signature
|
||||
}
|
||||
}
|
||||
94
lesingle-aicreate-client/demo/README.md
Normal file
94
lesingle-aicreate-client/demo/README.md
Normal file
@ -0,0 +1,94 @@
|
||||
# C 端接入 Demo
|
||||
|
||||
乐读派 AI 创作系统 C 端接入参考代码。
|
||||
|
||||
## 文件说明
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `HmacSigner.js` | HMAC-SHA256 签名工具(所有 C 端 API 均需签名) |
|
||||
| `CreationApi.js` | API 封装(A1-A4 创作 + B1-B6 查询) |
|
||||
| `CreationProgressTracker.js` | 创作进度追踪器(WebSocket + HTTP 双通道) |
|
||||
| `example-usage.js` | 完整接入示例(4 个场景) |
|
||||
|
||||
## 快速接入
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
npm install crypto-js sockjs-client @stomp/stompjs
|
||||
```
|
||||
|
||||
### 2. 最简接入(纯 HTTP 轮询)
|
||||
|
||||
```javascript
|
||||
import CreationApi from './CreationApi.js'
|
||||
|
||||
const api = new CreationApi('https://server.com', 'ORG001', 'your-secret')
|
||||
|
||||
// 创作
|
||||
const { workId } = await api.createBySentence({
|
||||
orgId: 'ORG001', phone: '13800138000',
|
||||
sentence: '小恐龙学游泳', style: 'style_cartoon'
|
||||
})
|
||||
|
||||
// 轮询直到完成
|
||||
const poll = setInterval(async () => {
|
||||
const data = await api.getWork(workId)
|
||||
console.log(`${data.progress}% - ${data.progressMessage}`)
|
||||
if (data.status === 'COMPLETED' || data.status === 'FAILED') {
|
||||
clearInterval(poll)
|
||||
console.log(data.status === 'COMPLETED' ? data.pageList : data.failReason)
|
||||
}
|
||||
}, 5000)
|
||||
```
|
||||
|
||||
### 3. 最佳体验(WebSocket + 自动降级)
|
||||
|
||||
```javascript
|
||||
import CreationApi from './CreationApi.js'
|
||||
import CreationProgressTracker from './CreationProgressTracker.js'
|
||||
|
||||
const api = new CreationApi('https://server.com', 'ORG001', 'your-secret')
|
||||
const { workId } = await api.createBySentence({ ... })
|
||||
|
||||
const tracker = new CreationProgressTracker({
|
||||
serverUrl: 'https://server.com',
|
||||
orgId: 'ORG001', appKey: 'ORG001', appSecret: 'your-secret',
|
||||
workId,
|
||||
onProgress: (pct, msg) => updateUI(pct, msg),
|
||||
onComplete: (result) => showResult(result)
|
||||
})
|
||||
tracker.start()
|
||||
|
||||
// 用户离开页面时
|
||||
tracker.stop()
|
||||
```
|
||||
|
||||
## 接口清单
|
||||
|
||||
| 接口 | 方法 | 路径 | 签名 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| B1 | POST | /api/v1/query/validate | 需要 | 校验额度 |
|
||||
| B2 | GET | /api/v1/query/work/{workId} | 需要 | 作品详情+实时进度 |
|
||||
| B3 | GET | /api/v1/query/works | 需要 | 作品列表 |
|
||||
| B6 | GET | /api/v1/query/styles | 不需要 | 画风列表 |
|
||||
| A1 | POST | /api/v1/creation/one-sentence | 需要 | 一句话创作 |
|
||||
| A2 | POST | /api/v1/creation/scene-elements | 需要 | 四要素创作 |
|
||||
| A3 | POST | /api/v1/creation/image-story | 需要 | 图片故事创作 |
|
||||
| A4 | POST | /api/v1/creation/video | 需要 | 视频合成 |
|
||||
|
||||
## 进度追踪策略
|
||||
|
||||
```
|
||||
WebSocket 连接成功
|
||||
│ 实时推送 progress + message
|
||||
│
|
||||
├─ 断连 → 自动重连(3秒间隔)
|
||||
│ 同时启动 B2 HTTP 轮询补位(5秒间隔)
|
||||
│
|
||||
├─ 重连成功 → 停止轮询,恢复 WebSocket
|
||||
│ 立即查一次 B2 补齐断连期间状态
|
||||
│
|
||||
└─ progress=100 或 -1 → 查 B2 获取完整数据 → onComplete 回调
|
||||
```
|
||||
197
lesingle-aicreate-client/demo/example-usage.js
Normal file
197
lesingle-aicreate-client/demo/example-usage.js
Normal file
@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 乐读派 AI 创作系统 — C 端接入完整示例
|
||||
*
|
||||
* 依赖安装:
|
||||
* npm install crypto-js sockjs-client @stomp/stompjs
|
||||
*
|
||||
* 本示例演示完整的创作流程:
|
||||
* 1. 校验额度
|
||||
* 2. 获取画风列表
|
||||
* 3. 发起 A1/A3 创作
|
||||
* 4. WebSocket + HTTP 轮询双通道追踪进度
|
||||
* 5. 获取创作结果
|
||||
*/
|
||||
|
||||
import CreationApi from './CreationApi.js'
|
||||
import CreationProgressTracker from './CreationProgressTracker.js'
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// 配置(替换为实际值)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
const CONFIG = {
|
||||
serverUrl: 'https://your-server.com', // 替换为实际服务器地址
|
||||
appKey: 'ORG001', // 机构 App Key
|
||||
appSecret: 'your-app-secret', // 机构密钥(联系管理员获取)
|
||||
phone: '13800138000' // 用户手机号
|
||||
}
|
||||
|
||||
const api = new CreationApi(CONFIG.serverUrl, CONFIG.appKey, CONFIG.appSecret)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// 示例 1: A1 一句话创作(最简单的接入方式)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
async function exampleA1Creation() {
|
||||
console.log('=== A1 一句话创作 ===')
|
||||
|
||||
// Step 1: 校验额度
|
||||
const validation = await api.validate(CONFIG.appKey, CONFIG.phone)
|
||||
if (!validation.valid) {
|
||||
console.error('额度不足:', validation.reason)
|
||||
return
|
||||
}
|
||||
console.log('额度校验通过,剩余:', validation.remainQuota)
|
||||
|
||||
// Step 2: 获取画风列表
|
||||
const styles = await api.getStyles()
|
||||
console.log('可用画风:', styles.map(s => s.styleName).join(', '))
|
||||
|
||||
// Step 3: 发起创作
|
||||
const result = await api.createBySentence({
|
||||
orgId: CONFIG.appKey,
|
||||
phone: CONFIG.phone,
|
||||
sentence: '小恐龙学游泳',
|
||||
style: 'style_cartoon',
|
||||
enableVoice: false,
|
||||
author: '小朋友',
|
||||
age: 5
|
||||
})
|
||||
console.log('创作已提交,workId:', result.workId)
|
||||
|
||||
// Step 4: 追踪进度(WebSocket + HTTP 双通道)
|
||||
const tracker = new CreationProgressTracker({
|
||||
serverUrl: CONFIG.serverUrl,
|
||||
orgId: CONFIG.appKey,
|
||||
appKey: CONFIG.appKey,
|
||||
appSecret: CONFIG.appSecret,
|
||||
workId: result.workId,
|
||||
|
||||
onProgress: (progress, message) => {
|
||||
// 更新 UI 进度条
|
||||
console.log(`[${progress}%] ${message}`)
|
||||
// updateProgressBar(progress)
|
||||
// updateStatusText(message)
|
||||
},
|
||||
|
||||
onComplete: (data) => {
|
||||
if (data.status === 'COMPLETED') {
|
||||
console.log('创作完成!')
|
||||
console.log('标题:', data.title)
|
||||
console.log('页数:', data.pageList.length)
|
||||
console.log('耗时:', Math.round(data.durationMs / 1000), '秒')
|
||||
|
||||
// 展示绘本
|
||||
data.pageList.forEach(page => {
|
||||
console.log(` P${page.pageNum}: ${page.text}`)
|
||||
console.log(` 图片: ${page.imageUrl}`)
|
||||
if (page.audioUrl) console.log(` 音频: ${page.audioUrl}`)
|
||||
})
|
||||
} else {
|
||||
console.error('创作失败:', data.failReason)
|
||||
if (data.retryCount > 0) {
|
||||
console.log(`(已自动重试 ${data.retryCount} 次)`)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onError: (err) => {
|
||||
console.error('追踪异常:', err.message)
|
||||
}
|
||||
})
|
||||
|
||||
tracker.start()
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// 示例 2: A3 图片故事创作(带角色引用)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
async function exampleA3Creation() {
|
||||
console.log('=== A3 图片故事创作 ===')
|
||||
|
||||
const result = await api.createByImage({
|
||||
orgId: CONFIG.appKey,
|
||||
phone: CONFIG.phone,
|
||||
imageUrl: 'https://your-oss.com/upload/child-drawing.png',
|
||||
storyHint: '小兔子去森林冒险',
|
||||
style: 'style_watercolor',
|
||||
enableVoice: true,
|
||||
// 可选: 从 A5/A6 提取的角色引用
|
||||
// heroCharId: 'char_123',
|
||||
// characterRefs: [{ charId: 'char_123', imageUrl: 'https://...', name: '小兔子' }]
|
||||
})
|
||||
|
||||
console.log('A3 创作已提交,workId:', result.workId)
|
||||
|
||||
// 使用 tracker 追踪进度(同示例1)
|
||||
const tracker = new CreationProgressTracker({
|
||||
serverUrl: CONFIG.serverUrl,
|
||||
orgId: CONFIG.appKey,
|
||||
appKey: CONFIG.appKey,
|
||||
appSecret: CONFIG.appSecret,
|
||||
workId: result.workId,
|
||||
onProgress: (p, m) => console.log(`[${p}%] ${m}`),
|
||||
onComplete: (data) => console.log('完成:', data.status, data.title)
|
||||
})
|
||||
tracker.start()
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// 示例 3: 纯 B2 轮询(不使用 WebSocket,最简单接入)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
async function exampleSimplePolling(workId) {
|
||||
console.log('=== 纯 HTTP 轮询模式 ===')
|
||||
|
||||
const poll = async () => {
|
||||
const data = await api.getWork(workId)
|
||||
console.log(`[${data.progress}%] ${data.progressMessage}`)
|
||||
|
||||
if (data.status === 'COMPLETED') {
|
||||
console.log('完成! 标题:', data.title, '页数:', data.pageList.length)
|
||||
return
|
||||
}
|
||||
if (data.status === 'FAILED') {
|
||||
console.error('失败:', data.failReason)
|
||||
return
|
||||
}
|
||||
|
||||
// 继续轮询
|
||||
setTimeout(poll, 5000)
|
||||
}
|
||||
|
||||
poll()
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// 示例 4: A4 视频合成(基于已完成的作品)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
async function exampleVideoCreation(workId) {
|
||||
console.log('=== A4 视频合成 ===')
|
||||
|
||||
const result = await api.createVideo({
|
||||
orgId: CONFIG.appKey,
|
||||
phone: CONFIG.phone,
|
||||
workId: workId
|
||||
})
|
||||
|
||||
console.log('视频合成已提交,轮询视频状态...')
|
||||
|
||||
const poll = async () => {
|
||||
const data = await api.getWork(workId)
|
||||
if (data.videoStatus === 'VIDEO_COMPLETED') {
|
||||
console.log('视频完成:', data.videoUrl)
|
||||
return
|
||||
}
|
||||
if (data.videoStatus === 'VIDEO_FAILED') {
|
||||
console.error('视频失败:', data.failReason)
|
||||
return
|
||||
}
|
||||
setTimeout(poll, 5000)
|
||||
}
|
||||
|
||||
poll()
|
||||
}
|
||||
|
||||
// 运行示例
|
||||
// exampleA1Creation()
|
||||
// exampleA3Creation()
|
||||
// exampleSimplePolling('your-work-id')
|
||||
// exampleVideoCreation('your-work-id')
|
||||
14
lesingle-aicreate-client/index.html
Normal file
14
lesingle-aicreate-client/index.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#FF6B35" />
|
||||
<title>乐读派 - AI智能儿童绘本创作</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
2874
lesingle-aicreate-client/package-lock.json
generated
Normal file
2874
lesingle-aicreate-client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
lesingle-aicreate-client/package.json
Normal file
25
lesingle-aicreate-client/package.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "lesingle-aicreate-client",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stomp/stompjs": "^7.3.0",
|
||||
"ali-oss": "^6.23.0",
|
||||
"axios": "^1.7.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"sass": "^1.80.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
28
lesingle-aicreate-client/src/App.vue
Normal file
28
lesingle-aicreate-client/src/App.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="slide" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.app-shell {
|
||||
max-width: 430px;
|
||||
height: 100dvh;
|
||||
height: 100vh;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.slide-enter-from { opacity: 0; transform: translateX(30px); }
|
||||
.slide-leave-to { opacity: 0; transform: translateX(-30px); }
|
||||
</style>
|
||||
206
lesingle-aicreate-client/src/api/index.js
Normal file
206
lesingle-aicreate-client/src/api/index.js
Normal file
@ -0,0 +1,206 @@
|
||||
import axios from 'axios'
|
||||
import OSS from 'ali-oss'
|
||||
import { signRequest } from '@/utils/hmac'
|
||||
import { store } from '@/utils/store'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 120000
|
||||
})
|
||||
|
||||
// 双模式认证拦截器:
|
||||
// 1. 有 sessionToken → Bearer Token(企业生产环境,通过 auth/session 换取)
|
||||
// 2. 有 appSecret → HMAC 签名(开发调试 / 直连后端模式)
|
||||
api.interceptors.request.use(config => {
|
||||
if (store.sessionToken) {
|
||||
config.headers['Authorization'] = 'Bearer ' + store.sessionToken
|
||||
} else if (store.orgId && store.appSecret) {
|
||||
const queryParams = {}
|
||||
if (config.params) {
|
||||
Object.entries(config.params).forEach(([k, v]) => {
|
||||
if (v != null) queryParams[k] = String(v)
|
||||
})
|
||||
}
|
||||
const headers = signRequest(store.orgId, store.appSecret, queryParams)
|
||||
Object.assign(config.headers, headers)
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
res => {
|
||||
const d = res.data
|
||||
if (d?.code !== 0 && d?.code !== 200) {
|
||||
return Promise.reject(new Error(d?.msg || '请求失败'))
|
||||
}
|
||||
return d
|
||||
},
|
||||
err => Promise.reject(err)
|
||||
)
|
||||
|
||||
// ─── 图片上传 ───
|
||||
export function uploadImage(file) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api.post('/creation/upload', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 30000
|
||||
})
|
||||
}
|
||||
|
||||
// ─── A6 角色提取(JSON方式,传 imageUrl) ───
|
||||
export function extractCharacters(imageUrl, { saveOriginal, title } = {}) {
|
||||
const body = {
|
||||
orgId: store.orgId,
|
||||
phone: store.phone,
|
||||
imageUrl
|
||||
}
|
||||
if (saveOriginal) body.saveOriginal = true
|
||||
if (title) body.title = title
|
||||
return api.post('/creation/extract-original', body, { timeout: 120000 })
|
||||
}
|
||||
|
||||
// ─── 批量更新配音URL(录音直传OSS后调用) ───
|
||||
export function batchUpdateAudio(workId, pages) {
|
||||
return api.post('/update/batch-audio', {
|
||||
orgId: store.orgId,
|
||||
phone: store.phone,
|
||||
workId,
|
||||
pages
|
||||
})
|
||||
}
|
||||
|
||||
// ─── A3 图片故事创作 ───
|
||||
export function createStory({ imageUrl, storyHint, style, title, author, heroCharId, extractId, refAdaptMode = 'enhanced' }) {
|
||||
const body = {
|
||||
orgId: store.orgId,
|
||||
phone: store.phone,
|
||||
imageUrl,
|
||||
storyHint,
|
||||
style,
|
||||
title,
|
||||
refAdaptMode,
|
||||
enableVoice: false
|
||||
}
|
||||
if (author) body.author = author
|
||||
if (heroCharId) body.heroCharId = heroCharId
|
||||
if (extractId) body.extractId = extractId
|
||||
return api.post('/creation/image-story', body)
|
||||
}
|
||||
|
||||
// ─── B2 查询作品详情 ───
|
||||
export function getWorkDetail(workId) {
|
||||
return api.get(`/query/work/${workId}`, {
|
||||
params: { orgId: store.orgId, phone: store.phone }
|
||||
})
|
||||
}
|
||||
|
||||
// ─── B1 额度校验 ───
|
||||
export function checkQuota() {
|
||||
return api.post('/query/validate', {
|
||||
orgId: store.orgId,
|
||||
phone: store.phone,
|
||||
type: 'A'
|
||||
})
|
||||
}
|
||||
|
||||
// ─── C1 编辑绘本信息 ───
|
||||
export function updateWork(workId, data) {
|
||||
return api.put(`/update/work/${workId}`, data)
|
||||
}
|
||||
|
||||
// ─── A20 AI配音 ───
|
||||
export function voicePage(data) {
|
||||
return api.post('/creation/voice', {
|
||||
orgId: store.orgId,
|
||||
phone: store.phone,
|
||||
...data
|
||||
}, { timeout: 120000 })
|
||||
}
|
||||
|
||||
// ─── STS 临时凭证 ───
|
||||
export function getStsToken() {
|
||||
return api.post('/oss/sts-token', {
|
||||
orgId: store.orgId,
|
||||
phone: store.phone
|
||||
})
|
||||
}
|
||||
|
||||
// ─── OSS 直传(STS 模式) ───
|
||||
let _ossClient = null
|
||||
let _stsData = null
|
||||
|
||||
async function getOssClient() {
|
||||
// 复用未过期的 client
|
||||
if (_ossClient && _stsData) {
|
||||
const expireTime = new Date(_stsData.expiration).getTime()
|
||||
if (Date.now() < expireTime - 5 * 60 * 1000) {
|
||||
return { client: _ossClient, prefix: _stsData.uploadPrefix }
|
||||
}
|
||||
}
|
||||
const res = await getStsToken()
|
||||
_stsData = res.data
|
||||
_ossClient = new OSS({
|
||||
region: _stsData.region,
|
||||
accessKeyId: _stsData.accessKeyId,
|
||||
accessKeySecret: _stsData.accessKeySecret,
|
||||
stsToken: _stsData.securityToken,
|
||||
bucket: _stsData.bucket,
|
||||
endpoint: _stsData.endpoint,
|
||||
refreshSTSToken: async () => {
|
||||
const r = await getStsToken()
|
||||
_stsData = r.data
|
||||
return {
|
||||
accessKeyId: _stsData.accessKeyId,
|
||||
accessKeySecret: _stsData.accessKeySecret,
|
||||
stsToken: _stsData.securityToken
|
||||
}
|
||||
},
|
||||
refreshSTSTokenInterval: 300000
|
||||
})
|
||||
return { client: _ossClient, prefix: _stsData.uploadPrefix }
|
||||
}
|
||||
|
||||
/**
|
||||
* STS 直传文件到 OSS
|
||||
* @param {File|Blob} file - 要上传的文件
|
||||
* @param {Object} [opts] - 选项
|
||||
* @param {string} [opts.type='img'] - 文件类型前缀: img=图片, aud=音频
|
||||
* @param {Function} [opts.onProgress] - 进度回调 (0-100)
|
||||
* @param {string} [opts.ext] - 强制指定扩展名(Blob 无 name 时使用)
|
||||
* @returns {Promise<string>} 文件的完整 OSS URL
|
||||
*/
|
||||
export async function ossUpload(file, opts = {}) {
|
||||
const { type = 'img', onProgress, ext: forceExt } = opts
|
||||
const { client, prefix } = await getOssClient()
|
||||
const ext = forceExt || (file.name ? file.name.split('.').pop() : 'bin').toLowerCase()
|
||||
const date = new Date().toISOString().slice(0, 10)
|
||||
const rand = Math.random().toString(36).slice(2, 10)
|
||||
const key = `${prefix}${date}/${type}_${Date.now()}_${rand}.${ext}`
|
||||
await client.put(key, file, {
|
||||
headers: { 'x-oss-object-acl': 'public-read' },
|
||||
progress: (p) => { if (onProgress) onProgress(Math.round(p * 100)) }
|
||||
})
|
||||
// 返回完整 URL
|
||||
if (_stsData.cdnDomain) {
|
||||
return `${_stsData.cdnDomain}/${key}`
|
||||
}
|
||||
return `https://${_stsData.bucket}.${_stsData.endpoint.replace('https://', '')}/${key}`
|
||||
}
|
||||
|
||||
/**
|
||||
* STS 列举用户目录下的文件
|
||||
* @returns {Promise<Array>} 文件列表 [{name, size, lastModified, url}]
|
||||
*/
|
||||
export async function ossListFiles() {
|
||||
const { client, prefix } = await getOssClient()
|
||||
const result = await client.list({ prefix, 'max-keys': 100 })
|
||||
return (result.objects || []).map(obj => ({
|
||||
name: obj.name.replace(prefix, ''),
|
||||
size: obj.size,
|
||||
lastModified: obj.lastModified,
|
||||
url: obj.url
|
||||
}))
|
||||
}
|
||||
|
||||
export default api
|
||||
139
lesingle-aicreate-client/src/assets/global.scss
Normal file
139
lesingle-aicreate-client/src/assets/global.scss
Normal file
@ -0,0 +1,139 @@
|
||||
// 乐读派 C端 — 全局样式
|
||||
// 暖橙 + 奶油白 儿童绘本风格
|
||||
|
||||
:root {
|
||||
--primary: #FF6B35;
|
||||
--primary-light: #FFF0E8;
|
||||
--secondary: #6C63FF;
|
||||
--accent: #FFD166;
|
||||
--success: #2EC4B6;
|
||||
--bg: #FFFDF7;
|
||||
--card: #FFFFFF;
|
||||
--text: #2D2D3F;
|
||||
--text-sub: #8E8EA0;
|
||||
--border: #F0EDE8;
|
||||
--radius: 20px;
|
||||
--radius-sm: 14px;
|
||||
--shadow: 0 8px 32px rgba(255, 107, 53, 0.12);
|
||||
--shadow-soft: 0 4px 20px rgba(0, 0, 0, 0.06);
|
||||
--gradient: linear-gradient(135deg, #FF6B35 0%, #FF8F65 50%, #FFB088 100%);
|
||||
--gradient-purple: linear-gradient(135deg, #6C63FF 0%, #9B93FF 100%);
|
||||
--font: 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: #F0ECE3;
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
input, textarea, select, button {
|
||||
font-family: var(--font);
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
// 通用按钮
|
||||
.btn-primary {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 16px 0;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
background: var(--gradient);
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@extend .btn-primary;
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
// 通用卡片
|
||||
.card {
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
// 安全区底部
|
||||
.safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom, 20px);
|
||||
}
|
||||
|
||||
// ═══ 全屏自适应布局框架 ═══
|
||||
// 解决移动端页面内容超出屏幕、底部按钮被推出视口的问题
|
||||
// 用法: 页面根元素加 .page-fullscreen,底部按钮区域加 .page-bottom
|
||||
|
||||
.page-fullscreen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh; // 移动端动态视口高度(排除地址栏)
|
||||
height: 100vh; // 降级:不支持 dvh 的浏览器
|
||||
min-height: 0 !important; // 覆盖各页面的 min-height: 100vh
|
||||
overflow: hidden;
|
||||
|
||||
// 可滚动的内容区域(自动填充剩余空间)
|
||||
> .page-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
// 固定在底部的按钮区域(不参与滚动)
|
||||
> .page-bottom {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 20px;
|
||||
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||||
background: var(--bg, #FFFDF7);
|
||||
}
|
||||
}
|
||||
|
||||
// 动画
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
52
lesingle-aicreate-client/src/components/PageHeader.vue
Normal file
52
lesingle-aicreate-client/src/components/PageHeader.vue
Normal file
@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<div class="header-row">
|
||||
<div v-if="showBack" class="back-btn" @click="$router.back()">
|
||||
<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">
|
||||
<div class="header-title">{{ title }}</div>
|
||||
<div v-if="subtitle" class="header-sub">{{ subtitle }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<StepBar v-if="step != null" :current="step" :total="totalSteps" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import StepBar from './StepBar.vue'
|
||||
defineProps({
|
||||
title: String,
|
||||
subtitle: String,
|
||||
showBack: { type: Boolean, default: true },
|
||||
step: { type: Number, default: null },
|
||||
totalSteps: { type: Number, default: 6 }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-header {
|
||||
padding: 16px 20px 8px;
|
||||
background: transparent;
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.back-btn {
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
.header-title {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
}
|
||||
.header-sub {
|
||||
font-size: 13px;
|
||||
color: var(--text-sub);
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
38
lesingle-aicreate-client/src/components/StepBar.vue
Normal file
38
lesingle-aicreate-client/src/components/StepBar.vue
Normal file
@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="step-bar">
|
||||
<div
|
||||
v-for="i in total"
|
||||
:key="i"
|
||||
class="step-dot"
|
||||
:class="{ active: i - 1 === current, done: i - 1 < current }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({ current: Number, total: { type: Number, default: 6 } })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.step-bar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.step-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--border);
|
||||
transition: all 0.4s ease;
|
||||
|
||||
&.active {
|
||||
width: 24px;
|
||||
background: var(--primary);
|
||||
}
|
||||
&.done {
|
||||
background: var(--success);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
6
lesingle-aicreate-client/src/main.js
Normal file
6
lesingle-aicreate-client/src/main.js
Normal file
@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/global.scss'
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
80
lesingle-aicreate-client/src/router/index.js
Normal file
80
lesingle-aicreate-client/src/router/index.js
Normal file
@ -0,0 +1,80 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Welcome',
|
||||
component: () => import('@/views/Welcome.vue')
|
||||
},
|
||||
{
|
||||
path: '/upload',
|
||||
name: 'Upload',
|
||||
component: () => import('@/views/Upload.vue')
|
||||
},
|
||||
{
|
||||
path: '/characters',
|
||||
name: 'Characters',
|
||||
component: () => import('@/views/Characters.vue')
|
||||
},
|
||||
{
|
||||
path: '/style',
|
||||
name: 'Style',
|
||||
component: () => import('@/views/StyleSelect.vue')
|
||||
},
|
||||
{
|
||||
path: '/story',
|
||||
name: 'Story',
|
||||
component: () => import('@/views/StoryInput.vue')
|
||||
},
|
||||
{
|
||||
path: '/creating',
|
||||
name: 'Creating',
|
||||
component: () => import('@/views/Creating.vue')
|
||||
},
|
||||
{
|
||||
path: '/preview/:workId?',
|
||||
name: 'Preview',
|
||||
component: () => import('@/views/Preview.vue')
|
||||
},
|
||||
{
|
||||
path: '/edit-info/:workId',
|
||||
name: 'EditInfo',
|
||||
component: () => import('@/views/EditInfo.vue')
|
||||
},
|
||||
{
|
||||
path: '/save-success/:workId',
|
||||
name: 'SaveSuccess',
|
||||
component: () => import('@/views/SaveSuccess.vue')
|
||||
},
|
||||
{
|
||||
path: '/dubbing/:workId',
|
||||
name: 'Dubbing',
|
||||
component: () => import('@/views/Dubbing.vue')
|
||||
},
|
||||
{
|
||||
path: '/read/:workId',
|
||||
name: 'Read',
|
||||
component: () => import('@/views/BookReader.vue'),
|
||||
meta: { noAuth: true }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Auth guard: 双模式检查(sessionToken 或 appSecret 任一存在即可)
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (to.meta.noAuth || to.name === 'Welcome') {
|
||||
next()
|
||||
return
|
||||
}
|
||||
const hasSession = sessionStorage.getItem('le_sessionToken')
|
||||
const hasHmac = localStorage.getItem('le_appSecret')
|
||||
if (!hasSession && !hasHmac) {
|
||||
next({ name: 'Welcome' })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
25
lesingle-aicreate-client/src/utils/hmac.js
Normal file
25
lesingle-aicreate-client/src/utils/hmac.js
Normal file
@ -0,0 +1,25 @@
|
||||
import CryptoJS from 'crypto-js'
|
||||
|
||||
/**
|
||||
* HMAC-SHA256 签名工具
|
||||
* 签名规则:排序的 query params + nonce + timestamp,用 & 拼接
|
||||
* POST JSON body 不参与签名
|
||||
*/
|
||||
export function signRequest(orgId, appSecret, queryParams = {}) {
|
||||
const timestamp = String(Date.now())
|
||||
const nonce = Math.random().toString(36).substring(2, 15) + Date.now().toString(36)
|
||||
|
||||
// Build sign string: sorted params + nonce + timestamp
|
||||
const allParams = { ...queryParams, nonce, timestamp }
|
||||
const sorted = Object.keys(allParams).sort()
|
||||
const signStr = sorted.map(k => `${k}=${allParams[k]}`).join('&')
|
||||
|
||||
const signature = CryptoJS.HmacSHA256(signStr, appSecret).toString(CryptoJS.enc.Hex)
|
||||
|
||||
return {
|
||||
'X-App-Key': orgId,
|
||||
'X-Timestamp': timestamp,
|
||||
'X-Nonce': nonce,
|
||||
'X-Signature': signature
|
||||
}
|
||||
}
|
||||
66
lesingle-aicreate-client/src/utils/store.js
Normal file
66
lesingle-aicreate-client/src/utils/store.js
Normal file
@ -0,0 +1,66 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
/**
|
||||
* 简单全局状态(不用 Pinia,C端足够轻量)
|
||||
*
|
||||
* ★ 企业集成关键:
|
||||
* - phone: 当前登录用户手机号(企业从自己的登录系统获取)
|
||||
* - orgId: 机构ID(乐读派分配给企业的唯一标识)
|
||||
* - sessionToken: 会话令牌(企业后端调用 /api/v1/auth/session 换取,短期有效)
|
||||
*
|
||||
* 企业集成流程:
|
||||
* 1. 企业后端调用 POST /api/v1/auth/session { orgId, appSecret, phone } 换取 sessionToken
|
||||
* 2. 企业重定向用户到 H5: ?token=sess_xxx&phone=138xxx
|
||||
* 3. H5 自动读取 token,所有 API 调用使用 Authorization: Bearer sess_xxx
|
||||
*
|
||||
* 安全说明: appSecret 只存在于企业服务端,不会到达浏览器
|
||||
*/
|
||||
export const store = reactive({
|
||||
phone: localStorage.getItem('le_phone') || '',
|
||||
orgId: localStorage.getItem('le_orgId') || '',
|
||||
appSecret: localStorage.getItem('le_appSecret') || '',
|
||||
sessionToken: sessionStorage.getItem('le_sessionToken') || '', // 企业生产模式用
|
||||
|
||||
// 创作流程数据
|
||||
imageUrl: '',
|
||||
extractId: '',
|
||||
characters: [],
|
||||
selectedCharacter: null,
|
||||
selectedStyle: '',
|
||||
storyData: null,
|
||||
workId: '',
|
||||
workDetail: null,
|
||||
|
||||
setPhone(phone) {
|
||||
this.phone = phone
|
||||
localStorage.setItem('le_phone', phone)
|
||||
},
|
||||
|
||||
// 开发调试模式:直接设 orgId + appSecret(HMAC 签名)
|
||||
setOrg(orgId, appSecret) {
|
||||
this.orgId = orgId
|
||||
this.appSecret = appSecret
|
||||
localStorage.setItem('le_orgId', orgId)
|
||||
localStorage.setItem('le_appSecret', appSecret)
|
||||
},
|
||||
|
||||
// 企业生产模式:通过 auth/session 换取 sessionToken(Bearer Token)
|
||||
setSession(orgId, sessionToken) {
|
||||
this.orgId = orgId
|
||||
this.sessionToken = sessionToken
|
||||
sessionStorage.setItem('le_orgId', orgId)
|
||||
sessionStorage.setItem('le_sessionToken', sessionToken)
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.imageUrl = ''
|
||||
this.extractId = ''
|
||||
this.characters = []
|
||||
this.selectedCharacter = null
|
||||
this.selectedStyle = ''
|
||||
this.storyData = null
|
||||
this.workId = ''
|
||||
this.workDetail = null
|
||||
localStorage.removeItem('le_workId')
|
||||
}
|
||||
})
|
||||
373
lesingle-aicreate-client/src/views/BookReader.vue
Normal file
373
lesingle-aicreate-client/src/views/BookReader.vue
Normal file
@ -0,0 +1,373 @@
|
||||
<template>
|
||||
<div class="reader-page" @touchstart="onTouchStart" @touchend="onTouchEnd">
|
||||
<!-- 顶栏 -->
|
||||
<div class="reader-top">
|
||||
<div class="top-title">{{ title }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 书本区域 -->
|
||||
<div class="book-area">
|
||||
<div
|
||||
class="book"
|
||||
:class="{ 'flip-left': flipDir === -1, 'flip-right': flipDir === 1 }"
|
||||
:style="{ background: pageBg }"
|
||||
>
|
||||
<div class="book-spine" />
|
||||
|
||||
<!-- 封面 -->
|
||||
<div v-if="isCover" class="page-cover">
|
||||
<div class="cover-deco star">⭐</div>
|
||||
<div class="cover-image" v-if="coverImageUrl">
|
||||
<img :src="coverImageUrl" class="cover-real-img" />
|
||||
</div>
|
||||
<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 v-if="authorDisplay" class="cover-author">✍️ {{ authorDisplay }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 正文页 -->
|
||||
<div v-else-if="isContent" class="page-content">
|
||||
<div class="content-image">
|
||||
<img v-if="currentPage.imageUrl" :src="currentPage.imageUrl" class="content-real-img" />
|
||||
<span v-else class="content-emoji">{{ pageEmoji }}</span>
|
||||
<div class="page-num">P{{ idx }}</div>
|
||||
</div>
|
||||
<div class="content-text">{{ currentPage.text }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 封底 -->
|
||||
<div v-else-if="isBack" class="page-back">
|
||||
<div class="back-emoji">🎉</div>
|
||||
<div class="back-title">故事讲完啦!</div>
|
||||
<div class="back-divider" />
|
||||
<div class="back-desc">每一个孩子的画<br/>都是一个精彩的故事</div>
|
||||
<div v-if="workTags.length" class="book-tags">
|
||||
<span v-for="tag in workTags" :key="tag" class="book-tag">{{ tag }}</span>
|
||||
</div>
|
||||
<div class="back-replay" @click="jumpTo(0)">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 翻页导航 -->
|
||||
<div class="nav-row">
|
||||
<div class="nav-btn prev" :class="{ disabled: idx <= 0 }" @click="go(-1)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M15 18l-6-6 6-6"/></svg>
|
||||
</div>
|
||||
<div class="nav-label">{{ isCover ? '封面' : isBack ? '— 完 —' : `${idx} / ${totalContent}` }}</div>
|
||||
<div class="nav-btn next" :class="{ disabled: idx >= pages.length - 1 }" @click="go(1)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 18l6-6-6-6"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="progress-bar-wrap">
|
||||
<div class="progress-bar-fill" :style="{ width: progressPct + '%' }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:再次创作 -->
|
||||
<div class="reader-bottom safe-bottom">
|
||||
<button class="btn-primary" @click="goHome">再次创作 →</button>
|
||||
<div class="bottom-hint">本作品可在作品板块中查看</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { store } from '@/utils/store'
|
||||
import { getWorkDetail } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const idx = ref(0)
|
||||
const flipDir = ref(0)
|
||||
|
||||
const title = ref('我的绘本')
|
||||
const coverImageUrl = ref('')
|
||||
const authorDisplay = ref('')
|
||||
const workTags = ref([])
|
||||
const pages = ref([
|
||||
{ pageNum: 0, text: '我的绘本', type: 'cover' },
|
||||
{ pageNum: 99, text: '', type: 'backcover' },
|
||||
])
|
||||
|
||||
const currentPage = computed(() => pages.value[idx.value])
|
||||
const isCover = computed(() => idx.value === 0)
|
||||
const isBack = computed(() => currentPage.value?.type === 'backcover')
|
||||
const isContent = computed(() => !isCover.value && !isBack.value)
|
||||
const totalContent = computed(() => pages.value.length - 2)
|
||||
const progressPct = computed(() => ((idx.value) / (pages.value.length - 1)) * 100)
|
||||
|
||||
const bgColors = ['#FFF5EB', '#E8F4FD', '#F0F9EC', '#FFF8E1', '#F3E8FF', '#E0F7F4', '#FFF9E6', '#FCE4EC']
|
||||
const emojis = ['📖', '🌅', '🐦', '🗣️', '🍄', '🏠', '❤️', '🌟']
|
||||
const pageEmoji = computed(() => emojis[idx.value % emojis.length])
|
||||
|
||||
const pageBg = computed(() => {
|
||||
if (isCover.value) return 'linear-gradient(135deg, #FF8F65 0%, #FF6B35 40%, #E85D26 100%)'
|
||||
if (isBack.value) return 'linear-gradient(135deg, #FFD4A8 0%, #FFB874 50%, #FF9F43 100%)'
|
||||
return `linear-gradient(180deg, ${bgColors[idx.value % bgColors.length]} 0%, #FFFFFF 100%)`
|
||||
})
|
||||
|
||||
const go = (dir) => {
|
||||
const next = idx.value + dir
|
||||
if (next < 0 || next >= pages.value.length) return
|
||||
flipDir.value = dir
|
||||
setTimeout(() => { idx.value = next; flipDir.value = 0 }, 250)
|
||||
}
|
||||
|
||||
const jumpTo = (i) => { idx.value = i; flipDir.value = 0 }
|
||||
|
||||
// 触摸滑动翻页
|
||||
let touchStartX = 0
|
||||
let touchStartY = 0
|
||||
|
||||
const onTouchStart = (e) => {
|
||||
touchStartX = e.touches[0].clientX
|
||||
touchStartY = e.touches[0].clientY
|
||||
}
|
||||
|
||||
const onTouchEnd = (e) => {
|
||||
const dx = e.changedTouches[0].clientX - touchStartX
|
||||
const dy = e.changedTouches[0].clientY - touchStartY
|
||||
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5) {
|
||||
go(dx < 0 ? 1 : -1)
|
||||
}
|
||||
}
|
||||
|
||||
const goHome = () => {
|
||||
store.reset() // 清空所有创作缓存,确保新创作从零开始
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const workId = route.params.workId
|
||||
if (!workId) return
|
||||
try {
|
||||
let work
|
||||
const shareToken = new URLSearchParams(window.location.search).get('st') || ''
|
||||
if (store.sessionToken || store.appSecret) {
|
||||
// 认证用户: 用 axios 实例(自动加 HMAC/Bearer 头)
|
||||
const res = await getWorkDetail(workId)
|
||||
work = res.data
|
||||
} else if (shareToken) {
|
||||
// 分享链接: 无认证,用 shareToken
|
||||
const resp = await fetch(`/api/v1/query/work/${workId}?shareToken=${encodeURIComponent(shareToken)}`)
|
||||
const json = await resp.json()
|
||||
work = json.data
|
||||
} else {
|
||||
error.value = '请从创作流程或分享链接进入'
|
||||
return
|
||||
}
|
||||
if (work) {
|
||||
title.value = work.title || '我的绘本'
|
||||
const list = [{ pageNum: 0, text: work.title || '我的绘本', type: 'cover' }]
|
||||
;(work.pageList || []).forEach(p => {
|
||||
if (p.pageNum > 0) list.push({ pageNum: p.pageNum, text: p.text, imageUrl: p.imageUrl })
|
||||
})
|
||||
if (work.pageList?.[0]?.imageUrl) coverImageUrl.value = work.pageList[0].imageUrl
|
||||
if (work.author) authorDisplay.value = work.author
|
||||
if (Array.isArray(work.tags) && work.tags.length > 0) workTags.value = work.tags
|
||||
list.push({ pageNum: 99, text: '', type: 'backcover' })
|
||||
pages.value = list
|
||||
}
|
||||
} catch { /* use default */ }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.reader-page {
|
||||
min-height: 100vh;
|
||||
background: #F5F0E8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
// 顶栏
|
||||
.reader-top {
|
||||
padding: 14px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(255,255,255,0.85);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.top-title { font-size: 15px; font-weight: 700; color: var(--text); flex: 1; text-align: center; }
|
||||
|
||||
// 书本区域
|
||||
.book-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.book {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
aspect-ratio: 3/4;
|
||||
border-radius: 4px 16px 16px 4px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: inset -4px 0 8px rgba(0,0,0,0.04), 4px 0 12px rgba(0,0,0,0.08), 0 8px 32px rgba(0,0,0,0.12);
|
||||
transition: transform 0.25s ease;
|
||||
|
||||
&.flip-left { transform: perspective(800px) rotateY(8deg); }
|
||||
&.flip-right { transform: perspective(800px) rotateY(-8deg); }
|
||||
}
|
||||
|
||||
.book-spine {
|
||||
position: absolute; left: 0; top: 0; bottom: 0; width: 6px;
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.08), rgba(0,0,0,0.02), rgba(0,0,0,0.08));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
// 封面
|
||||
.page-cover {
|
||||
width: 100%; height: 100%;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
padding: 32px; text-align: center;
|
||||
}
|
||||
.cover-deco { position: absolute; opacity: 0.4; &.star { top: 20px; right: 24px; font-size: 24px; } }
|
||||
.cover-image {
|
||||
width: calc(100% - 32px); aspect-ratio: 4/3; border-radius: 16px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 72px; margin-bottom: 20px; overflow: hidden;
|
||||
}
|
||||
.cover-real-img { width: 100%; height: 100%; object-fit: cover; border-radius: 16px; }
|
||||
.cover-title {
|
||||
font-size: 24px; font-weight: 900; color: #fff;
|
||||
text-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||
line-height: 1.4; letter-spacing: 2px;
|
||||
}
|
||||
.cover-divider { width: 60px; height: 3px; background: rgba(255,255,255,0.5); border-radius: 2px; margin: 16px 0; }
|
||||
.cover-brand { font-size: 13px; color: rgba(255,255,255,0.8); }
|
||||
.cover-author {
|
||||
margin-top: 8px; font-size: 12px; color: rgba(255,255,255,0.7);
|
||||
background: rgba(255,255,255,0.15); border-radius: 12px; padding: 4px 14px;
|
||||
}
|
||||
|
||||
// 正文页
|
||||
.page-content { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
||||
.content-image {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
padding: 16px; position: relative;
|
||||
}
|
||||
.content-emoji { font-size: 64px; }
|
||||
.content-real-img { width: 100%; height: 100%; object-fit: contain; border-radius: 12px; }
|
||||
.page-num {
|
||||
position: absolute; top: 20px; left: 20px;
|
||||
background: rgba(0,0,0,0.4); border-radius: 8px; padding: 2px 10px;
|
||||
font-size: 11px; font-weight: 600; color: #fff;
|
||||
}
|
||||
.content-text {
|
||||
padding: 12px 24px 20px; text-align: center;
|
||||
background: rgba(255,255,255,0.6); border-top: 1px solid rgba(0,0,0,0.04);
|
||||
font-size: 16px; font-weight: 500; line-height: 1.8; letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
// 封底
|
||||
.page-back {
|
||||
width: 100%; height: 100%;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
padding: 40px; text-align: center;
|
||||
}
|
||||
.back-emoji { font-size: 56px; margin-bottom: 20px; }
|
||||
.back-title { font-size: 22px; font-weight: 900; color: #fff; text-shadow: 0 2px 6px rgba(0,0,0,0.15); }
|
||||
.back-divider { width: 40px; height: 3px; background: rgba(255,255,255,0.5); border-radius: 2px; margin: 14px 0; }
|
||||
.back-desc { font-size: 14px; color: rgba(255,255,255,0.8); line-height: 1.8; }
|
||||
.back-replay {
|
||||
margin-top: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(255,255,255,0.95);
|
||||
color: var(--primary);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
padding: 12px 32px;
|
||||
border-radius: 28px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
||||
transition: transform 0.2s;
|
||||
&:active { transform: scale(0.95); }
|
||||
}
|
||||
.back-brand {
|
||||
margin-top: 20px;
|
||||
font-size: 12px; color: rgba(255,255,255,0.7);
|
||||
}
|
||||
.book-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.book-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #E11D48;
|
||||
background: linear-gradient(135deg, #FFF1F2, #FFE4E6);
|
||||
border: 1px solid #FECDD3;
|
||||
}
|
||||
|
||||
// 翻页导航
|
||||
.nav-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
width: 100%; max-width: 360px; margin-top: 16px; padding: 0 4px;
|
||||
}
|
||||
.nav-btn {
|
||||
width: 44px; height: 44px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; transition: all 0.2s;
|
||||
&.prev { background: var(--card); box-shadow: var(--shadow-soft); color: var(--text); }
|
||||
&.next { background: var(--gradient); box-shadow: var(--shadow); color: #fff; }
|
||||
&.disabled { opacity: 0; pointer-events: none; }
|
||||
}
|
||||
.nav-label { font-size: 13px; color: var(--text-sub); font-weight: 500; }
|
||||
|
||||
// 进度条
|
||||
.progress-bar-wrap {
|
||||
width: 100%; max-width: 360px; height: 3px;
|
||||
background: #E2DDD4; border-radius: 2px; margin-top: 12px; overflow: hidden;
|
||||
}
|
||||
.progress-bar-fill {
|
||||
height: 100%; background: var(--primary); border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
// 底部
|
||||
.reader-bottom {
|
||||
padding: 12px 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: rgba(255,255,255,0.85);
|
||||
backdrop-filter: blur(10px);
|
||||
button { width: 100%; }
|
||||
}
|
||||
.bottom-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #9E9E9E;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
373
lesingle-aicreate-client/src/views/Characters.vue
Normal file
373
lesingle-aicreate-client/src/views/Characters.vue
Normal file
@ -0,0 +1,373 @@
|
||||
<template>
|
||||
<div class="char-page page-fullscreen">
|
||||
<PageHeader title="选择主角" subtitle="AI已识别画中角色,请选择绘本主角" :step="1" />
|
||||
|
||||
<div class="content page-content">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="loading-emojis">
|
||||
<span class="loading-emoji e1">🔍</span>
|
||||
<span class="loading-emoji e2">🎨</span>
|
||||
<span class="loading-emoji e3">✨</span>
|
||||
</div>
|
||||
<div class="loading-title">AI正在识别角色...</div>
|
||||
<div class="loading-sub">通常需要 10-20 秒</div>
|
||||
<div class="progress-bar"><div class="progress-fill" /></div>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<template v-else-if="error">
|
||||
<div class="error-state">
|
||||
<div class="error-emoji">😔</div>
|
||||
<div class="error-text">{{ error }}</div>
|
||||
<button class="btn-ghost" style="max-width:200px;margin-top:20px" @click="$router.back()">返回重新上传</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="result-tip">
|
||||
<span class="result-icon">🎉</span>
|
||||
<span>发现 <strong>{{ characters.length }}</strong> 个角色!点击选择绘本主角</span>
|
||||
</div>
|
||||
|
||||
<div class="char-list">
|
||||
<div
|
||||
v-for="c in characters"
|
||||
:key="c.charId"
|
||||
class="char-card"
|
||||
:class="{ selected: selected === c.charId }"
|
||||
@click="selected = c.charId"
|
||||
>
|
||||
<!-- 选中星星装饰 -->
|
||||
<div v-if="selected === c.charId" class="selected-stars">
|
||||
<span class="star s1">⭐</span>
|
||||
<span class="star s2">✨</span>
|
||||
<span class="star s3">⭐</span>
|
||||
</div>
|
||||
|
||||
<div class="char-avatar" @click.stop="c.originalCropUrl && (previewImg = c.originalCropUrl)">
|
||||
<img v-if="c.originalCropUrl" :src="c.originalCropUrl" class="char-img" />
|
||||
<div v-else class="char-placeholder">🎭</div>
|
||||
</div>
|
||||
<div class="char-info">
|
||||
<div class="char-name-row">
|
||||
<span class="char-name">{{ c.name }}</span>
|
||||
<span v-if="c.type === 'HERO'" class="hero-badge">⭐ 推荐主角</span>
|
||||
</div>
|
||||
<div class="char-hint">点击头像可放大查看</div>
|
||||
</div>
|
||||
<div class="check-badge" :class="{ checked: selected === c.charId }">
|
||||
<span v-if="selected === c.charId">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片预览 -->
|
||||
<Transition name="fade">
|
||||
<div v-if="previewImg" class="preview-overlay" @click="previewImg = ''">
|
||||
<img :src="previewImg" class="preview-full-img" />
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
<div class="page-bottom">
|
||||
<button class="btn-primary next-btn" :disabled="!selected" @click="goNext">
|
||||
确定主角,选画风 →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import { store } from '@/utils/store'
|
||||
import { extractCharacters } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const selected = ref(null)
|
||||
const characters = ref([])
|
||||
const error = ref('')
|
||||
const previewImg = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
if (store.characters && store.characters.length > 0) {
|
||||
characters.value = store.characters
|
||||
// 自动选中推荐主角
|
||||
const hero = characters.value.find(c => c.type === 'HERO')
|
||||
if (hero) selected.value = hero.charId
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!store.imageUrl) {
|
||||
error.value = '未上传图片,请返回上传'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await extractCharacters(store.imageUrl)
|
||||
const data = res.data || {}
|
||||
characters.value = (data.characters || []).map(c => ({
|
||||
...c,
|
||||
type: c.charType || c.type || 'SIDEKICK'
|
||||
}))
|
||||
if (characters.value.length === 0) {
|
||||
error.value = 'AI未识别到角色,请更换图片重试'
|
||||
}
|
||||
store.extractId = data.extractId || ''
|
||||
store.characters = characters.value
|
||||
const hero = characters.value.find(c => c.type === 'HERO')
|
||||
if (hero) selected.value = hero.charId
|
||||
} catch (e) {
|
||||
error.value = '角色识别失败:' + (e.message || '请检查网络')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const goNext = () => {
|
||||
store.selectedCharacter = characters.value.find(c => c.charId === selected.value)
|
||||
router.push('/style')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.char-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #F0F4FF 0%, #F5F0FF 40%, #FFF5F8 70%, #FFFDF7 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.content { flex: 1; padding: 16px 20px; display: flex; flex-direction: column; }
|
||||
|
||||
// Loading
|
||||
.loading-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.loading-emojis {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.loading-emoji {
|
||||
font-size: 40px;
|
||||
display: inline-block;
|
||||
animation: emojiPop 1.8s ease-in-out infinite;
|
||||
&.e1 { animation-delay: 0s; }
|
||||
&.e2 { animation-delay: 0.4s; }
|
||||
&.e3 { animation-delay: 0.8s; }
|
||||
}
|
||||
@keyframes emojiPop {
|
||||
0%, 100% { transform: scale(1) rotate(0deg); opacity: 0.5; }
|
||||
50% { transform: scale(1.3) rotate(10deg); opacity: 1; }
|
||||
}
|
||||
.loading-title { font-size: 18px; font-weight: 700; margin-top: 4px; color: var(--text); }
|
||||
.loading-sub { font-size: 14px; color: var(--text-sub); margin-top: 8px; }
|
||||
.progress-bar { width: 220px; height: 6px; background: rgba(108,99,255,0.15); border-radius: 3px; margin-top: 20px; overflow: hidden; }
|
||||
.progress-fill { width: 100%; height: 100%; background: linear-gradient(90deg, #6C63FF, #9B93FF); border-radius: 3px; animation: loading 2s ease-in-out infinite; }
|
||||
@keyframes loading { 0%{transform:translateX(-100%)} 100%{transform:translateX(200%)} }
|
||||
|
||||
// Error
|
||||
.error-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.error-emoji { font-size: 56px; }
|
||||
.error-text { font-size: 16px; font-weight: 600; margin-top: 16px; color: var(--text); text-align: center; }
|
||||
|
||||
// Result tip
|
||||
.result-tip {
|
||||
background: linear-gradient(135deg, #E8F5E9, #F1F8E9);
|
||||
border: 1.5px solid #C8E6C9;
|
||||
border-radius: 18px;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
color: #2E7D32;
|
||||
font-weight: 600;
|
||||
}
|
||||
.result-icon { font-size: 22px; }
|
||||
|
||||
// Character list
|
||||
.char-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.char-card {
|
||||
background: rgba(255,255,255,0.95);
|
||||
border-radius: 20px;
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
border: 3px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.05);
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
|
||||
&:active { transform: scale(0.98); }
|
||||
|
||||
&.selected {
|
||||
border-color: var(--primary);
|
||||
background: linear-gradient(135deg, #FFF5F0 0%, #FFFAF7 50%, #FFF0F5 100%);
|
||||
box-shadow: 0 6px 24px rgba(255, 107, 53, 0.2);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
// 选中星星装饰
|
||||
.selected-stars {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -4px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.star {
|
||||
font-size: 14px;
|
||||
animation: starPop 1.5s ease-in-out infinite;
|
||||
&.s1 { animation-delay: 0s; }
|
||||
&.s2 { animation-delay: 0.3s; font-size: 12px; }
|
||||
&.s3 { animation-delay: 0.6s; }
|
||||
}
|
||||
@keyframes starPop {
|
||||
0%, 100% { transform: scale(1); opacity: 0.6; }
|
||||
50% { transform: scale(1.3); opacity: 1; }
|
||||
}
|
||||
|
||||
.char-avatar {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #FFF5F0, #FFE8D6);
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
cursor: zoom-in;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:active { transform: scale(0.95); }
|
||||
}
|
||||
|
||||
.char-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.char-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.char-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.char-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.char-name {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.char-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-sub);
|
||||
}
|
||||
|
||||
// Check badge (right side)
|
||||
.check-badge {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 2.5px solid #E2E8F0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.checked {
|
||||
background: linear-gradient(135deg, #FF8C42, #FF6B35);
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 3px 10px rgba(255, 107, 53, 0.4);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
font-size: 11px;
|
||||
background: linear-gradient(135deg, #FFD166, #FFBE4A);
|
||||
color: #fff;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 6px rgba(255,209,102,0.3);
|
||||
}
|
||||
|
||||
.bottom-area { margin-top: auto; padding-top: 20px; }
|
||||
|
||||
.next-btn {
|
||||
font-size: 17px !important;
|
||||
padding: 16px 0 !important;
|
||||
border-radius: 28px !important;
|
||||
background: linear-gradient(135deg, #FF8C42, #FF6B35) !important;
|
||||
box-shadow: 0 6px 24px rgba(255,107,53,0.3) !important;
|
||||
}
|
||||
|
||||
// 图片预览
|
||||
.preview-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 999;
|
||||
background: rgba(0,0,0,0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: zoom-out;
|
||||
}
|
||||
.preview-full-img {
|
||||
max-width: 90%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 40px rgba(0,0,0,0.3);
|
||||
}
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
463
lesingle-aicreate-client/src/views/Creating.vue
Normal file
463
lesingle-aicreate-client/src/views/Creating.vue
Normal file
@ -0,0 +1,463 @@
|
||||
<template>
|
||||
<div class="creating-page">
|
||||
<!-- 飘浮装饰元素 -->
|
||||
<div class="floating-deco d1">☁️</div>
|
||||
<div class="floating-deco d2">🌟</div>
|
||||
<div class="floating-deco d3">🎨</div>
|
||||
<div class="floating-deco d4">📖</div>
|
||||
<div class="floating-deco d5">✨</div>
|
||||
|
||||
<!-- 进度环 -->
|
||||
<div class="ring-wrap">
|
||||
<svg width="180" height="180" class="ring-svg">
|
||||
<circle cx="90" cy="90" r="80" fill="none" stroke="rgba(255,255,255,0.5)" stroke-width="8" />
|
||||
<circle cx="90" cy="90" r="80" fill="none" stroke="var(--primary)" stroke-width="8"
|
||||
:stroke-dasharray="502" :stroke-dashoffset="502 - (502 * progress / 100)"
|
||||
stroke-linecap="round" class="ring-fill" />
|
||||
</svg>
|
||||
<div class="ring-center">
|
||||
<div class="ring-pct">{{ progress }}%</div>
|
||||
<div class="ring-label">创作进度</div>
|
||||
</div>
|
||||
<!-- 星星点缀 -->
|
||||
<div class="ring-stars">
|
||||
<span class="ring-star s1">✨</span>
|
||||
<span class="ring-star s2">⭐</span>
|
||||
<span class="ring-star s3">✨</span>
|
||||
<span class="ring-star s4">⭐</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态文字 -->
|
||||
<div class="stage-text">{{ stage }}</div>
|
||||
|
||||
<!-- 轮转创作 tips -->
|
||||
<div v-if="!error" class="rotating-tips">
|
||||
<Transition name="tip-fade" mode="out-in">
|
||||
<div class="rotating-tip" :key="currentTipIdx">{{ creatingTips[currentTipIdx] }}</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- 网络波动提示(非致命,轮询仍在继续) -->
|
||||
<div v-if="networkWarn && !error" class="network-warn">
|
||||
网络不太稳定,正在尝试重新连接{{ dots }}
|
||||
</div>
|
||||
|
||||
<!-- 错误重试 -->
|
||||
<div v-if="error" class="error-box">
|
||||
<div class="error-emoji">😔</div>
|
||||
<div class="error-text">{{ error }}</div>
|
||||
<button v-if="store.workId" class="btn-primary error-retry-btn" @click="resumePolling">恢复查询进度</button>
|
||||
<button class="btn-primary error-retry-btn" :class="{ 'btn-outline': store.workId }" @click="retry">重新创作</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Client } from '@stomp/stompjs'
|
||||
import { store } from '@/utils/store'
|
||||
import { createStory, getWorkDetail } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const progress = ref(0)
|
||||
const stage = ref('准备中...')
|
||||
const dots = ref('')
|
||||
const error = ref('')
|
||||
const networkWarn = ref(false)
|
||||
const currentTipIdx = ref(0)
|
||||
const creatingTips = [
|
||||
'AI 画师正在构思精彩故事...',
|
||||
'魔法画笔正在绘制插画...',
|
||||
'故事世界正在成形...',
|
||||
'角色们正在准备登场...',
|
||||
'色彩魔法正在施展中...',
|
||||
]
|
||||
|
||||
let pollTimer = null
|
||||
let dotTimer = null
|
||||
let tipTimer = null
|
||||
let stompClient = null
|
||||
let wsDegraded = false // WebSocket 已降级到轮询,防止重复降级
|
||||
let submitted = false
|
||||
let consecutiveErrors = 0
|
||||
const MAX_SILENT_ERRORS = 3
|
||||
const MAX_POLL_ERRORS = 15
|
||||
|
||||
// 错误消息脱敏
|
||||
function sanitizeError(msg) {
|
||||
if (!msg) return '创作遇到问题,请重新尝试'
|
||||
if (msg.includes('400') || msg.includes('status code')) return '创作请求异常,请返回重新操作'
|
||||
if (msg.includes('火山引擎') || msg.includes('VolcEngine')) return 'AI 服务暂时繁忙,请稍后重试'
|
||||
if (msg.includes('额度')) return msg // 额度提示保留原文
|
||||
if (msg.includes('重复') || msg.includes('DUPLICATE')) return '您有正在创作的作品,请等待完成'
|
||||
if (msg.includes('timeout') || msg.includes('超时')) return '创作超时,请重新尝试'
|
||||
if (msg.includes('OSS') || msg.includes('MiniMax')) return '服务暂时不可用,请稍后重试'
|
||||
if (msg.length > 50) return '创作遇到问题,请重新尝试'
|
||||
return 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
|
||||
}
|
||||
|
||||
// 持久化 workId 到 localStorage,页面刷新后可恢复轮询
|
||||
function saveWorkId(id) {
|
||||
store.workId = id
|
||||
if (id) {
|
||||
localStorage.setItem('le_workId', id)
|
||||
} else {
|
||||
localStorage.removeItem('le_workId')
|
||||
}
|
||||
}
|
||||
|
||||
function restoreWorkId() {
|
||||
if (!store.workId) {
|
||||
store.workId = localStorage.getItem('le_workId') || ''
|
||||
}
|
||||
}
|
||||
|
||||
// ─── WebSocket 实时推送 (首次进入使用) ───
|
||||
const startWebSocket = (workId) => {
|
||||
wsDegraded = false
|
||||
const wsScheme = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const wsUrl = `${wsScheme}://${location.host}/ws/websocket?orgId=${encodeURIComponent(store.orgId)}`
|
||||
|
||||
stompClient = new Client({
|
||||
brokerURL: wsUrl,
|
||||
reconnectDelay: 0, // 不自动重连,失败直接降级轮询
|
||||
onConnect: () => {
|
||||
stompClient.subscribe(`/topic/progress/${workId}`, (msg) => {
|
||||
try {
|
||||
const data = JSON.parse(msg.body)
|
||||
if (data.progress != null && data.progress > progress.value) progress.value = data.progress
|
||||
if (data.message) stage.value = friendlyStage(data.progress, data.message)
|
||||
|
||||
if (data.progress >= 100) {
|
||||
progress.value = 100
|
||||
stage.value = '🎉 绘本创作完成!'
|
||||
closeWebSocket()
|
||||
saveWorkId('')
|
||||
setTimeout(() => router.replace(`/preview/${workId}`), 800)
|
||||
} else if (data.progress < 0) {
|
||||
closeWebSocket()
|
||||
saveWorkId('')
|
||||
error.value = sanitizeError(data.message)
|
||||
}
|
||||
} catch { /* ignore parse error */ }
|
||||
})
|
||||
},
|
||||
onStompError: () => {
|
||||
if (wsDegraded) return
|
||||
wsDegraded = true
|
||||
closeWebSocket()
|
||||
startPolling(workId)
|
||||
},
|
||||
onWebSocketError: () => {
|
||||
if (wsDegraded) return
|
||||
wsDegraded = true
|
||||
closeWebSocket()
|
||||
startPolling(workId)
|
||||
},
|
||||
onWebSocketClose: () => {
|
||||
if (wsDegraded) return
|
||||
if (store.workId) {
|
||||
wsDegraded = true
|
||||
closeWebSocket()
|
||||
startPolling(workId)
|
||||
}
|
||||
}
|
||||
})
|
||||
stompClient.activate()
|
||||
}
|
||||
|
||||
const closeWebSocket = () => {
|
||||
if (stompClient) {
|
||||
try { stompClient.deactivate() } catch { /* ignore */ }
|
||||
stompClient = null
|
||||
}
|
||||
}
|
||||
|
||||
// ─── B2 轮询 (重进 / WebSocket 降级使用) ───
|
||||
const startPolling = (workId) => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
consecutiveErrors = 0
|
||||
networkWarn.value = false
|
||||
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const detail = await getWorkDetail(workId)
|
||||
const work = detail.data
|
||||
if (!work) return
|
||||
|
||||
// 轮询成功,清除网络异常状态
|
||||
if (consecutiveErrors > 0 || networkWarn.value) {
|
||||
consecutiveErrors = 0
|
||||
networkWarn.value = false
|
||||
}
|
||||
|
||||
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') {
|
||||
progress.value = 100
|
||||
stage.value = '🎉 绘本创作完成!'
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
saveWorkId('') // 清除已完成的 workId
|
||||
setTimeout(() => router.replace(`/preview/${workId}`), 800)
|
||||
} else if (work.status === 'FAILED') {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
saveWorkId('') // 清除已失败的 workId
|
||||
error.value = sanitizeError(work.failReason)
|
||||
}
|
||||
} catch {
|
||||
consecutiveErrors++
|
||||
if (consecutiveErrors > MAX_POLL_ERRORS) {
|
||||
// 连续失败太多次,暂停轮询,让用户手动恢复
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
networkWarn.value = false
|
||||
error.value = '网络连接异常,创作仍在后台进行中'
|
||||
} else if (consecutiveErrors > MAX_SILENT_ERRORS) {
|
||||
// 连续失败超过阈值,提示网络波动但继续轮询
|
||||
networkWarn.value = true
|
||||
}
|
||||
// 前几次静默忽略,避免偶尔的网络抖动触发提示
|
||||
}
|
||||
}, 8000)
|
||||
}
|
||||
|
||||
const startCreation = async () => {
|
||||
if (submitted) return // 防重复
|
||||
submitted = true
|
||||
|
||||
error.value = ''
|
||||
progress.value = 5
|
||||
stage.value = '📝 正在提交创作请求...'
|
||||
|
||||
try {
|
||||
const res = await createStory({
|
||||
imageUrl: store.imageUrl,
|
||||
storyHint: store.storyData?.storyHint || '',
|
||||
style: store.selectedStyle,
|
||||
title: store.storyData?.title || '',
|
||||
author: store.storyData?.author,
|
||||
heroCharId: store.selectedCharacter?.charId,
|
||||
extractId: store.extractId,
|
||||
})
|
||||
|
||||
const workId = res.data?.workId
|
||||
if (!workId) {
|
||||
error.value = res.msg || '创作提交失败'
|
||||
submitted = false
|
||||
return
|
||||
}
|
||||
|
||||
saveWorkId(workId)
|
||||
progress.value = 10
|
||||
stage.value = '📝 故事构思中...'
|
||||
// 首次提交:优先 WebSocket 实时推送
|
||||
startWebSocket(workId)
|
||||
|
||||
} catch (e) {
|
||||
// 创作提交可能已入库(超时但服务端已接收)
|
||||
if (store.workId) {
|
||||
progress.value = 10
|
||||
stage.value = '📝 创作已提交到后台...'
|
||||
startPolling(store.workId)
|
||||
} else {
|
||||
error.value = sanitizeError(e.message)
|
||||
submitted = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resumePolling = () => {
|
||||
error.value = ''
|
||||
networkWarn.value = false
|
||||
progress.value = 10
|
||||
stage.value = '📝 正在查询创作进度...'
|
||||
startPolling(store.workId)
|
||||
}
|
||||
|
||||
const retry = () => {
|
||||
saveWorkId('')
|
||||
submitted = false
|
||||
startCreation()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
dotTimer = setInterval(() => {
|
||||
dots.value = dots.value.length >= 3 ? '' : dots.value + '.'
|
||||
}, 500)
|
||||
|
||||
tipTimer = setInterval(() => {
|
||||
currentTipIdx.value = (currentTipIdx.value + 1) % creatingTips.length
|
||||
}, 3500)
|
||||
|
||||
// 恢复 localStorage 中的 workId(页面完全刷新后 reactive store 会重置)
|
||||
restoreWorkId()
|
||||
|
||||
// 如果已有进行中的任务(页面刷新/HMR重载),恢复轮询而非重新提交
|
||||
if (store.workId) {
|
||||
submitted = true
|
||||
progress.value = 10
|
||||
stage.value = '📝 正在查询创作进度...'
|
||||
startPolling(store.workId)
|
||||
} else {
|
||||
startCreation()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
closeWebSocket()
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
if (dotTimer) clearInterval(dotTimer)
|
||||
if (tipTimer) clearInterval(tipTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.creating-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(160deg, #FFF8E1 0%, #FFF0F0 40%, #F0F8FF 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 飘浮装饰元素 */
|
||||
.floating-deco {
|
||||
position: absolute;
|
||||
font-size: 28px;
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
animation: floatDeco 6s ease-in-out infinite;
|
||||
}
|
||||
.d1 { top: 8%; left: 10%; animation-delay: 0s; font-size: 36px; }
|
||||
.d2 { top: 15%; right: 12%; animation-delay: 1.2s; font-size: 24px; }
|
||||
.d3 { bottom: 20%; left: 8%; animation-delay: 2.4s; }
|
||||
.d4 { bottom: 12%; right: 15%; animation-delay: 0.8s; font-size: 32px; }
|
||||
.d5 { top: 40%; right: 6%; animation-delay: 3.6s; font-size: 20px; }
|
||||
@keyframes floatDeco {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
25% { transform: translateY(-12px) rotate(5deg); }
|
||||
50% { transform: translateY(-6px) rotate(-3deg); }
|
||||
75% { transform: translateY(-16px) rotate(3deg); }
|
||||
}
|
||||
|
||||
.ring-wrap {
|
||||
position: relative;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.ring-svg { transform: rotate(-90deg); }
|
||||
.ring-fill { transition: stroke-dashoffset 0.8s ease; }
|
||||
.ring-center {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ring-pct { font-size: 36px; font-weight: 900; color: var(--primary); }
|
||||
.ring-label { font-size: 12px; color: var(--text-sub); }
|
||||
|
||||
/* 星星点缀在进度环外圈 */
|
||||
.ring-stars {
|
||||
position: absolute;
|
||||
inset: -12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ring-star {
|
||||
position: absolute;
|
||||
font-size: 14px;
|
||||
animation: starTwinkle 2s ease-in-out infinite;
|
||||
}
|
||||
.ring-star.s1 { top: -4px; left: 50%; transform: translateX(-50%); animation-delay: 0s; }
|
||||
.ring-star.s2 { bottom: -4px; left: 50%; transform: translateX(-50%); animation-delay: 0.5s; }
|
||||
.ring-star.s3 { left: -4px; top: 50%; transform: translateY(-50%); animation-delay: 1s; }
|
||||
.ring-star.s4 { right: -4px; top: 50%; transform: translateY(-50%); animation-delay: 1.5s; }
|
||||
@keyframes starTwinkle {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
50% { opacity: 1; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
.stage-text { font-size: 18px; font-weight: 700; text-align: center; }
|
||||
|
||||
/* 轮转 tips */
|
||||
.rotating-tips {
|
||||
margin-top: 14px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.rotating-tip {
|
||||
font-size: 14px;
|
||||
color: #B0876E;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.tip-fade-enter-active,
|
||||
.tip-fade-leave-active {
|
||||
transition: opacity 0.5s ease, transform 0.5s ease;
|
||||
}
|
||||
.tip-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
.tip-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
.network-warn {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: #F59E0B;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.error-emoji { font-size: 40px; margin-bottom: 12px; }
|
||||
.error-text { color: #EF4444; font-size: 14px; font-weight: 600; line-height: 1.6; max-width: 260px; }
|
||||
.error-retry-btn { max-width: 200px; margin-top: 16px; }
|
||||
.error-retry-btn.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--text-sub);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
816
lesingle-aicreate-client/src/views/Dubbing.vue
Normal file
816
lesingle-aicreate-client/src/views/Dubbing.vue
Normal file
@ -0,0 +1,816 @@
|
||||
<template>
|
||||
<div class="dubbing-page">
|
||||
<PageHeader title="绘本配音" subtitle="为每一页添加AI语音或人工配音" :showBack="true" />
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="loading-emojis">
|
||||
<span class="loading-emoji e1">🎙️</span>
|
||||
<span class="loading-emoji e2">🎵</span>
|
||||
<span class="loading-emoji e3">✨</span>
|
||||
</div>
|
||||
<div class="loading-text">正在加载绘本...</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="content">
|
||||
<!-- 当前页展示 -->
|
||||
<div class="page-display">
|
||||
<div class="page-image-wrap">
|
||||
<div class="page-badge-pill">{{ currentPage.pageNum === 0 ? '封面' : 'P' + currentPage.pageNum }}</div>
|
||||
<img v-if="currentPage.imageUrl" :src="currentPage.imageUrl" class="page-image" />
|
||||
<div v-else class="page-image placeholder">📖</div>
|
||||
</div>
|
||||
<div v-if="currentPage.text" class="page-text-card">
|
||||
<div class="text-quote-mark">"</div>
|
||||
<div class="page-text-content">{{ currentPage.text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 音频控制 -->
|
||||
<div class="audio-controls">
|
||||
<template v-if="currentAudioSrc">
|
||||
<div class="audio-row">
|
||||
<button class="play-btn" @click="togglePlay">
|
||||
<span v-if="isPlaying">⏸</span>
|
||||
<span v-else>▶️</span>
|
||||
</button>
|
||||
<div class="audio-progress">
|
||||
<div class="audio-bar">
|
||||
<div class="audio-bar-fill" :style="{ width: playProgress + '%' }" />
|
||||
</div>
|
||||
<span class="audio-time">{{ formatTime(playCurrentTime) }} / {{ formatTime(playDuration) }}</span>
|
||||
</div>
|
||||
<div class="audio-source-tag" :class="currentPage.localBlob ? 'local' : currentPage.isAiVoice === false ? 'human' : 'ai'">
|
||||
{{ currentPage.localBlob ? '🎤 本地' : currentPage.isAiVoice === false ? '🎤 人工' : '🤖 AI' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="audio-empty">
|
||||
<span class="empty-icon">🔇</span>
|
||||
<span>暂未配音</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 配音按钮 -->
|
||||
<div class="voice-actions">
|
||||
<button
|
||||
class="record-btn"
|
||||
:class="{ recording: isRecording }"
|
||||
:disabled="voicingSingle || voicingAll"
|
||||
@mousedown.prevent="startRecording"
|
||||
@mouseup.prevent="stopRecording"
|
||||
@mouseleave="isRecording && stopRecording()"
|
||||
@touchstart.prevent="startRecording"
|
||||
@touchend.prevent="stopRecording"
|
||||
@touchcancel="isRecording && stopRecording()"
|
||||
>
|
||||
<span class="record-icon">{{ isRecording ? '🔴' : '🎤' }}</span>
|
||||
<span class="record-text">{{ isRecording ? '录音中... 松开结束' : '按住录音' }}</span>
|
||||
</button>
|
||||
<div class="ai-btns">
|
||||
<button
|
||||
class="ai-btn single"
|
||||
:disabled="voicingSingle || voicingAll || isRecording"
|
||||
@click="voiceSingle"
|
||||
>
|
||||
<span>🤖</span>
|
||||
{{ voicingSingle ? '配音中...' : 'AI配音' }}
|
||||
</button>
|
||||
<button
|
||||
class="ai-btn all"
|
||||
:disabled="voicingSingle || voicingAll || isRecording"
|
||||
@click="voiceAllConfirm"
|
||||
>
|
||||
<span>✨</span>
|
||||
{{ voicingAll ? '配音中...' : '全部AI' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 页面缩略图横向列表 -->
|
||||
<div class="thumb-section">
|
||||
<div class="thumb-header">
|
||||
<span class="thumb-title">配音进度</span>
|
||||
<span class="thumb-count">{{ voicedCount }}/{{ pages.length }} 已完成</span>
|
||||
</div>
|
||||
<div class="thumb-strip">
|
||||
<div
|
||||
v-for="(p, i) in pages" :key="i"
|
||||
class="thumb-item" :class="{ active: i === idx, voiced: p.audioUrl || p.localBlob }"
|
||||
@click="switchPage(i)"
|
||||
>
|
||||
<img v-if="p.imageUrl" :src="p.imageUrl" class="thumb-img" />
|
||||
<div v-else class="thumb-placeholder">{{ p.pageNum === 0 ? '封' : p.pageNum }}</div>
|
||||
<div class="thumb-status">
|
||||
<span v-if="p.localBlob" class="status-dot local-dot" />
|
||||
<span v-else-if="p.audioUrl" class="status-dot ai-dot" />
|
||||
<span v-else class="status-dot empty-dot" />
|
||||
</div>
|
||||
<div v-if="p.audioUrl || p.localBlob" class="thumb-voice-icon">🔊</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部 -->
|
||||
<div class="bottom-bar safe-bottom">
|
||||
<button class="btn-primary finish-btn" :disabled="submitting" @click="finish">
|
||||
{{ submitting ? '提交中...' : '完成配音 →' }}
|
||||
</button>
|
||||
<div v-if="localCount > 0" class="local-hint">🎤 {{ localCount }}页本地录音将在提交时上传</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Toast -->
|
||||
<Transition name="fade">
|
||||
<div v-if="toast" class="toast">{{ toast }}</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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 { store } from '@/utils/store'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const workId = computed(() => route.params.workId || store.workId)
|
||||
|
||||
const loading = ref(true)
|
||||
const submitting = ref(false)
|
||||
const pages = ref([])
|
||||
const idx = ref(0)
|
||||
const toast = ref('')
|
||||
|
||||
// Voice state
|
||||
const voicingSingle = ref(false)
|
||||
const voicingAll = ref(false)
|
||||
|
||||
// Recording state
|
||||
const isRecording = ref(false)
|
||||
let mediaRecorder = null
|
||||
let recordedChunks = []
|
||||
|
||||
// Audio playback state
|
||||
let audioEl = null
|
||||
const isPlaying = ref(false)
|
||||
const playProgress = ref(0)
|
||||
const playCurrentTime = ref(0)
|
||||
const playDuration = ref(0)
|
||||
|
||||
const currentPage = computed(() => pages.value[idx.value] || {})
|
||||
|
||||
// 当前页的播放源:优先本地 blob,其次服务端 audioUrl
|
||||
const currentAudioSrc = computed(() => {
|
||||
const p = currentPage.value
|
||||
if (p.localBlob) return URL.createObjectURL(p.localBlob)
|
||||
return p.audioUrl || null
|
||||
})
|
||||
|
||||
const voicedCount = computed(() => pages.value.filter(p => p.audioUrl || p.localBlob).length)
|
||||
const localCount = computed(() => pages.value.filter(p => p.localBlob).length)
|
||||
|
||||
function showToast(msg) {
|
||||
toast.value = msg
|
||||
setTimeout(() => { toast.value = '' }, 2500)
|
||||
}
|
||||
|
||||
function formatTime(sec) {
|
||||
if (!sec || isNaN(sec)) return '0:00'
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = Math.floor(sec % 60)
|
||||
return m + ':' + (s < 10 ? '0' : '') + s
|
||||
}
|
||||
|
||||
// ─── Audio Playback ───
|
||||
|
||||
function stopAudio() {
|
||||
if (audioEl) {
|
||||
audioEl.pause()
|
||||
audioEl.onerror = null // 先清回调,再清 src,防止空 src 触发 onerror
|
||||
audioEl.onended = null
|
||||
audioEl.ontimeupdate = null
|
||||
audioEl.src = ''
|
||||
audioEl = null
|
||||
}
|
||||
isPlaying.value = false
|
||||
playProgress.value = 0
|
||||
playCurrentTime.value = 0
|
||||
playDuration.value = 0
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
const src = currentAudioSrc.value
|
||||
if (!src) return
|
||||
|
||||
if (isPlaying.value) {
|
||||
audioEl?.pause()
|
||||
isPlaying.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!audioEl) audioEl = new Audio()
|
||||
|
||||
// 每次用最新的 src(blob URL 可能变化)
|
||||
audioEl.src = src
|
||||
audioEl.load()
|
||||
|
||||
audioEl.ontimeupdate = () => {
|
||||
playCurrentTime.value = audioEl.currentTime
|
||||
playDuration.value = audioEl.duration || 0
|
||||
playProgress.value = audioEl.duration ? (audioEl.currentTime / audioEl.duration * 100) : 0
|
||||
}
|
||||
audioEl.onended = () => { isPlaying.value = false; playProgress.value = 100 }
|
||||
audioEl.onerror = () => { isPlaying.value = false; showToast('播放失败') }
|
||||
|
||||
audioEl.play().then(() => { isPlaying.value = true }).catch(() => { showToast('播放失败,请点击重试') })
|
||||
}
|
||||
|
||||
function switchPage(i) {
|
||||
stopAudio()
|
||||
idx.value = i
|
||||
}
|
||||
|
||||
watch(idx, () => { stopAudio() })
|
||||
|
||||
// ─── Manual Recording (按住录音) ───
|
||||
|
||||
async function startRecording() {
|
||||
if (isRecording.value || voicingSingle.value || voicingAll.value) return
|
||||
|
||||
stopAudio() // 停止播放
|
||||
|
||||
try {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
showToast('请使用 HTTPS 访问以启用录音功能')
|
||||
return
|
||||
}
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
recordedChunks = []
|
||||
mediaRecorder = new MediaRecorder(stream, { mimeType: getSupportedMimeType() })
|
||||
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) recordedChunks.push(e.data)
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
// 释放麦克风
|
||||
stream.getTracks().forEach(t => t.stop())
|
||||
|
||||
if (recordedChunks.length === 0) return
|
||||
|
||||
const blob = new Blob(recordedChunks, { type: mediaRecorder.mimeType })
|
||||
|
||||
// 检查录音大小(太短的过滤掉)
|
||||
if (blob.size < 1000) {
|
||||
showToast('录音太短,请重新录制')
|
||||
return
|
||||
}
|
||||
|
||||
// 存入当前页的 localBlob
|
||||
const p = pages.value[idx.value]
|
||||
if (p) {
|
||||
p.localBlob = blob
|
||||
p.isAiVoice = false
|
||||
showToast('录音完成!')
|
||||
// 自动跳到下一个未配音的页面
|
||||
autoAdvance()
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.start()
|
||||
isRecording.value = true
|
||||
} catch (e) {
|
||||
if (e.name === 'NotAllowedError') {
|
||||
showToast('请允许麦克风权限后重试')
|
||||
} else {
|
||||
showToast('无法启动录音: ' + (e.message || '未知错误'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (!isRecording.value || !mediaRecorder) return
|
||||
isRecording.value = false
|
||||
if (mediaRecorder.state === 'recording') {
|
||||
mediaRecorder.stop()
|
||||
}
|
||||
}
|
||||
|
||||
function getSupportedMimeType() {
|
||||
const types = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg']
|
||||
for (const t of types) {
|
||||
if (MediaRecorder.isTypeSupported(t)) return t
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// 自动跳到下一个未配音的页
|
||||
function autoAdvance() {
|
||||
for (let i = idx.value + 1; i < pages.value.length; i++) {
|
||||
if (!pages.value[i].audioUrl && !pages.value[i].localBlob) {
|
||||
idx.value = i
|
||||
return
|
||||
}
|
||||
}
|
||||
// 都配完了,不跳
|
||||
}
|
||||
|
||||
// ─── AI Voice ───
|
||||
|
||||
async function voiceSingle() {
|
||||
voicingSingle.value = true
|
||||
try {
|
||||
const res = await voicePage({ workId: workId.value, voiceAll: false, pageNum: currentPage.value.pageNum })
|
||||
const data = res.data
|
||||
if (data.voicedPages?.length) {
|
||||
for (const vp of data.voicedPages) {
|
||||
const p = pages.value.find(x => x.pageNum === vp.pageNum)
|
||||
if (p) {
|
||||
p.audioUrl = vp.audioUrl
|
||||
p.localBlob = null // AI覆盖本地录音
|
||||
p.isAiVoice = true
|
||||
}
|
||||
}
|
||||
showToast('AI配音成功')
|
||||
} else {
|
||||
showToast('配音失败,请重试')
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(e.message || '配音失败')
|
||||
} finally {
|
||||
voicingSingle.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function voiceAllConfirm() {
|
||||
if (!confirm('将为所有未配音的页面生成AI语音,预计需要30-60秒,确认继续?')) return
|
||||
voicingAll.value = true
|
||||
try {
|
||||
const res = await voicePage({ workId: workId.value, voiceAll: true })
|
||||
const data = res.data
|
||||
if (data.voicedPages) {
|
||||
for (const vp of data.voicedPages) {
|
||||
const p = pages.value.find(x => x.pageNum === vp.pageNum)
|
||||
if (p) {
|
||||
p.audioUrl = vp.audioUrl
|
||||
p.localBlob = null
|
||||
p.isAiVoice = true
|
||||
}
|
||||
}
|
||||
}
|
||||
const failed = data.failedPages?.length || 0
|
||||
showToast(failed > 0 ? `${data.totalSucceeded}页成功,${failed}页失败` : '全部AI配音完成!')
|
||||
} catch (e) {
|
||||
showToast(e.message || '配音失败')
|
||||
} finally {
|
||||
voicingAll.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 完成配音 ───
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Step 2: 批量通知后端更新 DB
|
||||
showToast('保存配音...')
|
||||
await batchUpdateAudio(workId.value, audioPages)
|
||||
showToast('配音保存成功')
|
||||
setTimeout(() => router.push(`/read/${workId.value}`), 800)
|
||||
} catch (e) {
|
||||
showToast('上传失败:' + (e.message || '请重试'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Load ───
|
||||
|
||||
async function loadWork() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (!store.workDetail || store.workDetail.workId !== workId.value) {
|
||||
store.workDetail = null
|
||||
const res = await getWorkDetail(workId.value)
|
||||
store.workDetail = res.data
|
||||
}
|
||||
pages.value = (store.workDetail.pageList || []).map(p => ({
|
||||
pageNum: p.pageNum,
|
||||
text: p.text,
|
||||
imageUrl: p.imageUrl,
|
||||
audioUrl: p.audioUrl || null,
|
||||
localBlob: null, // 本地录音 Blob
|
||||
isAiVoice: p.audioUrl ? true : null // 区分来源
|
||||
}))
|
||||
} catch { /* fallback empty */ }
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(loadWork)
|
||||
onBeforeUnmount(() => {
|
||||
stopAudio()
|
||||
if (isRecording.value && mediaRecorder?.state === 'recording') {
|
||||
mediaRecorder.stop()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dubbing-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #F0F8FF 0%, #FFF5F0 40%, #FFF8E7 70%, #FFFDF7 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.loading-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.loading-emojis {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.loading-emoji {
|
||||
font-size: 36px;
|
||||
display: inline-block;
|
||||
animation: emojiPop 1.8s ease-in-out infinite;
|
||||
&.e1 { animation-delay: 0s; }
|
||||
&.e2 { animation-delay: 0.4s; }
|
||||
&.e3 { animation-delay: 0.8s; }
|
||||
}
|
||||
@keyframes emojiPop {
|
||||
0%, 100% { transform: scale(1) rotate(0deg); opacity: 0.5; }
|
||||
50% { transform: scale(1.3) rotate(10deg); opacity: 1; }
|
||||
}
|
||||
.loading-text { font-size: 16px; color: var(--text-sub); font-weight: 600; }
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 110px;
|
||||
}
|
||||
|
||||
/* 页面展示 */
|
||||
.page-display { display: flex; flex-direction: column; gap: 10px; }
|
||||
.page-image-wrap {
|
||||
position: relative;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #F8F6F0, #F0EDE8);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||
}
|
||||
.page-image {
|
||||
width: 100%;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
max-height: 38vh;
|
||||
}
|
||||
.placeholder {
|
||||
height: 180px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 48px;
|
||||
}
|
||||
.page-badge-pill {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background: linear-gradient(135deg, rgba(255,107,53,0.9), rgba(255,140,66,0.9));
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 4px 14px;
|
||||
border-radius: 20px;
|
||||
backdrop-filter: blur(4px);
|
||||
box-shadow: 0 2px 8px rgba(255,107,53,0.3);
|
||||
}
|
||||
|
||||
.page-text-card {
|
||||
background: rgba(255,255,255,0.92);
|
||||
border-radius: 18px;
|
||||
padding: 16px 20px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.05);
|
||||
position: relative;
|
||||
border-left: 4px solid #FFD166;
|
||||
}
|
||||
.text-quote-mark {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 12px;
|
||||
font-size: 32px;
|
||||
color: #FFD166;
|
||||
opacity: 0.3;
|
||||
font-family: serif;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
.page-text-content {
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
color: var(--text);
|
||||
text-align: center;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* 音频控制 */
|
||||
.audio-controls {
|
||||
background: rgba(255,255,255,0.92);
|
||||
border-radius: 18px;
|
||||
padding: 14px 16px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
|
||||
}
|
||||
.audio-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.play-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #FF8C42, #FF6B35);
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 3px 12px rgba(255,107,53,0.3);
|
||||
transition: transform 0.2s;
|
||||
&:active { transform: scale(0.9); }
|
||||
}
|
||||
.audio-progress { flex: 1; }
|
||||
.audio-bar {
|
||||
height: 6px;
|
||||
background: #F0EDE8;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.audio-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #FF8C42, #FF6B35);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.audio-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-sub);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
.audio-source-tag {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
&.ai { background: #FFF0E8; color: var(--primary); }
|
||||
&.local { background: #FEF2F2; color: #EF4444; }
|
||||
&.human { background: #F0F8FF; color: #3B82F6; }
|
||||
}
|
||||
|
||||
.audio-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-sub);
|
||||
font-weight: 500;
|
||||
}
|
||||
.empty-icon { font-size: 20px; }
|
||||
|
||||
/* 配音按钮 */
|
||||
.voice-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 录音大按钮 */
|
||||
.record-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border-radius: 22px;
|
||||
border: 2.5px solid #EF4444;
|
||||
background: linear-gradient(135deg, #FEF2F2, #FFF5F5);
|
||||
color: #EF4444;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
box-shadow: 0 3px 12px rgba(239,68,68,0.12);
|
||||
|
||||
&:active, &.recording {
|
||||
background: linear-gradient(135deg, #EF4444, #DC2626);
|
||||
color: #fff;
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 6px 24px rgba(239,68,68,0.3);
|
||||
border-color: #DC2626;
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
.record-icon { font-size: 22px; }
|
||||
.record-text { font-size: 16px; }
|
||||
|
||||
.ai-btns {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.ai-btn {
|
||||
flex: 1;
|
||||
padding: 14px 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
border-radius: 18px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.single {
|
||||
background: linear-gradient(135deg, #FF8C42, #FF6B35);
|
||||
color: #fff;
|
||||
box-shadow: 0 3px 12px rgba(255,107,53,0.25);
|
||||
}
|
||||
&.all {
|
||||
background: rgba(255,255,255,0.9);
|
||||
color: var(--primary);
|
||||
border: 2px solid #FFE4D0;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
&:active { transform: scale(0.97); }
|
||||
&:disabled { opacity: 0.4; pointer-events: none; }
|
||||
}
|
||||
|
||||
/* 缩略图横向滚动列表 */
|
||||
.thumb-section { margin-top: 4px; }
|
||||
.thumb-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.thumb-title { font-size: 15px; font-weight: 800; color: var(--text); }
|
||||
.thumb-count { font-size: 13px; color: var(--primary); font-weight: 700; }
|
||||
|
||||
.thumb-strip {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
overflow-x: auto;
|
||||
padding: 6px 2px 12px;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
.thumb-item {
|
||||
flex-shrink: 0;
|
||||
width: 64px;
|
||||
height: 80px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 2.5px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
background: rgba(255,255,255,0.8);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 4px 16px rgba(255,107,53,0.25);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
&.voiced { border-color: #C8E6C9; }
|
||||
&.active.voiced { border-color: var(--primary); }
|
||||
}
|
||||
.thumb-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.thumb-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #F5F0E8;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-sub);
|
||||
}
|
||||
.thumb-status {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.status-dot {
|
||||
display: block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
&.ai-dot { background: #2EC4B6; box-shadow: 0 0 4px rgba(46,196,182,0.5); }
|
||||
&.local-dot { background: #EF4444; box-shadow: 0 0 4px rgba(239,68,68,0.5); }
|
||||
&.empty-dot { background: #D1D5DB; }
|
||||
}
|
||||
.thumb-voice-icon {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 3px;
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.bottom-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding: 14px 20px 20px;
|
||||
background: linear-gradient(transparent, rgba(255,253,247,0.95) 25%);
|
||||
}
|
||||
.finish-btn {
|
||||
font-size: 17px !important;
|
||||
padding: 16px 0 !important;
|
||||
border-radius: 28px !important;
|
||||
background: linear-gradient(135deg, #FF8C42, #FF6B35) !important;
|
||||
box-shadow: 0 6px 24px rgba(255,107,53,0.3) !important;
|
||||
}
|
||||
.local-hint {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-sub);
|
||||
margin-top: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(45,45,63,0.88);
|
||||
color: #fff;
|
||||
padding: 12px 28px;
|
||||
border-radius: 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
z-index: 999;
|
||||
max-width: 80%;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
351
lesingle-aicreate-client/src/views/EditInfo.vue
Normal file
351
lesingle-aicreate-client/src/views/EditInfo.vue
Normal file
@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<div class="edit-page">
|
||||
<PageHeader title="编辑绘本信息" subtitle="补充信息让绘本更完整" :showBack="true" />
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div style="font-size:36px">📖</div>
|
||||
<div style="color:var(--text-sub);margin-top:8px">加载中...</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="content">
|
||||
<!-- 封面预览 -->
|
||||
<div class="cover-preview card" v-if="coverUrl">
|
||||
<img :src="coverUrl" class="cover-img" />
|
||||
<div class="cover-title-overlay">{{ store.workDetail?.title || '未命名' }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<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" />
|
||||
<span class="char-count-inline">{{ form.author.length }}/16</span>
|
||||
</div>
|
||||
|
||||
<div class="field-item">
|
||||
<div class="field-label"><span>📝</span> 副标题 <span class="optional-mark">选填</span></div>
|
||||
<input v-model="form.subtitle" class="text-input" placeholder="如:一个关于勇气的故事" maxlength="20" />
|
||||
<span class="char-count-inline">{{ form.subtitle.length }}/20</span>
|
||||
</div>
|
||||
|
||||
<div class="field-item">
|
||||
<div class="field-label">
|
||||
<span>📖</span> 绘本简介 <span class="optional-mark">选填</span>
|
||||
<span class="char-count">{{ form.intro.length }}/250</span>
|
||||
</div>
|
||||
<textarea v-model="form.intro" class="textarea-input" placeholder="简单介绍一下这个绘本的故事" maxlength="250" rows="3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签 -->
|
||||
<div class="card form-card">
|
||||
<div class="field-label" style="margin-bottom:12px"><span>🏷️</span> 绘本标签</div>
|
||||
|
||||
<div class="tags-wrap">
|
||||
<span v-for="(tag, i) in selectedTags" :key="'s'+i" class="tag selected-tag">
|
||||
{{ tag }}
|
||||
<span v-if="selectedTags.length > 1" class="tag-remove" @click="removeTag(i)">×</span>
|
||||
</span>
|
||||
<!-- 添加标签(达到5个上限时隐藏) -->
|
||||
<template v-if="selectedTags.length < 5">
|
||||
<span v-if="!addingTag" class="tag add-tag" @click="addingTag = true">+</span>
|
||||
<span v-else class="tag adding-tag">
|
||||
<input
|
||||
ref="tagInput"
|
||||
v-model="newTag"
|
||||
class="tag-input"
|
||||
placeholder="输入标签"
|
||||
maxlength="8"
|
||||
@keydown.enter="confirmAddTag"
|
||||
@blur="confirmAddTag"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 推荐标签(达到5个上限时隐藏,只显示未选中的) -->
|
||||
<div v-if="selectedTags.length < 5 && limitedPresets.length > 0" class="preset-tags">
|
||||
<span
|
||||
v-for="p in limitedPresets" :key="p"
|
||||
class="tag preset-tag"
|
||||
@click="addPresetTag(p)"
|
||||
>+ {{ p }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部 -->
|
||||
<div v-if="!loading" class="bottom-bar safe-bottom">
|
||||
<button class="btn-primary" :disabled="saving" @click="handleSave">
|
||||
{{ saving ? '保存中...' : '保存绘本 →' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import { getWorkDetail, updateWork } from '@/api'
|
||||
import { store } from '@/utils/store'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const workId = computed(() => route.params.workId || store.workId)
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const coverUrl = ref('')
|
||||
|
||||
const form = ref({ author: '', subtitle: '', intro: '' })
|
||||
const selectedTags = ref([])
|
||||
const addingTag = ref(false)
|
||||
const newTag = ref('')
|
||||
const tagInput = ref(null)
|
||||
|
||||
const PRESET_TAGS = ['冒险', '成长', '友谊', '魔法', '勇敢', '快乐', '温暖', '探索', '梦想', '好奇']
|
||||
const availablePresets = computed(() =>
|
||||
PRESET_TAGS.filter(t => !selectedTags.value.includes(t))
|
||||
)
|
||||
// 推荐标签最多显示到总标签数 5 个的剩余空位
|
||||
const limitedPresets = computed(() => {
|
||||
const remaining = 5 - selectedTags.value.length
|
||||
if (remaining <= 0) return []
|
||||
return availablePresets.value.slice(0, remaining)
|
||||
})
|
||||
|
||||
function removeTag(i) {
|
||||
if (selectedTags.value.length <= 1) return
|
||||
selectedTags.value.splice(i, 1)
|
||||
}
|
||||
|
||||
function addPresetTag(tag) {
|
||||
if (selectedTags.value.length >= 5) return
|
||||
if (!selectedTags.value.includes(tag)) selectedTags.value.push(tag)
|
||||
}
|
||||
|
||||
function confirmAddTag() {
|
||||
const t = newTag.value.trim()
|
||||
if (t && !selectedTags.value.includes(t) && selectedTags.value.length < 5) {
|
||||
selectedTags.value.push(t)
|
||||
}
|
||||
newTag.value = ''
|
||||
addingTag.value = false
|
||||
}
|
||||
|
||||
async function loadWork() {
|
||||
loading.value = true
|
||||
try {
|
||||
// 缓存不匹配当前 workId 时重新请求(防止上一个作品数据残留)
|
||||
if (!store.workDetail || store.workDetail.workId !== workId.value) {
|
||||
store.workDetail = null
|
||||
const res = await getWorkDetail(workId.value)
|
||||
store.workDetail = res.data
|
||||
}
|
||||
const w = store.workDetail
|
||||
form.value.author = w.author || ''
|
||||
form.value.subtitle = w.subtitle || ''
|
||||
form.value.intro = w.intro || ''
|
||||
selectedTags.value = Array.isArray(w.tags) && w.tags.length ? [...w.tags] : ['冒险']
|
||||
coverUrl.value = w.pageList?.[0]?.imageUrl || ''
|
||||
} catch (e) {
|
||||
// fallback: proceed with empty form
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const data = { tags: selectedTags.value }
|
||||
if (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()
|
||||
|
||||
await updateWork(workId.value, data)
|
||||
|
||||
// 更新缓存
|
||||
if (store.workDetail) {
|
||||
if (data.author) store.workDetail.author = data.author
|
||||
if (data.subtitle) store.workDetail.subtitle = data.subtitle
|
||||
if (data.intro) store.workDetail.intro = data.intro
|
||||
store.workDetail.tags = [...selectedTags.value]
|
||||
}
|
||||
|
||||
router.push(`/save-success/${workId.value}`)
|
||||
} catch (e) {
|
||||
alert(e.message || '保存失败,请重试')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadWork()
|
||||
nextTick(() => { if (tagInput.value) tagInput.value.focus() })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.edit-page {
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.loading-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.cover-preview {
|
||||
position: relative;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
height: 120px;
|
||||
}
|
||||
.cover-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.cover-title-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 10px 16px;
|
||||
background: linear-gradient(transparent, rgba(0,0,0,0.6));
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-card { padding: 20px; }
|
||||
|
||||
.field-item { margin-bottom: 16px; &:last-child { margin-bottom: 0; } }
|
||||
.field-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-sub);
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.required-mark { color: var(--primary); font-size: 11px; }
|
||||
.optional-mark { color: #94A3B8; font-size: 11px; }
|
||||
.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; }
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: #F8F7F4;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 14px 16px;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
box-sizing: border-box;
|
||||
transition: box-shadow 0.2s;
|
||||
|
||||
&:focus { box-shadow: 0 0 0 2px var(--primary); }
|
||||
&.input-error { box-shadow: 0 0 0 2px #EF4444; }
|
||||
}
|
||||
.textarea-input {
|
||||
width: 100%;
|
||||
border: 1.5px solid var(--border);
|
||||
background: #FAFAF8;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 14px;
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
resize: none;
|
||||
box-sizing: border-box;
|
||||
transition: border 0.3s;
|
||||
&:focus { border-color: var(--primary); }
|
||||
}
|
||||
.error-text { color: #EF4444; font-size: 12px; margin-top: 4px; }
|
||||
|
||||
.tags-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.selected-tag {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tag-remove {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
opacity: 0.6;
|
||||
&:hover { opacity: 1; }
|
||||
}
|
||||
.add-tag {
|
||||
background: var(--border);
|
||||
color: var(--text-sub);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
padding: 6px 16px;
|
||||
}
|
||||
.adding-tag {
|
||||
background: #fff;
|
||||
border: 1.5px solid var(--primary);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.tag-input {
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
width: 60px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.preset-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.preset-tag {
|
||||
background: #F0EDE8;
|
||||
color: var(--text-sub);
|
||||
font-size: 12px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding: 12px 20px 20px;
|
||||
background: linear-gradient(transparent, var(--bg) 20%);
|
||||
}
|
||||
</style>
|
||||
338
lesingle-aicreate-client/src/views/Preview.vue
Normal file
338
lesingle-aicreate-client/src/views/Preview.vue
Normal file
@ -0,0 +1,338 @@
|
||||
<template>
|
||||
<div class="preview-page">
|
||||
<!-- 顶部 -->
|
||||
<div class="top-bar">
|
||||
<div class="top-title">绘本预览</div>
|
||||
<div class="top-sub">你的绘本已生成!</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="loading-icon">📖</div>
|
||||
<div class="loading-text">加载中...</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载失败 -->
|
||||
<div v-else-if="error" class="error-state card">
|
||||
<div style="font-size:36px;margin-bottom:12px">😥</div>
|
||||
<div style="font-weight:600;margin-bottom:8px">加载失败</div>
|
||||
<div style="color:var(--text-sub);font-size:13px;margin-bottom:16px">{{ error }}</div>
|
||||
<button class="btn-primary" style="width:auto;padding:10px 32px" @click="loadWork">重试</button>
|
||||
</div>
|
||||
|
||||
<!-- 主内容 -->
|
||||
<template v-else-if="pages.length">
|
||||
<div class="content" @touchstart="onTouchStart" @touchend="onTouchEnd">
|
||||
<!-- 1. 图片区:16:9 完整展示,不裁切 -->
|
||||
<div class="image-section">
|
||||
<div class="page-badge">{{ pageBadge }}</div>
|
||||
<img v-if="currentPage.imageUrl" :src="currentPage.imageUrl" class="page-image" />
|
||||
<div v-else class="page-image placeholder-img">📖</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 故事文字区 -->
|
||||
<div class="text-section">
|
||||
<div class="text-deco">"</div>
|
||||
<div class="story-text">{{ currentPage.text || '' }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 翻页 -->
|
||||
<div class="nav-row">
|
||||
<button class="nav-btn" :class="{ invisible: idx <= 0 }" @click="prev">‹</button>
|
||||
<span class="page-counter">{{ idx + 1 }} / {{ pages.length }}</span>
|
||||
<button class="nav-btn" :class="{ invisible: idx >= pages.length - 1 }" @click="next">›</button>
|
||||
</div>
|
||||
|
||||
<!-- 4. 横版卡片网格(2列,填满空白) -->
|
||||
<div class="thumb-grid">
|
||||
<div v-for="(p, i) in pages" :key="i"
|
||||
class="thumb-card" :class="{ active: i === idx }"
|
||||
@click="idx = i">
|
||||
<div class="thumb-card-img-wrap">
|
||||
<img v-if="p.imageUrl" :src="p.imageUrl" class="thumb-card-img" />
|
||||
<div v-else class="thumb-card-placeholder">📖</div>
|
||||
<div class="thumb-card-badge">{{ i === 0 ? '封面' : 'P' + i }}</div>
|
||||
</div>
|
||||
<div class="thumb-card-text">{{ p.text ? (p.text.length > 16 ? p.text.slice(0,16) + '...' : p.text) : '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<div class="bottom-bar safe-bottom">
|
||||
<button class="btn-primary" @click="goEditInfo">下一步: 编辑绘本信息 →</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { getWorkDetail } from '@/api'
|
||||
import { store } from '@/utils/store'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const pages = ref([])
|
||||
const idx = ref(0)
|
||||
const thumbStrip = ref(null)
|
||||
|
||||
// Touch swipe
|
||||
let touchX = 0
|
||||
const onTouchStart = (e) => { touchX = e.touches[0].clientX }
|
||||
const onTouchEnd = (e) => {
|
||||
const dx = e.changedTouches[0].clientX - touchX
|
||||
if (Math.abs(dx) > 50) dx > 0 ? prev() : next()
|
||||
}
|
||||
|
||||
const currentPage = computed(() => pages.value[idx.value] || {})
|
||||
const pageBadge = computed(() => {
|
||||
if (idx.value === 0) return '封面'
|
||||
return `P${idx.value}`
|
||||
})
|
||||
|
||||
function prev() { if (idx.value > 0) { idx.value--; scrollThumbIntoView(idx.value) } }
|
||||
function next() { if (idx.value < pages.value.length - 1) { idx.value++; scrollThumbIntoView(idx.value) } }
|
||||
|
||||
function scrollThumbIntoView(i) {
|
||||
nextTick(() => {
|
||||
const el = thumbStrip.value?.children[i]
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||
})
|
||||
}
|
||||
|
||||
const workId = computed(() => route.params.workId || store.workId)
|
||||
|
||||
async function loadWork() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await getWorkDetail(workId.value)
|
||||
const work = res.data
|
||||
store.workDetail = work
|
||||
store.workId = work.workId
|
||||
pages.value = (work.pageList || []).map(p => ({
|
||||
pageNum: p.pageNum,
|
||||
text: p.text,
|
||||
imageUrl: p.imageUrl,
|
||||
audioUrl: p.audioUrl
|
||||
}))
|
||||
} catch (e) {
|
||||
error.value = e.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goEditInfo() {
|
||||
router.push(`/edit-info/${workId.value}`)
|
||||
}
|
||||
|
||||
onMounted(loadWork)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.preview-page {
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
background: var(--gradient);
|
||||
padding: 20px 20px 16px;
|
||||
color: #fff;
|
||||
}
|
||||
.top-title { font-size: 20px; font-weight: 800; }
|
||||
.top-sub { font-size: 13px; opacity: 0.85; margin-top: 4px; }
|
||||
|
||||
.loading-state, .error-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
.loading-icon { font-size: 48px; animation: pulse 1.5s ease infinite; }
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 1. 图片区:16:9 完整展示 */
|
||||
.image-section {
|
||||
position: relative;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
background: #1A1A1A;
|
||||
box-shadow: 0 6px 24px rgba(0,0,0,0.12);
|
||||
}
|
||||
.page-image {
|
||||
width: 100%;
|
||||
display: block;
|
||||
object-fit: contain; /* 16:9 横图完整显示,不裁切 */
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #1A1A1A; /* 上下留黑,像电影画幅 */
|
||||
}
|
||||
.placeholder-img {
|
||||
aspect-ratio: 16 / 9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 48px;
|
||||
background: #F5F3EE;
|
||||
}
|
||||
.page-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background: linear-gradient(135deg, rgba(255,107,53,0.9), rgba(255,140,66,0.9));
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 4px 14px;
|
||||
border-radius: 20px;
|
||||
z-index: 2;
|
||||
box-shadow: 0 2px 8px rgba(255,107,53,0.3);
|
||||
}
|
||||
|
||||
/* 2. 故事文字区 */
|
||||
.text-section {
|
||||
background: rgba(255,255,255,0.92);
|
||||
border-radius: 0 0 18px 18px;
|
||||
margin-top: -8px; /* 与图片无缝衔接 */
|
||||
padding: 18px 22px 16px;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.05);
|
||||
min-height: 60px;
|
||||
}
|
||||
.text-deco {
|
||||
position: absolute;
|
||||
top: 4px; left: 14px;
|
||||
font-size: 36px; color: #FFD166; opacity: 0.25;
|
||||
font-family: Georgia, serif; font-weight: 900; line-height: 1;
|
||||
}
|
||||
.story-text {
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
color: #3D2E1E;
|
||||
font-weight: 500;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
/* 3. 翻页行 */
|
||||
|
||||
.nav-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.nav-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--card);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
|
||||
&:active { transform: scale(0.9); }
|
||||
&.invisible { opacity: 0; pointer-events: none; }
|
||||
}
|
||||
.page-counter {
|
||||
font-size: 12px;
|
||||
color: var(--text-sub);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 4. 横版卡片网格 */
|
||||
.thumb-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.thumb-card {
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: rgba(255,255,255,0.9);
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.06);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 2.5px solid transparent;
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 3px 14px rgba(255,107,53,0.25);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
.thumb-card-img-wrap {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
background: #F0EDE8;
|
||||
}
|
||||
.thumb-card-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.thumb-card-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
.thumb-card-badge {
|
||||
position: absolute;
|
||||
top: 4px; left: 4px;
|
||||
background: rgba(0,0,0,0.5);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.thumb-card-text {
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
color: #64748B;
|
||||
line-height: 1.4;
|
||||
min-height: 20px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
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); }
|
||||
50% { transform: scale(1.08); }
|
||||
}
|
||||
</style>
|
||||
199
lesingle-aicreate-client/src/views/SaveSuccess.vue
Normal file
199
lesingle-aicreate-client/src/views/SaveSuccess.vue
Normal file
@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div class="success-page">
|
||||
<!-- 撒花装饰 -->
|
||||
<div class="confetti c1">🎊</div>
|
||||
<div class="confetti c2">🌟</div>
|
||||
<div class="confetti c3">✨</div>
|
||||
<div class="confetti c4">🎉</div>
|
||||
<div class="confetti c5">⭐</div>
|
||||
<div class="confetti c6">🎊</div>
|
||||
|
||||
<div class="success-content">
|
||||
<!-- 撒花大图标 -->
|
||||
<div class="celebration-icon">🎉</div>
|
||||
<div class="success-title">保存成功!</div>
|
||||
<div class="success-sub">太棒了,你的绘本已保存</div>
|
||||
|
||||
<!-- 封面卡片 - 3D 微倾斜效果 -->
|
||||
<div class="cover-card-wrap" v-if="coverUrl">
|
||||
<div class="cover-card">
|
||||
<img :src="coverUrl" class="cover-img" />
|
||||
<div class="cover-info">
|
||||
<div class="cover-name">{{ title }}</div>
|
||||
<div v-if="author" class="cover-author">作者:{{ author }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-group">
|
||||
<button class="btn-primary action-btn" @click="goDubbing">
|
||||
<span class="action-icon">🎙️</span>
|
||||
<div class="action-text">
|
||||
<div class="action-main">给绘本配音</div>
|
||||
<div class="action-desc">为每一页添加AI语音</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { getWorkDetail } from '@/api'
|
||||
import { store } from '@/utils/store'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const workId = computed(() => route.params.workId || store.workId)
|
||||
|
||||
const coverUrl = ref('')
|
||||
const title = ref('')
|
||||
const author = ref('')
|
||||
|
||||
async function loadWork() {
|
||||
try {
|
||||
if (!store.workDetail || store.workDetail.workId !== workId.value) {
|
||||
store.workDetail = null
|
||||
const res = await getWorkDetail(workId.value)
|
||||
store.workDetail = res.data
|
||||
}
|
||||
const w = store.workDetail
|
||||
title.value = w.title || '我的绘本'
|
||||
author.value = w.author || ''
|
||||
coverUrl.value = w.pageList?.[0]?.imageUrl || ''
|
||||
} catch { /* show page without cover */ }
|
||||
}
|
||||
|
||||
function goDubbing() {
|
||||
router.push(`/dubbing/${workId.value}`)
|
||||
}
|
||||
|
||||
|
||||
|
||||
onMounted(loadWork)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.success-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(160deg, #FFF8E1 0%, #FFECB3 40%, #FFE0B2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 撒花动画 */
|
||||
.confetti {
|
||||
position: absolute;
|
||||
font-size: 24px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
animation: confettiFall 3s ease-in-out infinite;
|
||||
}
|
||||
.c1 { left: 10%; top: -5%; animation-delay: 0s; font-size: 28px; }
|
||||
.c2 { left: 30%; top: -8%; animation-delay: 0.5s; font-size: 20px; }
|
||||
.c3 { left: 55%; top: -3%; animation-delay: 1s; font-size: 22px; }
|
||||
.c4 { left: 75%; top: -6%; animation-delay: 1.5s; font-size: 26px; }
|
||||
.c5 { left: 90%; top: -4%; animation-delay: 0.8s; font-size: 18px; }
|
||||
.c6 { left: 45%; top: -7%; animation-delay: 2s; font-size: 24px; }
|
||||
@keyframes confettiFall {
|
||||
0% { transform: translateY(0) rotate(0deg); opacity: 0; }
|
||||
10% { opacity: 0.8; }
|
||||
80% { opacity: 0.6; }
|
||||
100% { transform: translateY(100vh) rotate(720deg); opacity: 0; }
|
||||
}
|
||||
|
||||
.success-content {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 40px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.celebration-icon {
|
||||
font-size: 64px;
|
||||
animation: celebrationBounce 0.8s ease;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@keyframes celebrationBounce {
|
||||
0% { transform: scale(0) rotate(-20deg); opacity: 0; }
|
||||
50% { transform: scale(1.3) rotate(10deg); }
|
||||
70% { transform: scale(0.9) rotate(-5deg); }
|
||||
100% { transform: scale(1) rotate(0deg); opacity: 1; }
|
||||
}
|
||||
|
||||
.success-title {
|
||||
font-size: 26px;
|
||||
font-weight: 900;
|
||||
color: #4A3728;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.success-sub {
|
||||
font-size: 15px;
|
||||
color: #8D6E63;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
/* 封面卡片 3D 效果 */
|
||||
.cover-card-wrap {
|
||||
perspective: 600px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.cover-card {
|
||||
width: 260px;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06);
|
||||
transform: rotateY(-3deg) rotateX(2deg);
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
.cover-img {
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.cover-info {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.cover-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #4A3728;
|
||||
}
|
||||
.cover-author {
|
||||
font-size: 12px;
|
||||
color: #8D6E63;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
text-align: left;
|
||||
padding: 16px 20px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.action-icon { font-size: 24px; flex-shrink: 0; }
|
||||
.action-text { flex: 1; }
|
||||
.action-main { font-size: 15px; font-weight: 700; }
|
||||
.action-desc { font-size: 12px; opacity: 0.7; margin-top: 2px; }
|
||||
</style>
|
||||
286
lesingle-aicreate-client/src/views/StoryInput.vue
Normal file
286
lesingle-aicreate-client/src/views/StoryInput.vue
Normal file
@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<div class="story-page page-fullscreen">
|
||||
<PageHeader title="编写故事" subtitle="告诉AI你想要什么样的故事" :step="3" />
|
||||
|
||||
<div class="content page-content">
|
||||
<!-- 绘本信息 -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<span class="section-icon">📚</span>
|
||||
<span class="section-label">绘本信息</span>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<div class="field-label">
|
||||
<span>✏️</span> 绘本标题
|
||||
<span class="required-mark">必填</span>
|
||||
</div>
|
||||
<div class="input-wrap" :class="{ focus: bookTitleFocus }">
|
||||
<input v-model="bookTitle" class="text-input" placeholder="如:小璃的冒险"
|
||||
maxlength="12" @focus="bookTitleFocus = true" @blur="bookTitleFocus = false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主角名字 -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<span class="section-icon">🐰</span>
|
||||
<span class="section-label">主角名字</span>
|
||||
<span class="required-mark">必填</span>
|
||||
</div>
|
||||
<div class="input-wrap" :class="{ focus: heroNameFocus }">
|
||||
<input v-model="heroName" class="text-input" placeholder="给主角起个名字吧~"
|
||||
maxlength="10" @focus="heroNameFocus = true" @blur="heroNameFocus = false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 故事要素 -->
|
||||
<div class="section-card story-elements">
|
||||
<div class="section-header">
|
||||
<span class="section-icon">📖</span>
|
||||
<span class="section-label">故事要素</span>
|
||||
</div>
|
||||
|
||||
<div v-for="(f, i) in fields" :key="i" class="field-item">
|
||||
<div class="field-label">
|
||||
<span class="field-emoji">{{ f.emoji }}</span>
|
||||
<span>{{ f.label }}</span>
|
||||
<span v-if="f.required" class="required-mark">必填</span>
|
||||
<span v-else class="optional-mark">选填</span>
|
||||
</div>
|
||||
<div class="textarea-wrap" :class="{ focus: f.focused?.value }">
|
||||
<textarea
|
||||
v-model="f.value.value"
|
||||
:placeholder="f.placeholder"
|
||||
maxlength="100"
|
||||
rows="2"
|
||||
class="textarea-input"
|
||||
@focus="f.focused.value = true"
|
||||
@blur="f.focused.value = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="page-bottom">
|
||||
<button class="btn-primary create-btn" :disabled="!canSubmit" @click="goNext">
|
||||
<span class="btn-rocket">🚀</span> 开始创作绘本
|
||||
</button>
|
||||
<div class="time-hint" style="text-align:center;margin-top:6px;font-size:12px;color:var(--text-sub)">✨ 创作预计需要 1-3 分钟</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import { store } from '@/utils/store'
|
||||
|
||||
const router = useRouter()
|
||||
const bookTitle = ref('')
|
||||
const heroName = ref(store.selectedCharacter?.name || '')
|
||||
const storyStart = ref('')
|
||||
const meetWho = ref('')
|
||||
const whatHappens = ref('')
|
||||
|
||||
const bookTitleFocus = ref(false)
|
||||
const heroNameFocus = ref(false)
|
||||
|
||||
const fields = [
|
||||
{ emoji: '🌅', label: '故事开始', placeholder: '如:一个阳光明媚的早晨...', value: storyStart, required: false, focused: ref(false) },
|
||||
{ emoji: '👋', label: '遇见谁', placeholder: '如:遇到了一只迷路的小鸟', value: meetWho, required: false, focused: ref(false) },
|
||||
{ emoji: '⚡', label: '发生什么', placeholder: '如:一起去森林探险寻找宝藏', value: whatHappens, required: true, focused: ref(false) },
|
||||
]
|
||||
|
||||
const canSubmit = computed(() => bookTitle.value.trim() && heroName.value.trim() && whatHappens.value.trim())
|
||||
|
||||
let submitted = false
|
||||
const goNext = () => {
|
||||
if (submitted) return // 防重复点击
|
||||
submitted = true
|
||||
|
||||
const parts = []
|
||||
parts.push(`主角名字:${heroName.value}`)
|
||||
if (storyStart.value.trim()) parts.push(`故事开始:${storyStart.value.trim()}`)
|
||||
if (meetWho.value.trim()) parts.push(`遇见谁:${meetWho.value.trim()}`)
|
||||
parts.push(`发生什么:${whatHappens.value.trim()}`)
|
||||
|
||||
store.storyData = {
|
||||
heroName: heroName.value,
|
||||
storyHint: parts.join(';'),
|
||||
title: bookTitle.value.trim()
|
||||
}
|
||||
router.push('/creating')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.story-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #FFF8E7 0%, #FFFAF0 30%, #FFF5E6 60%, #FFFDF7 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: rgba(255,255,255,0.92);
|
||||
border-radius: 22px;
|
||||
padding: 18px 18px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.05);
|
||||
// 书页纹理效果
|
||||
border-left: 4px solid #FFE4C8;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: -4px;
|
||||
bottom: 12px;
|
||||
width: 4px;
|
||||
background: repeating-linear-gradient(
|
||||
180deg,
|
||||
transparent 0px,
|
||||
transparent 4px,
|
||||
#FFD4A8 4px,
|
||||
#FFD4A8 8px
|
||||
);
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.section-icon { font-size: 20px; }
|
||||
.section-label {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.input-wrap {
|
||||
background: #FFF8F0;
|
||||
border-radius: 14px;
|
||||
border: 2px solid #FFE8D4;
|
||||
transition: all 0.3s;
|
||||
overflow: hidden;
|
||||
|
||||
&.focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 4px rgba(255,107,53,0.1);
|
||||
background: #FFF;
|
||||
}
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 14px 16px;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
box-sizing: border-box;
|
||||
font-weight: 500;
|
||||
|
||||
&::placeholder { color: #C8B8A8; }
|
||||
}
|
||||
|
||||
.field-item {
|
||||
margin-bottom: 14px;
|
||||
&:last-child { margin-bottom: 0; }
|
||||
}
|
||||
.field-label {
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.field-emoji { font-size: 16px; }
|
||||
.required-mark {
|
||||
color: var(--primary);
|
||||
font-size: 11px;
|
||||
background: rgba(255,107,53,0.1);
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.optional-mark {
|
||||
color: #94A3B8;
|
||||
font-size: 11px;
|
||||
background: rgba(148,163,184,0.1);
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.textarea-wrap {
|
||||
background: #FFF8F0;
|
||||
border-radius: 14px;
|
||||
border: 2px solid #FFE8D4;
|
||||
transition: all 0.3s;
|
||||
overflow: hidden;
|
||||
|
||||
&.focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 4px rgba(255,107,53,0.1);
|
||||
background: #FFF;
|
||||
}
|
||||
}
|
||||
|
||||
.textarea-input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
resize: none;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.6;
|
||||
font-weight: 500;
|
||||
|
||||
&::placeholder { color: #C8B8A8; }
|
||||
}
|
||||
|
||||
.bottom-area { margin-top: auto; padding-top: 8px; padding-bottom: 20px; }
|
||||
|
||||
.create-btn {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 18px !important;
|
||||
padding: 18px 0 !important;
|
||||
border-radius: 28px !important;
|
||||
background: linear-gradient(135deg, #FF8C42, #FF6B35, #FF5722) !important;
|
||||
box-shadow: 0 6px 24px rgba(255,107,53,0.35) !important;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.btn-rocket { font-size: 20px; }
|
||||
|
||||
.time-hint {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--primary);
|
||||
margin-top: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
209
lesingle-aicreate-client/src/views/StyleSelect.vue
Normal file
209
lesingle-aicreate-client/src/views/StyleSelect.vue
Normal file
@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div class="style-page page-fullscreen">
|
||||
<PageHeader title="选择画风" subtitle="为绘本挑选一种你喜欢的画风" :step="2" />
|
||||
|
||||
<div class="content page-content">
|
||||
<!-- 提示文字 -->
|
||||
<div class="tip-banner">
|
||||
<span class="tip-icon">🎨</span>
|
||||
<span>每种画风都有独特魅力,选一个最喜欢的吧!</span>
|
||||
</div>
|
||||
|
||||
<div class="style-grid">
|
||||
<div
|
||||
v-for="s in styles"
|
||||
:key="s.styleId"
|
||||
class="style-card"
|
||||
:class="{ selected: selected === s.styleId }"
|
||||
@click="selected = s.styleId"
|
||||
>
|
||||
<!-- 选中角标 -->
|
||||
<div v-if="selected === s.styleId" class="check-corner" :style="{ background: s.color }">
|
||||
<span>✓</span>
|
||||
</div>
|
||||
|
||||
<div class="style-preview" :style="{ background: `linear-gradient(135deg, ${s.color}18, ${s.color}35)` }">
|
||||
<span class="style-emoji">{{ s.emoji }}</span>
|
||||
</div>
|
||||
<div class="style-info">
|
||||
<div class="style-name">{{ s.styleName }}</div>
|
||||
<div class="style-desc">{{ s.desc }}</div>
|
||||
</div>
|
||||
<div v-if="selected === s.styleId" class="style-selected-tag" :style="{ background: s.color }">
|
||||
已选择
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="page-bottom">
|
||||
<button class="btn-primary next-btn" :disabled="!selected" @click="goNext">
|
||||
下一步,编故事 →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import { store } from '@/utils/store'
|
||||
|
||||
const router = useRouter()
|
||||
const selected = ref('')
|
||||
|
||||
const styles = [
|
||||
{ styleId: 'style_cartoon', styleName: '卡通风格', emoji: '🎨', color: '#FF6B35', desc: '色彩鲜明,充满童趣' },
|
||||
{ styleId: 'style_watercolor', styleName: '水彩风格', emoji: '🖌️', color: '#2EC4B6', desc: '柔和透明,梦幻浪漫' },
|
||||
{ styleId: 'style_ink', styleName: '水墨国风', emoji: '🏮', color: '#6C63FF', desc: '古韵悠长,意境深远' },
|
||||
{ styleId: 'style_pencil', styleName: '彩铅风格', emoji: '✏️', color: '#FFD166', desc: '细腻温暖,自然亲切' },
|
||||
{ styleId: 'style_oilpaint', styleName: '油画风格', emoji: '🖼️', color: '#8B5E3C', desc: '色彩浓郁,质感丰富' },
|
||||
{ styleId: 'style_collage', styleName: '剪贴画', emoji: '✂️', color: '#E91E63', desc: '趣味拼贴,创意满满' },
|
||||
]
|
||||
|
||||
const goNext = () => {
|
||||
store.selectedStyle = selected.value
|
||||
router.push('/story')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.style-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #F5F0FF 0%, #FFF0F5 40%, #FFF5F0 70%, #FFFDF7 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tip-banner {
|
||||
background: rgba(255,255,255,0.85);
|
||||
border: 1.5px solid #E8D5F5;
|
||||
border-radius: 16px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #7B61B8;
|
||||
}
|
||||
.tip-icon { font-size: 18px; }
|
||||
|
||||
.style-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.style-card {
|
||||
background: rgba(255,255,255,0.92);
|
||||
border-radius: 22px;
|
||||
padding: 14px;
|
||||
text-align: center;
|
||||
border: 3px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.05);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:active { transform: scale(0.97); }
|
||||
|
||||
&.selected {
|
||||
transform: scale(1.04);
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,0.12);
|
||||
|
||||
.style-preview {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 动态设置选中边框颜色
|
||||
.style-card.selected {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.check-corner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 0 19px 0 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
z-index: 2;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.style-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 18px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.style-emoji {
|
||||
font-size: 52px;
|
||||
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.1));
|
||||
}
|
||||
|
||||
.style-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.style-name {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.style-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-sub);
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.style-selected-tag {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 4px 14px;
|
||||
display: inline-block;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.bottom-area { margin-top: auto; padding-top: 16px; }
|
||||
|
||||
.next-btn {
|
||||
font-size: 17px !important;
|
||||
padding: 16px 0 !important;
|
||||
border-radius: 28px !important;
|
||||
background: linear-gradient(135deg, #FF8C42, #FF6B35) !important;
|
||||
box-shadow: 0 6px 24px rgba(255,107,53,0.3) !important;
|
||||
}
|
||||
</style>
|
||||
435
lesingle-aicreate-client/src/views/Upload.vue
Normal file
435
lesingle-aicreate-client/src/views/Upload.vue
Normal file
@ -0,0 +1,435 @@
|
||||
<template>
|
||||
<div class="upload-page page-fullscreen">
|
||||
<PageHeader title="上传作品" subtitle="拍下孩子的画,让AI识别角色" :step="0" />
|
||||
|
||||
<div class="content page-content">
|
||||
<template v-if="!preview">
|
||||
<!-- 上传区域 -->
|
||||
<div class="upload-area card">
|
||||
<template v-if="uploading">
|
||||
<div class="uploading-icon">📤</div>
|
||||
<div class="uploading-text">正在上传...</div>
|
||||
<div class="progress-bar"><div class="progress-fill" /></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="upload-icon">🖼️</div>
|
||||
<div class="upload-title">上传孩子的画作</div>
|
||||
<div class="upload-desc">支持拍照或从相册选择<br/>AI会自动识别画中的角色</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 拍照/相册按钮 -->
|
||||
<div class="action-btns">
|
||||
<div class="action-btn camera" @click="pickImage('camera')">
|
||||
<div class="action-emoji">📷</div>
|
||||
<div class="action-label">拍照</div>
|
||||
<input ref="cameraInput" type="file" accept="image/*" capture="environment" @change="onFileChange" style="display:none" />
|
||||
</div>
|
||||
<div class="action-btn album" @click="pickImage('album')">
|
||||
<div class="action-emoji">🖼️</div>
|
||||
<div class="action-label">相册</div>
|
||||
<input ref="albumInput" type="file" accept="image/*" @change="onFileChange" style="display:none" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- 预览 -->
|
||||
<div class="preview-card card">
|
||||
<div class="preview-image">
|
||||
<img :src="preview" alt="预览" />
|
||||
</div>
|
||||
<!-- 识别中:填满空间 -->
|
||||
<div v-if="uploading" class="recognizing-box">
|
||||
<div class="recognizing-emojis">
|
||||
<span class="recognizing-emoji e1">🎨</span>
|
||||
<span class="recognizing-emoji e2">✏️</span>
|
||||
<span class="recognizing-emoji e3">🖌️</span>
|
||||
</div>
|
||||
<div class="recognizing-text">{{ uploadProgress || 'AI 小画家正在认识你的角色...' }}</div>
|
||||
</div>
|
||||
<div v-else class="preview-info">
|
||||
<div class="preview-ok">✅ 已选择图片</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 识别中的趣味等待内容(填满空白) -->
|
||||
<div v-if="uploading" class="waiting-content">
|
||||
<div class="waiting-card">
|
||||
<div class="waiting-title">✨ AI 正在为你做这些事</div>
|
||||
<div class="waiting-steps">
|
||||
<div class="w-step" :class="{ active: uploadStage >= 1 }">
|
||||
<span class="w-icon">📤</span>
|
||||
<span>上传画作到云端</span>
|
||||
<span v-if="uploadStage >= 1" class="w-done">✓</span>
|
||||
</div>
|
||||
<div class="w-step" :class="{ active: uploadStage >= 2 }">
|
||||
<span class="w-icon">👀</span>
|
||||
<span>AI 识别画中角色</span>
|
||||
<span v-if="uploadStage >= 2" class="w-done">✓</span>
|
||||
</div>
|
||||
<div class="w-step" :class="{ active: uploadStage >= 3 }">
|
||||
<span class="w-icon">🎭</span>
|
||||
<span>提取角色特征</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="waiting-funfact">
|
||||
<span class="ff-icon">💡</span>
|
||||
<span class="ff-text">你知道吗?AI 画师可以识别超过 100 种不同的卡通角色哦!</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!quotaOk" class="quota-warn">{{ quotaMsg }}</div>
|
||||
<div v-if="uploadError" class="upload-error">{{ uploadError }}</div>
|
||||
<div class="preview-actions">
|
||||
<button class="btn-ghost" @click="reset">重新上传</button>
|
||||
<button class="btn-primary" :disabled="uploading" @click="goNext">
|
||||
{{ uploading ? 'AI 识别中...' : '识别角色 →' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import { store } from '@/utils/store'
|
||||
import { extractCharacters, ossUpload, checkQuota } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const preview = ref(null)
|
||||
const uploading = ref(false)
|
||||
const cameraInput = ref(null)
|
||||
const albumInput = ref(null)
|
||||
const fileSizeInfo = ref('')
|
||||
const compressed = ref(false)
|
||||
const saveOriginal = ref(false)
|
||||
const uploadProgress = ref('')
|
||||
const uploadStage = ref(0) // 0=未开始 1=上传中 2=识别中 3=提取中
|
||||
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
|
||||
} catch (e) {
|
||||
const msg = e.message || ''
|
||||
// 只有明确的额度不足才阻止,网络/签名错误放行(后端会二次校验)
|
||||
if (msg.includes('额度') || msg.includes('QUOTA') || msg.includes('30003')) {
|
||||
quotaOk.value = false
|
||||
quotaMsg.value = '创作额度不足,请联系管理员'
|
||||
}
|
||||
// 其他错误(403签名/网络超时等)静默放行
|
||||
}
|
||||
})
|
||||
|
||||
const pickImage = (type) => {
|
||||
if (type === 'camera') cameraInput.value?.click()
|
||||
else albumInput.value?.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas 压缩:将超高清图片缩放到 maxWidth,输出 JPEG 质量 quality
|
||||
* 目标:手机原片 8-20MB → 压缩后 <2MB
|
||||
*/
|
||||
const MAX_WIDTH = 1920
|
||||
const MAX_SIZE = 2 * 1024 * 1024 // 2MB
|
||||
const JPEG_QUALITY = 0.85
|
||||
|
||||
function compressImage(file) {
|
||||
return new Promise((resolve) => {
|
||||
// 小于 2MB 的图片不压缩
|
||||
if (file.size <= MAX_SIZE) {
|
||||
resolve(file)
|
||||
return
|
||||
}
|
||||
|
||||
const img = new Image()
|
||||
const url = URL.createObjectURL(file)
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
let { width, height } = img
|
||||
// 缩放到 maxWidth
|
||||
if (width > MAX_WIDTH) {
|
||||
height = Math.round(height * MAX_WIDTH / width)
|
||||
width = MAX_WIDTH
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
const compressedFile = new File([blob], file.name.replace(/\.\w+$/, '.jpg'), {
|
||||
type: 'image/jpeg',
|
||||
lastModified: Date.now()
|
||||
})
|
||||
resolve(compressedFile)
|
||||
} else {
|
||||
resolve(file) // fallback to original
|
||||
}
|
||||
},
|
||||
'image/jpeg',
|
||||
JPEG_QUALITY
|
||||
)
|
||||
}
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(file) // fallback
|
||||
}
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
const onFileChange = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
uploading.value = true
|
||||
compressed.value = false
|
||||
|
||||
const originalSize = file.size
|
||||
|
||||
// 压缩超大图片
|
||||
const processedFile = await compressImage(file)
|
||||
selectedFile = processedFile
|
||||
|
||||
if (processedFile.size < originalSize) {
|
||||
compressed.value = true
|
||||
fileSizeInfo.value = `${formatSize(originalSize)} → ${formatSize(processedFile.size)}`
|
||||
} else {
|
||||
fileSizeInfo.value = formatSize(originalSize)
|
||||
}
|
||||
|
||||
// 本地预览(用压缩后的文件)
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
preview.value = ev.target.result
|
||||
uploading.value = false
|
||||
}
|
||||
reader.readAsDataURL(processedFile)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
preview.value = null
|
||||
selectedFile = null
|
||||
fileSizeInfo.value = ''
|
||||
compressed.value = false
|
||||
uploadError.value = ''
|
||||
}
|
||||
|
||||
const uploadError = ref('')
|
||||
|
||||
// 错误消息脱敏:不向用户暴露后端服务名称
|
||||
function sanitizeError(msg) {
|
||||
if (!msg) return '请稍后重试'
|
||||
if (msg.includes('火山引擎') || msg.includes('VolcEngine')) return '图片分析服务暂时不可用,请稍后重试'
|
||||
if (msg.includes('MiniMax')) return '语音服务暂时不可用,请稍后重试'
|
||||
if (msg.includes('OSS') || msg.includes('oss')) return '文件存储服务异常,请稍后重试'
|
||||
if (msg.includes('401') || msg.includes('签名')) return '请返回首页重新开始'
|
||||
if (msg.includes('403')) return '认证失败,请检查机构配置后重试'
|
||||
if (msg.includes('timeout') || msg.includes('超时')) return '网络超时,请检查网络后重试'
|
||||
return msg
|
||||
}
|
||||
|
||||
const goNext = async () => {
|
||||
if (!selectedFile) return
|
||||
if (!quotaOk.value) {
|
||||
uploadError.value = quotaMsg.value || '创作额度不足'
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
uploadError.value = ''
|
||||
uploadStage.value = 1
|
||||
try {
|
||||
// Step 1: STS 直传图片到 OSS
|
||||
uploadProgress.value = '上传画作到云端...'
|
||||
const ossUrl = await ossUpload(selectedFile, {
|
||||
type: 'img',
|
||||
onProgress: (p) => { uploadProgress.value = `上传画作 ${p}%` }
|
||||
})
|
||||
uploadStage.value = 2
|
||||
|
||||
// Step 2: 用 URL 调 A6 角色提取
|
||||
uploadProgress.value = 'AI 正在识别角色...'
|
||||
const res = await extractCharacters(ossUrl, {
|
||||
saveOriginal: saveOriginal.value
|
||||
})
|
||||
const data = res.data || {}
|
||||
const chars = (data.characters || []).map(c => ({
|
||||
...c,
|
||||
type: c.charType || c.type || 'SIDEKICK'
|
||||
}))
|
||||
if (chars.length === 0) throw new Error('AI未识别到角色,请更换图片重试')
|
||||
store.extractId = data.extractId || ''
|
||||
store.characters = chars
|
||||
store.imageUrl = ossUrl
|
||||
if (data.workId) store.originalWorkId = data.workId
|
||||
router.push('/characters')
|
||||
} catch (e) {
|
||||
uploadError.value = '识别失败:' + sanitizeError(e.message)
|
||||
uploading.value = false
|
||||
uploadProgress.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.upload-page {
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.upload-area {
|
||||
flex: 1;
|
||||
min-height: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 3px dashed var(--border);
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
}
|
||||
.upload-icon, .uploading-icon {
|
||||
font-size: 64px;
|
||||
}
|
||||
.uploading-icon { animation: pulse 1.5s infinite; }
|
||||
.upload-title { font-size: 18px; font-weight: 700; margin-top: 16px; }
|
||||
.upload-desc { font-size: 14px; color: var(--text-sub); margin-top: 8px; line-height: 1.6; }
|
||||
.uploading-text { font-size: 16px; font-weight: 600; margin-top: 16px; }
|
||||
.progress-bar {
|
||||
width: 200px; height: 6px; background: var(--border); border-radius: 3px; margin-top: 16px; overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
width: 100%; height: 100%; background: var(--gradient); border-radius: 3px;
|
||||
animation: loading 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes loading { 0%{transform:translateX(-100%)} 100%{transform:translateX(200%)} }
|
||||
|
||||
.action-btns {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
border-radius: var(--radius);
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow);
|
||||
|
||||
&.camera { background: var(--gradient); }
|
||||
&.album { background: var(--gradient-purple); }
|
||||
}
|
||||
.action-emoji { font-size: 32px; }
|
||||
.action-label { font-size: 15px; font-weight: 700; color: #fff; margin-top: 8px; }
|
||||
|
||||
.preview-card { overflow: hidden; flex: 1; }
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
background: #F5F0E8;
|
||||
img { width: 100%; height: 100%; object-fit: cover; }
|
||||
}
|
||||
.preview-info { padding: 20px; text-align: center; }
|
||||
.preview-ok { font-size: 15px; font-weight: 600; color: var(--success); }
|
||||
|
||||
/* 儿童风格识别中动画 */
|
||||
.recognizing-box {
|
||||
background: linear-gradient(135deg, #FFF8E1, #FFFDE7);
|
||||
border-radius: 0 0 16px 16px;
|
||||
padding: 20px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.recognizing-emojis {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.recognizing-emoji {
|
||||
font-size: 32px;
|
||||
display: inline-block;
|
||||
animation: emojiPop 1.8s ease-in-out infinite;
|
||||
}
|
||||
.recognizing-emoji.e1 { animation-delay: 0s; }
|
||||
.recognizing-emoji.e2 { animation-delay: 0.4s; }
|
||||
.recognizing-emoji.e3 { animation-delay: 0.8s; }
|
||||
@keyframes emojiPop {
|
||||
0%, 100% { transform: scale(1) rotate(0deg); opacity: 0.5; }
|
||||
50% { transform: scale(1.3) rotate(10deg); opacity: 1; }
|
||||
}
|
||||
.recognizing-text {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #F59E0B;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
// 等待内容 — 填满空白
|
||||
.waiting-content {
|
||||
display: flex; flex-direction: column; gap: 12px; flex: 1;
|
||||
}
|
||||
.waiting-card {
|
||||
background: rgba(255,255,255,0.92); border-radius: 20px;
|
||||
padding: 18px 20px; box-shadow: 0 4px 16px rgba(0,0,0,0.05);
|
||||
}
|
||||
.waiting-title {
|
||||
font-size: 15px; font-weight: 800; color: #1E293B; margin-bottom: 14px;
|
||||
}
|
||||
.waiting-steps { display: flex; flex-direction: column; gap: 10px; }
|
||||
.w-step {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 14px; border-radius: 14px; background: #F8F8F5;
|
||||
font-size: 14px; color: #94A3B8; transition: all 0.3s;
|
||||
&.active { background: #FFF8E7; color: #1E293B; font-weight: 600; }
|
||||
}
|
||||
.w-icon { font-size: 20px; }
|
||||
.w-done { margin-left: auto; color: #10B981; font-weight: 700; font-size: 16px; }
|
||||
|
||||
.waiting-funfact {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
background: linear-gradient(135deg, #EDE9FE, #F5F3FF);
|
||||
border-radius: 16px; padding: 14px 16px;
|
||||
}
|
||||
.ff-icon { font-size: 20px; flex-shrink: 0; }
|
||||
.ff-text { font-size: 13px; color: #6D28D9; line-height: 1.6; }
|
||||
.quota-warn {
|
||||
background: #FEF3C7; color: #92400E; font-size: 13px; text-align: center;
|
||||
padding: 10px 16px; border-radius: 10px; margin-top: 12px; font-weight: 600;
|
||||
}
|
||||
.upload-error { color: #EF4444; font-size: 13px; text-align: center; margin-top: 12px; font-weight: 500; }
|
||||
.preview-actions { display: flex; gap: 12px; margin-top: 12px; button { flex: 1; } }
|
||||
</style>
|
||||
352
lesingle-aicreate-client/src/views/Welcome.vue
Normal file
352
lesingle-aicreate-client/src/views/Welcome.vue
Normal file
@ -0,0 +1,352 @@
|
||||
<template>
|
||||
<div class="welcome-page">
|
||||
<!-- Hero(紧凑版) -->
|
||||
<div class="hero-compact">
|
||||
<div class="hero-bg-deco d1">⭐</div>
|
||||
<div class="hero-bg-deco d2">🌈</div>
|
||||
<div class="hero-bg-deco d3">✨</div>
|
||||
<div class="hero-row">
|
||||
<div class="hero-books">
|
||||
<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>
|
||||
</div>
|
||||
<div class="hero-tag">✨ 拍一张画,AI帮你变成绘本</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区(flex:1 撑满中间) -->
|
||||
<div class="main-area">
|
||||
<!-- 流程步骤(垂直时间线) -->
|
||||
<div class="steps-card">
|
||||
<div class="steps-header">🎯 创作流程</div>
|
||||
<div class="steps-timeline">
|
||||
<div v-for="(s, i) in steps" :key="i" class="step-item">
|
||||
<div class="step-left">
|
||||
<div class="step-num" :style="{ background: s.color }">{{ i + 1 }}</div>
|
||||
<div v-if="i < steps.length - 1" class="step-line" :style="{ background: s.color + '40' }" />
|
||||
</div>
|
||||
<div class="step-right">
|
||||
<div class="step-head">
|
||||
<span class="step-emoji">{{ s.emoji }}</span>
|
||||
<span class="step-title">{{ s.title }}</span>
|
||||
</div>
|
||||
<div class="step-desc">{{ s.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 特色标签 -->
|
||||
<div class="features-row">
|
||||
<div class="feature-tag">🎨 AI绘画</div>
|
||||
<div class="feature-tag">📖 自动排版</div>
|
||||
<div class="feature-tag">🔊 语音配音</div>
|
||||
<div class="feature-tag">🎤 人工配音</div>
|
||||
</div>
|
||||
|
||||
<!-- 亮点描述 -->
|
||||
<div class="highlights">
|
||||
<div class="hl-item">
|
||||
<span class="hl-icon">🖌️</span>
|
||||
<span class="hl-text">上传孩子的画作,AI 自动识别角色</span>
|
||||
</div>
|
||||
<div class="hl-item">
|
||||
<span class="hl-icon">📖</span>
|
||||
<span class="hl-text">一键生成多页精美绘本故事</span>
|
||||
</div>
|
||||
<div class="hl-item">
|
||||
<span class="hl-icon">🔊</span>
|
||||
<span class="hl-text">AI 配音或亲自录音,让故事活起来</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部(固定) -->
|
||||
<div class="bottom-area safe-bottom">
|
||||
<button class="btn-primary start-btn" @click="handleStart">
|
||||
<span class="btn-icon">🚀</span> 开始创作
|
||||
</button>
|
||||
<div class="slogan">让每个孩子都是小画家 ✨</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { store } from '@/utils/store'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 测试固定号码,上线后替换为企业登录对接
|
||||
const TEST_PHONE = '18911223344'
|
||||
|
||||
const steps = [
|
||||
{ emoji: '📸', title: '拍照上传', desc: '拍下孩子的画作', color: '#FF6B35' },
|
||||
{ emoji: '🎭', title: '角色提取', desc: 'AI智能识别角色', color: '#6C63FF' },
|
||||
{ emoji: '✏️', title: '编排故事', desc: '选画风填要素', color: '#2EC4B6' },
|
||||
{ emoji: '📖', title: '绘本创作', desc: 'AI生成完整绘本', color: '#FFD166' },
|
||||
]
|
||||
|
||||
// 从 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')
|
||||
|
||||
if (urlToken) {
|
||||
if (!urlOrgId) {
|
||||
console.error('[Welcome] URL缺少orgId参数,企业重定向应包含 ?token=xxx&orgId=yyy&phone=zzz')
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
// ★ 认证双模式:
|
||||
// 生产环境: 企业后端调 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')
|
||||
}
|
||||
store.reset() // 每次开始创作都清空上一次的缓存
|
||||
router.push('/upload')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.welcome-page {
|
||||
height: 100vh;
|
||||
background: linear-gradient(180deg, #FFF8E7 0%, #FFF3E0 30%, #FFF0F0 60%, #FFFDF7 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- Hero 紧凑版 ---- */
|
||||
.hero-compact {
|
||||
background: linear-gradient(135deg, #FF6B35 0%, #FF8F65 40%, #FFB088 70%, #FFCBA4 100%);
|
||||
border-radius: 0 0 32px 32px;
|
||||
padding: 40px 20px 18px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hero-bg-deco {
|
||||
position: absolute;
|
||||
opacity: 0.18;
|
||||
pointer-events: none;
|
||||
animation: twinkle 3s ease-in-out infinite;
|
||||
&.d1 { top: 10px; left: 16px; font-size: 20px; }
|
||||
&.d2 { top: 16px; right: 20px; font-size: 18px; animation-delay: 0.8s; }
|
||||
&.d3 { bottom: 10px; right: 30%; font-size: 16px; animation-delay: 1.5s; }
|
||||
}
|
||||
@keyframes twinkle {
|
||||
0%, 100% { transform: scale(1) rotate(0deg); opacity: 0.18; }
|
||||
50% { transform: scale(1.15) rotate(8deg); opacity: 0.35; }
|
||||
}
|
||||
.hero-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.hero-books {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.book-icon {
|
||||
font-size: 26px;
|
||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.15));
|
||||
animation: bookBounce 2.5s ease-in-out infinite;
|
||||
&:nth-child(2) { animation-delay: 0.3s; }
|
||||
&:nth-child(3) { animation-delay: 0.6s; }
|
||||
&:nth-child(4) { animation-delay: 0.9s; }
|
||||
}
|
||||
@keyframes bookBounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-5px); }
|
||||
}
|
||||
.hero-text { text-align: left; }
|
||||
.hero-title {
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
color: #fff;
|
||||
letter-spacing: 3px;
|
||||
text-shadow: 0 2px 8px rgba(0,0,0,0.18);
|
||||
}
|
||||
.hero-sub {
|
||||
font-size: 13px;
|
||||
color: rgba(255,255,255,0.9);
|
||||
margin-top: 2px;
|
||||
letter-spacing: 1.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.hero-tag {
|
||||
margin-top: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: rgba(255,255,255,0.22);
|
||||
border-radius: 20px;
|
||||
padding: 5px 16px;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
backdrop-filter: blur(8px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- 主内容区 ---- */
|
||||
.main-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
padding: 10px 16px 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* 流程步骤(垂直时间线) */
|
||||
.steps-card {
|
||||
background: rgba(255,255,255,0.92);
|
||||
border-radius: 16px;
|
||||
padding: 12px 14px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.05);
|
||||
}
|
||||
.steps-header {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.steps-timeline { display: flex; flex-direction: column; }
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.step-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.step-num {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
.step-line {
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
margin: 3px 0;
|
||||
}
|
||||
.step-right { padding-top: 4px; }
|
||||
.step-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.step-emoji { font-size: 18px; }
|
||||
.step-title { font-size: 14px; font-weight: 800; color: #333; }
|
||||
.step-desc { font-size: 11px; color: #999; margin-top: 1px; padding-left: 24px; }
|
||||
|
||||
/* 特色标签 */
|
||||
.features-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.feature-tag {
|
||||
background: rgba(255,255,255,0.85);
|
||||
border: 1.5px solid #FFE4D0;
|
||||
border-radius: 20px;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--primary, #FF6B35);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 亮点描述 */
|
||||
.highlights {
|
||||
background: rgba(255,255,255,0.88);
|
||||
border-radius: 16px;
|
||||
padding: 12px 14px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
|
||||
}
|
||||
.hl-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 0;
|
||||
&:not(:last-child) { border-bottom: 1px dashed #FFE4D0; }
|
||||
}
|
||||
.hl-icon { font-size: 20px; flex-shrink: 0; }
|
||||
.hl-text {
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ---- 底部固定区 ---- */
|
||||
.bottom-area {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 16px 8px;
|
||||
}
|
||||
.start-btn {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 16px 0 !important;
|
||||
font-size: 18px !important;
|
||||
border-radius: 28px !important;
|
||||
background: linear-gradient(135deg, #FF8C42, #FF6B35, #FF5722) !important;
|
||||
box-shadow: 0 6px 24px rgba(255,107,53,0.35) !important;
|
||||
letter-spacing: 2px;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
&:active { transform: scale(0.98); opacity: 0.9; }
|
||||
}
|
||||
.btn-icon { font-size: 20px; }
|
||||
.slogan {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--primary, #FF6B35);
|
||||
font-weight: 600;
|
||||
margin-top: 8px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
</style>
|
||||
71
lesingle-aicreate-client/start.bat
Normal file
71
lesingle-aicreate-client/start.bat
Normal file
@ -0,0 +1,71 @@
|
||||
@echo off
|
||||
chcp 65001 >CON
|
||||
setlocal EnableDelayedExpansion
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
set APP_NAME=lesingle-aicreate-client
|
||||
set APP_PORT=3001
|
||||
|
||||
echo ============================================================
|
||||
echo %APP_NAME% Port: %APP_PORT%
|
||||
echo ============================================================
|
||||
echo.
|
||||
|
||||
echo [1/3] Checking port %APP_PORT%...
|
||||
set "KILL_PID="
|
||||
for /f "tokens=5" %%a in ('netstat -ano 2^>^&1 ^| findstr /R "LISTENING" ^| findstr /R ":%APP_PORT% "') do (
|
||||
if not "%%a"=="0" set "KILL_PID=%%a"
|
||||
)
|
||||
if defined KILL_PID (
|
||||
echo Killing PID=!KILL_PID!...
|
||||
taskkill /F /PID !KILL_PID!
|
||||
timeout /t 2 /nobreak >CON
|
||||
) else (
|
||||
echo Port %APP_PORT% is free
|
||||
)
|
||||
echo.
|
||||
|
||||
echo [2/3] Checking dependencies...
|
||||
if not exist "node_modules\" (
|
||||
echo Installing dependencies...
|
||||
where pnpm >CON 2>&1
|
||||
if !errorlevel! equ 0 (
|
||||
call pnpm install
|
||||
) else (
|
||||
echo pnpm not found, using npm...
|
||||
call npm install
|
||||
)
|
||||
if !errorlevel! neq 0 (
|
||||
echo [FAILED] Install failed
|
||||
goto :fail
|
||||
)
|
||||
echo.
|
||||
) else (
|
||||
echo Dependencies OK
|
||||
)
|
||||
echo.
|
||||
|
||||
echo [3/3] Starting dev server...
|
||||
echo.
|
||||
echo ============================================================
|
||||
echo URL: http://localhost:%APP_PORT%
|
||||
echo Proxy: /api -^> http://localhost:8080
|
||||
echo Press Ctrl+C to stop
|
||||
echo ============================================================
|
||||
echo.
|
||||
|
||||
where pnpm >CON 2>&1
|
||||
if !errorlevel! equ 0 (
|
||||
call pnpm exec vite --port %APP_PORT% --host
|
||||
) else (
|
||||
call npx vite --port %APP_PORT% --host
|
||||
)
|
||||
|
||||
goto :end
|
||||
|
||||
:fail
|
||||
echo.
|
||||
echo [Script stopped]
|
||||
:end
|
||||
pause
|
||||
38
lesingle-aicreate-client/vite.config.js
Normal file
38
lesingle-aicreate-client/vite.config.js
Normal file
@ -0,0 +1,38 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// HTTPS: 启动时加 --https 参数,或设环境变量 VITE_HTTPS=true
|
||||
// 默认 HTTP(局域网测试友好,无证书问题)
|
||||
const useHttps = process.argv.includes('--https') || process.env.VITE_HTTPS === 'true'
|
||||
let sslPlugin = []
|
||||
if (useHttps) {
|
||||
try {
|
||||
const basicSsl = (await import('@vitejs/plugin-basic-ssl')).default
|
||||
sslPlugin = [basicSsl()]
|
||||
} catch { /* basicSsl not installed, skip */ }
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), ...sslPlugin],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3001,
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://192.168.1.72:8080',
|
||||
// rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
changeOrigin: true
|
||||
},
|
||||
'/ws': {
|
||||
target: 'http://192.168.1.72:8080',
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
802
lesingle-aicreate-client/企业定制对接指南_V3.1.md
Normal file
802
lesingle-aicreate-client/企业定制对接指南_V3.1.md
Normal file
@ -0,0 +1,802 @@
|
||||
# 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
|
||||
5399
pnpm-lock.yaml
generated
5399
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user