feat: 重构 IdentifyFace.vue 为 Hooks 架构

- 新增 hooks/ 目录,包含三个专用 Hook:
  * useVoiceGeneration - 语音生成和校验逻辑
  * useDigitalHumanGeneration - 数字人视频生成逻辑
  * useIdentifyFaceController - 协调两个子 Hook 的控制器

- 新增 types/identify-face.ts 完整类型定义

- 重构 IdentifyFace.vue 使用 hooks 架构:
  * 视图层与业务逻辑分离
  * 状态管理清晰化
  * 模块解耦,逻辑清晰

- 遵循单一职责原则,每个 Hook 只负责一个领域
- 提升代码可测试性和可维护性
- 支持两种视频素材来源:素材库选择和直接上传
- 实现语音生成优先校验的业务规则

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-28 00:19:17 +08:00
parent effbbc694c
commit 36195ea55a
46 changed files with 4258 additions and 3454 deletions

View File

@@ -16,7 +16,7 @@ function saveTokens(info) {
tokenManager.setTokens({
accessToken: info.accessToken || '',
refreshToken: info.refreshToken || '',
expiresIn: info.expiresTime || 7200, // expiresTime 是秒数
expiresTime: info.expiresTime || 0, // 直接传递,由 token-manager 处理格式转换
tokenType: info.tokenType || 'Bearer'
})
}
@@ -71,7 +71,7 @@ export async function loginByPassword(mobile, password) {
const { clearUserInfoCache } = await import('@gold/hooks/web/useUserInfo')
clearUserInfoCache()
} catch (e) {
console.error('清除用户信息缓存失败:', e)
// 清除缓存失败不影响登录流程
}
return info;
@@ -126,7 +126,7 @@ export async function loginBySms(mobile, code) {
const { clearUserInfoCache } = await import('@gold/hooks/web/useUserInfo')
clearUserInfoCache()
} catch (e) {
console.error('清除用户信息缓存失败:', e)
// 清除缓存失败不影响登录流程
}
return info;
@@ -151,7 +151,7 @@ export async function refreshToken() {
const { clearUserInfoCache } = await import('@gold/hooks/web/useUserInfo')
clearUserInfoCache()
} catch (e) {
console.error('清除用户信息缓存失败:', e)
// 清除缓存失败不影响登录流程
}
return info;

View File

