This commit is contained in:
2026-02-24 21:41:05 +08:00
parent c1d1b0ed70
commit 9388f7d75b
7 changed files with 108 additions and 178 deletions

View File

@@ -1,60 +1,37 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { computed } from 'vue'
import { message } from 'ant-design-vue'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import LoginModal from '@/components/LoginModal.vue'
import UserDropdown from '@/components/UserDropdown.vue' import UserDropdown from '@/components/UserDropdown.vue'
import TestService from '@/api/test'
const styles = { const styles = {
background: 'var(--color-slate-900)', background: 'var(--color-slate-900)',
color: 'var(--color-text-inverse)' color: 'var(--color-text-inverse)'
} }
// const route = useRoute()
const userStore = useUserStore()
const showLogin = ref(false)
const userStore = useUserStore()
// 计算是否应该显示用户组件 // 计算是否应该显示用户组件
// 判断用户是否有用户名,有用户名说明用户信息已加载完成
// 使用 userStore.displayName 作为响应式依赖,确保用户信息变化时更新
const shouldShowUser = computed(() => { const shouldShowUser = computed(() => {
// 检查用户是否有用户名nickname 或 wechatNickname
// 有用户名说明用户信息已加载,可以显示用户组件
const hasUserName = !!userStore.displayName && userStore.displayName !== '未命名用户' const hasUserName = !!userStore.displayName && userStore.displayName !== '未命名用户'
return hasUserName return hasUserName
}) })
// function go(path) {
// router.push(path)
// }
</script> </script>
<template> <template>
<header <header class="header-box" :style="styles">
class="header-box"
:style="styles"
>
<div> <div>
<div class="h-[70px] flex items-center"> <div class="h-[70px] flex items-center">
<div class="flex items-center gap-3 flex-1 pl-[30px]"> <div class="flex items-center gap-3 flex-1 pl-[30px]">
<!-- 左侧可放 logo 或其他内容 --> <!-- 左侧可放 logo 或其他内容 -->
</div> </div>
<div class="flex items-center gap-4 pr-[35px]"> <div class="flex items-center gap-4 pr-[35px]">
<template v-if="shouldShowUser"> <template v-if="shouldShowUser">
<UserDropdown /> <UserDropdown />
</template> </template>
<template v-else>
<button class="btn-primary-nav" @click="showLogin = true">免费试用</button>
</template>
</div> </div>
</div> </div>
</div> </div>
</header> </header>
<LoginModal v-model:open="showLogin" />
</template> </template>
<style scoped> <style scoped>
@@ -66,78 +43,4 @@ const shouldShowUser = computed(() => {
z-index: 100; z-index: 100;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
.nav-link {
height: 70px;
display: inline-flex;
align-items: center;
cursor: pointer;
padding: 0 6px;
color: var(--color-text-secondary);
position: relative;
transition: color .2s ease;
}
.nav-link:hover {
color: var(--color-text);
}
.nav-link--active {
color: var(--color-text);
font-weight: 600;
}
.nav-link--active::after {
content: "";
position: absolute;
left: 8px;
right: 8px;
bottom: 10px;
height: 2px;
background: linear-gradient(90deg, var(--color-primary), var(--color-blue));
opacity: 0.55;
border-radius: 2px;
}
.btn-primary-nav {
height: 32px;
padding: 0 12px;
border-radius: var(--radius-button);
background: var(--color-primary);
color: var(--color-text-inverse);
font-size: 14px;
font-weight: 600;
box-shadow: var(--shadow-blue);
transition: transform .2s ease, box-shadow .2s ease, filter .2s ease;
}
.btn-primary-nav:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-blue);
filter: brightness(1.03);
}
.btn-test-nav {
height: 32px;
padding: 0 12px;
border-radius: var(--radius-button);
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-text);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all .2s ease;
}
.btn-test-nav:hover:not(:disabled) {
background: var(--color-bg);
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-test-nav:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style> </style>

View File

@@ -188,7 +188,8 @@ const handlePlayVoiceSample = (voice) => {
}, },
(error) => { (error) => {
console.error('音频播放失败', error) console.error('音频播放失败', error)
} },
{ autoPlay: false } // 禁用自动播放,由 APlayer 控制
) )
} }

View File