@@ -56,16 +56,13 @@ export const ChatMessageApi = {
onmessage: onMessage,
onerror: (err) => {
retryCount++
console.error('SSE错误重试次数:', retryCount, err)
// 调用自定义错误处理
if (typeof onError === 'function') {
onError(err)
}
// 超过最大重试次数,停止重连
if (retryCount > maxRetries) {
throw err // 抛出错误,停止自动重连
throw err
}
},
onclose: () => {

View File

@@ -61,12 +61,11 @@ export const CommonService = {
onmessage: onMessage,
onerror: (err) => {
retryCount++
console.error('SSE错误重试次数:', retryCount, err)
if (typeof onError === 'function') {
onError(err)
}
if (retryCount > maxRetries) {
throw err
}

View File

@@ -33,23 +33,16 @@ export function createHttpClient(options = {}) {
// 默认处理尝试刷新token
try {
await refreshToken()
// 刷新成功标记错误已处理token已更新
error._handled = true
error._tokenRefreshed = true
console.info('Token刷新成功可以重试原请求')
// 不抛出错误,交给上层决定是否重试
} catch (refreshError) {
// 刷新失败使用router跳转避免整页刷新
console.error('刷新token失败:', refreshError)
router.push('/login')
}
},
on403: (error) => {
// 403没有权限直接跳转到登录页
if (on403) {
on403(error)
} else {
console.warn('403权限不足使用router跳转到登录页')
router.push('/login')
}
},

View File

@@ -6,8 +6,15 @@ import { message } from "ant-design-vue"
import { MaterialService } from './material'
/**
* 人脸识别
* 显示加载提示
*/
const showLoading = (text) => message.loading(text, 0)
/**
* 销毁加载提示
*/
const hideLoading = () => message.destroy()
export function identifyFace(data) {
return request({
url: '/webApi/api/tik/kling/identify-face',
@@ -16,9 +23,6 @@ export function identifyFace(data) {
})
}
/**
* 创建口型同步任务
*/
export function createLipSyncTask(data) {
return request({
url: '/webApi/api/tik/kling/task/create',
@@ -27,9 +31,6 @@ export function createLipSyncTask(data) {
})
}
/**
* 查询口型同步任务
*/
export function getLipSyncTask(taskId) {
return request({
url: `/webApi/api/tik/kling/lip-sync/${taskId}`,
@@ -37,13 +38,36 @@ export function getLipSyncTask(taskId) {
})
}
/**
* 创建可灵任务并识别(推荐方式)
*/
export async function createKlingTaskAndIdentify(file) {
export async function identifyUploadedVideo(videoFile) {
try {
// 1. 提取视频封面
message.loading('正在提取视频封面...', 0)
showLoading('正在识别视频中的人脸...')
const identifyRes = await identifyFace({ video_url: videoFile.fileUrl })
hideLoading()
if (identifyRes.code !== 0) {
throw new Error(identifyRes.msg || '识别失败')
}
return {
success: true,
data: {
fileId: videoFile.id,
videoUrl: videoFile.fileUrl,
sessionId: identifyRes.data.sessionId,
faceId: identifyRes.data.data.face_data[0].face_id || null,
startTime: identifyRes.data.data.face_data[0].start_time || 0,
endTime: identifyRes.data.data.face_data[0].end_time || 0
}
}
} catch (error) {
hideLoading()
throw error
}
}
export async function uploadAndIdentifyVideo(file) {
try {
showLoading('正在提取视频封面...')
let coverBase64 = null
try {
const { extractVideoCover } = await import('@/utils/video-cover')
@@ -52,46 +76,39 @@ export async function createKlingTaskAndIdentify(file) {
quality: 0.8
})
coverBase64 = cover.base64
console.log('视频封面提取成功')
} catch (coverError) {
console.warn('视频封面提取失败:', coverError)
// 封面提取失败不影响主流程
}
message.destroy()
hideLoading()
// 2. 上传视频到OSS包含封面
message.loading('正在上传视频...', 0)
showLoading('正在上传视频...')
const uploadRes = await MaterialService.uploadFile(file, 'video', coverBase64)
message.destroy()
hideLoading()
if (uploadRes.code !== 0) {
throw new Error(uploadRes.msg || '上传失败')
}
const fileId = uploadRes.data
console.log('文件上传成功ID:', fileId, '封面长度:', coverBase64?.length || 0)
// 3. 获取公网播放URL
message.loading('正在生成播放链接...', 0)
showLoading('正在生成播放链接...')
const urlRes = await MaterialService.getVideoPlayUrl(fileId)
message.destroy()
hideLoading()
if (urlRes.code !== 0) {
throw new Error(urlRes.msg || '获取播放链接失败')
}
const videoUrl = urlRes.data
console.log('视频URL:', videoUrl)
// 4. 调用识别API
message.loading('正在识别视频中的人脸...', 0)
const videoUrl = urlRes.data
showLoading('正在识别视频中的人脸...')
const identifyRes = await identifyFace({ video_url: videoUrl })
message.destroy()
hideLoading()
if (identifyRes.code !== 0) {
throw new Error(identifyRes.msg || '识别失败')
}
return {
success: true,
data: {
@@ -99,14 +116,12 @@ export async function createKlingTaskAndIdentify(file) {
videoUrl,
sessionId: identifyRes.data.sessionId,
faceId: identifyRes.data.data.face_data[0].face_id || null,
// 人脸时间信息,用于音频插入时间
startTime: identifyRes.data.data.face_data[0].start_time || 0,
endTime: identifyRes.data.data.face_data[0].end_time || 0
}
}
} catch (error) {
message.destroy()
console.error('可灵任务失败:', error)
hideLoading()
throw error
}
}

View File

@@ -34,8 +34,7 @@ function getVideoDuration(file) {
video.onerror = function() {
URL.revokeObjectURL(video.src);
console.warn('[视频时长] 获取失败使用默认值60秒');
resolve(60); // 返回默认值
resolve(60);
};
video.src = URL.createObjectURL(file);
@@ -71,32 +70,24 @@ export const MaterialService = {
* @returns {Promise}
*/
async uploadFile(file, fileCategory, coverBase64 = null, duration = null) {
// 如果没有提供时长且是视频文件,自动获取
if (duration === null && file.type.startsWith('video/')) {
duration = await getVideoDuration(file);
console.log('[上传] 获取到视频时长:', duration, '秒');
}
const formData = new FormData()
formData.append('file', file)
formData.append('fileCategory', fileCategory)
// 添加时长(如果是视频文件)
if (duration !== null) {
formData.append('duration', duration.toString());
console.log('[上传] 附加视频时长:', duration, '秒');
}
// 如果有封面 base64添加到表单数据
if (coverBase64) {
// base64 格式data:image/jpeg;base64,/9j/4AAQ...
// 后端会解析这个格式
formData.append('coverBase64', coverBase64)
}
// 大文件上传需要更长的超时时间30分钟
return http.post(`${BASE_URL}/upload`, formData, {
timeout: 30 * 60 * 1000 // 30分钟
timeout: 30 * 60 * 1000
})
},

View File

@@ -15,7 +15,6 @@ export const UserPromptApi = {
* @returns {Promise} 响应数据
*/
createUserPrompt: async (data) => {
console.log('[UserPromptApi] 发送请求参数:', JSON.stringify(data, null, 2))
return await http.post(`${SERVER_BASE_AI}/user-prompt/create`, data, {
headers: {
'Content-Type': 'application/json'