@@ -215,8 +215,10 @@ export function useTTS(options = {}) {
* @param {Object} voice - 音色对象 * @param {Object} voice - 音色对象
* @param {Function} onSuccess - 成功回调 * @param {Function} onSuccess - 成功回调
* @param {Function} onError - 错误回调 * @param {Function} onError - 错误回调
* @param {Object} options - 选项
* @param {boolean} options.autoPlay - 是否自动播放(默认 true
*/ */
async function playVoiceSample(voice, onSuccess, onError) { async function playVoiceSample(voice, onSuccess, onError, options = { autoPlay: true }) {
if (!voice) return if (!voice) return
if (previewLoadingVoiceId.value === voice.id || playingPreviewVoiceId.value === voice.id) { if (previewLoadingVoiceId.value === voice.id || playingPreviewVoiceId.value === voice.id) {
return return
@@ -237,7 +239,11 @@ export function useTTS(options = {}) {
const cachedAudio = previewAudioCache.get(cacheKey) const cachedAudio = previewAudioCache.get(cacheKey)
if (cachedAudio) { if (cachedAudio) {
playCachedAudio(cachedAudio, resetPreviewState) if (options.autoPlay !== false) {
playCachedAudio(cachedAudio, resetPreviewState)
} else {
resetPreviewState()
}
onSuccess && onSuccess(cachedAudio) onSuccess && onSuccess(cachedAudio)
return return
} }
@@ -260,19 +266,23 @@ export function useTTS(options = {}) {
if (res.data?.audioUrl) { if (res.data?.audioUrl) {
resetPreviewState() resetPreviewState()
playAudioPreview(res.data.audioUrl, { if (options.autoPlay !== false) {
revokeOnEnd: true, playAudioPreview(res.data.audioUrl, {
onEnded: function() { revokeOnEnd: true,
URL.revokeObjectURL(res.data.audioUrl) onEnded: function() {
} URL.revokeObjectURL(res.data.audioUrl)
}) }
})
}
onSuccess?.(res.data) onSuccess?.(res.data)
} else if (res.data?.audioBase64) { } else if (res.data?.audioBase64) {
const audioData = await decodeAndCacheBase64(res.data.audioBase64, res.data.format, cacheKey) const audioData = await decodeAndCacheBase64(res.data.audioBase64, res.data.format, cacheKey)
resetPreviewState() resetPreviewState()
playCachedAudio(audioData, function() { if (options.autoPlay !== false) {
URL.revokeObjectURL(audioData.objectUrl) playCachedAudio(audioData, function() {
}) URL.revokeObjectURL(audioData.objectUrl)
})
}
onSuccess?.(audioData) onSuccess?.(audioData)
} else { } else {
message.error('试听失败') message.error('试听失败')

View File

@@ -10,7 +10,7 @@ const navConfig = [
order: 1, order: 1,
items: [ items: [
{ name: '对标分析', path: 'content-style/benchmark', icon: 'grid', component: () => import('../views/content-style/Benchmark.vue') }, { name: '对标分析', path: 'content-style/benchmark', icon: 'grid', component: () => import('../views/content-style/Benchmark.vue') },
{ name: '文案创作', path: 'content-style/copywriting', icon: 'text', component: () => import('../views/content-style/Copywriting.vue') }, // { name: '文案创作', path: 'content-style/copywriting', icon: 'text', component: () => import('../views/content-style/Copywriting.vue') },
{ name: '热点趋势', path: 'trends/forecast', icon: 'text', component: () => import('../views/trends/Forecast.vue') }, { name: '热点趋势', path: 'trends/forecast', icon: 'text', component: () => import('../views/trends/Forecast.vue') },
{ name: '智能体', path: 'agents', icon: 'robot', component: () => import('../views/agents/Agents.vue') }, { name: '智能体', path: 'agents', icon: 'robot', component: () => import('../views/agents/Agents.vue') },
] ]
@@ -32,14 +32,14 @@ const navConfig = [
{ name: '任务中心', path: 'system/task-management/:type', icon: 'video', component: () => import('../views/system/task-management/layout/TaskLayout.vue'), requiresAuth: true, params: { type: 'mix-task' } }, { name: '任务中心', path: 'system/task-management/:type', icon: 'video', component: () => import('../views/system/task-management/layout/TaskLayout.vue'), requiresAuth: true, params: { type: 'mix-task' } },
] ]
}, },
{ // {
group: '系统', // group: '系统',
order: 4, // order: 4,
requiresAuth: true, // requiresAuth: true,
items: [ // items: [
{ name: '风格设置', path: 'system/style-settings', icon: 'text', component: () => import('../views/system/style-settings/index.vue') }, // { name: '风格设置', path: 'system/style-settings', icon: 'text', component: () => import('../views/system/style-settings/index.vue') },
] // ]
} // }
] ]
// 导航图标定义 // 导航图标定义
@@ -144,23 +144,34 @@ const router = createRouter({
routes, routes,
}) })
// 白名单路由(无需登录)
const WHITE_LIST = ['/login']
// 路由守卫 // 路由守卫
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
const userStore = useUserStore() const userStore = useUserStore()
const authenToken = tokenManager.getAccessToken() const token = tokenManager.getAccessToken()
if (to.meta.requiresAuth && !authenToken) { // 1. 白名单路由直接放行
if (WHITE_LIST.includes(to.path)) {
// 已登录访问登录页 → 跳转首页
if (token) {
next({ path: '/', replace: true })
return
}
next()
return
}
// 2. 非白名单路由,必须有 token
if (!token) {
next({ path: '/login', query: { redirect: to.fullPath } }) next({ path: '/login', query: { redirect: to.fullPath } })
return return
} }
if (to.path === '/login' && authenToken) { // 3. 有 token 但未加载用户信息 → 加载
next({ path: '/content-style/benchmark', replace: true }) if (!userStore.isLoggedIn) {
return await userStore.fetchUserInfo()
}
if (authenToken && !userStore.isLoggedIn) {
userStore.fetchUserInfo()
} }
next() next()

View File

@@ -113,8 +113,17 @@ export function useSimplePipeline(options: PipelineOptions) {
* 运行完整流程(到 ready 状态) * 运行完整流程(到 ready 状态)
*/ */
async function run(params: PipelineParams): Promise<void> { async function run(params: PipelineParams): Promise<void> {
// 重置状态 // 重置上下文数据,但保持状态在即将开始工作的状态
reset() context.value = { ...INITIAL_CONTEXT }
error.value = null
history.value = ['idle']
// 立即设置忙碌状态,让 UI 显示 loading
// 根据是否有上传文件决定初始状态
const initialState: PipelineState = params.videoFile && !params.selectedVideo
? 'uploading'
: 'recognizing'
setState(initialState)
try { try {
// 保存参数到上下文 // 保存参数到上下文
@@ -126,20 +135,28 @@ export function useSimplePipeline(options: PipelineOptions) {
// 步骤1: 上传视频(如果是上传模式) // 步骤1: 上传视频(如果是上传模式)
if (params.videoFile && !params.selectedVideo) { if (params.videoFile && !params.selectedVideo) {
const fileId = await executeStep('uploading', () => try {
options.uploadVideo(params.videoFile!) const fileId = await options.uploadVideo(params.videoFile!)
) context.value.videoFileId = fileId
context.value.videoFileId = fileId } catch (err) {
setError(err as Error)
throw err
}
} else if (params.selectedVideo) { } else if (params.selectedVideo) {
context.value.videoFileId = params.selectedVideo.fileId context.value.videoFileId = params.selectedVideo.fileId
} }
// 步骤2: 识别人脸 // 步骤2: 识别人脸
const recognizeData = params.selectedVideo setState('recognizing')
? await options.recognizeFromLibrary(params.selectedVideo) let recognizeData
: await options.recognizeUploaded(context.value.videoFileId!) try {
recognizeData = params.selectedVideo
await executeStep('recognizing', async () => recognizeData) ? await options.recognizeFromLibrary(params.selectedVideo)
: await options.recognizeUploaded(context.value.videoFileId!)
} catch (err) {
setError(err as Error)
throw err
}
context.value.sessionId = recognizeData.sessionId context.value.sessionId = recognizeData.sessionId
context.value.faceId = recognizeData.faceId context.value.faceId = recognizeData.faceId
@@ -148,9 +165,14 @@ export function useSimplePipeline(options: PipelineOptions) {
context.value.videoDurationMs = recognizeData.duration || 0 context.value.videoDurationMs = recognizeData.duration || 0
// 步骤3: 生成音频 // 步骤3: 生成音频
const audioData = await executeStep('generating', () => setState('generating')
options.generateAudio(params.text, params.voice, params.speechRate) let audioData
) try {
audioData = await options.generateAudio(params.text, params.voice, params.speechRate)
} catch (err) {
setError(err as Error)
throw err
}
context.value.audioBase64 = audioData.audioBase64 context.value.audioBase64 = audioData.audioBase64
context.value.audioFormat = audioData.format || 'mp3' context.value.audioFormat = audioData.format || 'mp3'
@@ -160,17 +182,17 @@ export function useSimplePipeline(options: PipelineOptions) {
setState('validating') setState('validating')
const videoDurationMs = context.value.videoDurationMs ?? 0 const videoDurationMs = context.value.videoDurationMs ?? 0
if (context.value.audioDurationMs > videoDurationMs) { if (context.value.audioDurationMs > videoDurationMs) {
throw new Error( const errorMsg = `校验失败:音频时长(${(context.value.audioDurationMs / 1000).toFixed(1)}秒) 超过人脸时长(${(videoDurationMs / 1000).toFixed(1)}秒)`
`校验失败:音频时长(${(context.value.audioDurationMs / 1000).toFixed(1)}秒) 超过人脸时长(${(videoDurationMs / 1000).toFixed(1)}秒)` setError(new Error(errorMsg))
) return
} }
context.value.validationPassed = true context.value.validationPassed = true
// 到达 ready 状态 // 到达 ready 状态
setState('ready') setState('ready')
} catch (err) { } catch {
// 错误已在 executeStep 中处理 // 错误已在各步骤中处理
} }
} }

View File

@@ -12,7 +12,7 @@ import type {
IdentifyResult, IdentifyResult,
Video, Video,
} from '../types/identify-face' } from '../types/identify-face'
import { identifyUploadedVideo } from '@/api/kling' import { identifyUploadedVideo, uploadAndIdentifyVideo } from '@/api/kling'
import { useUpload } from '@/composables/useUpload' import { useUpload } from '@/composables/useUpload'
export function useDigitalHumanGeneration() { export function useDigitalHumanGeneration() {
@@ -90,6 +90,7 @@ export function useDigitalHumanGeneration() {
} }
if (hasSelectedVideo) { if (hasSelectedVideo) {
// 从素材库选择:调用识别接口
const res = await identifyUploadedVideo(hasSelectedVideo) as { const res = await identifyUploadedVideo(hasSelectedVideo) as {
success: boolean; success: boolean;
data: { sessionId: string; faceId: string | null; startTime: number; endTime: number } data: { sessionId: string; faceId: string | null; startTime: number; endTime: number }
@@ -100,35 +101,18 @@ export function useDigitalHumanGeneration() {
identifyResult.value.faceStartTime = res.data.startTime || 0 identifyResult.value.faceStartTime = res.data.startTime || 0
identifyResult.value.faceEndTime = res.data.endTime || 0 identifyResult.value.faceEndTime = res.data.endTime || 0
} else { } else {
// 上传新视频:使用 uploadAndIdentifyVideo 完成上传+识别
const file = hasUploadFile! const file = hasUploadFile!
let coverBase64 = null const res = await uploadAndIdentifyVideo(file) as {
try { success: boolean;
const { extractVideoCover } = await import('@/utils/video-cover') data: { fileId: string; sessionId: string; faceId: string | null; startTime: number; endTime: number }
const cover = await extractVideoCover(file, { maxWidth: 800, quality: 0.8 })
coverBase64 = cover.base64
} catch {
// 封面提取失败不影响主流程
} }
const fileId = await upload(file, { identifyResult.value.videoFileId = res.data.fileId
fileCategory: 'video', identifyResult.value.sessionId = res.data.sessionId
groupId: null, identifyResult.value.faceId = res.data.faceId || ''
coverBase64, identifyResult.value.faceStartTime = res.data.startTime || 0
onStart: function() {}, identifyResult.value.faceEndTime = res.data.endTime || 0
onProgress: function() {},
onSuccess: function() {},
onError: function(err: Error) {
message.error(err.message || '上传失败')
}
})
identifyResult.value.videoFileId = fileId
// 上传后需要再调用识别接口获取人脸信息
// 暂时清空,等待后续识别
identifyResult.value.sessionId = ''
identifyResult.value.faceId = ''
identifyResult.value.faceStartTime = 0
identifyResult.value.faceEndTime = 0
} }
return { ...identifyResult.value } return { ...identifyResult.value }

View File

@@ -71,7 +71,6 @@ export function useVoiceGeneration(): UseVoiceGeneration {
audioState.value.generated = audioData audioState.value.generated = audioData
audioState.value.durationMs = await parseAudioDuration(audioData.audioBase64) audioState.value.durationMs = await parseAudioDuration(audioData.audioBase64)
message.success('配音生成成功!')
} else { } else {
throw new Error(res.msg || '配音生成失败') throw new Error(res.msg || '配音生成失败')
} }