feat(phase9): AI能力增强合集 - 智能标题/智能配音/生成体验优化 #623
@@ -4,14 +4,13 @@
|
||||
* 左右布局:左侧 generate-form + 右侧 generate-preview
|
||||
* 保留所有现有业务逻辑(API 调用、URL 参数、CloneModal、TTS 等)
|
||||
*/
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react"
|
||||
import React, { useState, useRef, useCallback, useEffect, useMemo } from "react"
|
||||
import { useQuery, useMutation } from "@tanstack/react-query"
|
||||
import { Typography, message, Select, Modal } from "antd"
|
||||
import {
|
||||
AudioOutlined,
|
||||
ThunderboltOutlined,
|
||||
CheckCircleFilled,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
LoadingOutlined,
|
||||
PlayCircleOutlined,
|
||||
@@ -253,6 +252,30 @@ const SMART_MATCH_REASONS = [
|
||||
"人物表情生动",
|
||||
]
|
||||
|
||||
const AI_TITLE_TEMPLATES: Record<string, string[]> = {
|
||||
catchy: [
|
||||
"震惊!{topic}居然还能这样操作",
|
||||
"99%的人都不知道的{topic}秘诀",
|
||||
"{topic}的终极指南,看完直接封神",
|
||||
"别再走弯路了!{topic}看这一篇就够",
|
||||
"一个视频讲透{topic},建议收藏",
|
||||
],
|
||||
emotional: [
|
||||
"致每一个在{topic}路上坚持的人",
|
||||
"关于{topic},我想说句真心话",
|
||||
"{topic}背后的故事,看完沉默了",
|
||||
"为什么我劝你一定要了解{topic}",
|
||||
"这才是{topic}最动人的样子",
|
||||
],
|
||||
informative: [
|
||||
"{topic}完整科普:从入门到精通",
|
||||
"深度解析{topic}的核心原理",
|
||||
"{topic}行业趋势报告|2026最新版",
|
||||
"三分钟带你全面了解{topic}",
|
||||
"{topic}常见问题与解决方案汇总",
|
||||
],
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
组件
|
||||
================================================================ */
|
||||
@@ -294,6 +317,19 @@ const GeneratePage: React.FC = () => {
|
||||
/* 智能素材匹配:是否已执行过匹配 */
|
||||
const [hasMatched, setHasMatched] = useState(false)
|
||||
|
||||
/* ── 智能标题生成 ── */
|
||||
const [aiTitleInput, setAiTitleInput] = useState("")
|
||||
const [aiTitleGenerating, setAiTitleGenerating] = useState(false)
|
||||
const [aiTitleResults, setAiTitleResults] = useState<
|
||||
Array<{ title: string; highlight: string; style: "catchy" | "emotional" | "informative" }>
|
||||
>([])
|
||||
const [hasGeneratedTitles, setHasGeneratedTitles] = useState(false)
|
||||
|
||||
/* ── 智能配音推荐 ── */
|
||||
const [voiceRecommendLoading, setVoiceRecommendLoading] = useState(false)
|
||||
const [voiceRecommendations, setVoiceRecommendations] = useState<string[]>([])
|
||||
const [hasVoiceRecommend, setHasVoiceRecommend] = useState(false)
|
||||
|
||||
/* ── 标题设置 ── */
|
||||
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
|
||||
|
||||
@@ -453,7 +489,10 @@ const GeneratePage: React.FC = () => {
|
||||
queryKey: ["preset-voices"],
|
||||
queryFn: fetchPresetVoices,
|
||||
})
|
||||
const presetVoices: PresetVoiceItem[] = presetVoicesData?.items ?? []
|
||||
const presetVoices: PresetVoiceItem[] = useMemo(
|
||||
() => presetVoicesData?.items ?? [],
|
||||
[presetVoicesData],
|
||||
)
|
||||
|
||||
/* ── TTS 自定义合成状态 ── */
|
||||
const [customAudioUrl, setCustomAudioUrl] = useState<string | null>(null)
|
||||
@@ -584,6 +623,152 @@ const GeneratePage: React.FC = () => {
|
||||
setSmartSelectedIds([])
|
||||
}, [])
|
||||
|
||||
/* ── 智能标题生成 ── */
|
||||
|
||||
const extractTopic = (text: string): string => {
|
||||
const keywords = text
|
||||
.replace(/[,。!?、,.!?]/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
if (keywords.length === 0) return "这个话题"
|
||||
// 取前3个关键词组合
|
||||
return keywords.slice(0, 3).join("")
|
||||
}
|
||||
|
||||
const handleGenerateAiTitles = useCallback(async () => {
|
||||
if (!aiTitleInput.trim()) {
|
||||
message.warning("请先输入视频描述或关键词")
|
||||
return
|
||||
}
|
||||
setAiTitleGenerating(true)
|
||||
setHasGeneratedTitles(true)
|
||||
|
||||
// 模拟 AI 生成延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200))
|
||||
|
||||
const topic = extractTopic(aiTitleInput)
|
||||
const results: Array<{
|
||||
title: string
|
||||
highlight: string
|
||||
style: "catchy" | "emotional" | "informative"
|
||||
}> = []
|
||||
|
||||
const styles: Array<"catchy" | "emotional" | "informative"> = [
|
||||
"catchy",
|
||||
"emotional",
|
||||
"informative",
|
||||
]
|
||||
styles.forEach((style) => {
|
||||
const templates = AI_TITLE_TEMPLATES[style]
|
||||
// 每种风格随机选2个
|
||||
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
|
||||
shuffled.forEach((tpl) => {
|
||||
const title = tpl.replace(/\{topic\}/g, topic)
|
||||
const highlights = {
|
||||
catchy: "吸睛标题",
|
||||
emotional: "情感共鸣",
|
||||
informative: "知识干货",
|
||||
}
|
||||
results.push({
|
||||
title,
|
||||
highlight: highlights[style],
|
||||
style,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// 打乱顺序
|
||||
results.sort(() => Math.random() - 0.5)
|
||||
setAiTitleResults(results)
|
||||
setAiTitleGenerating(false)
|
||||
}, [aiTitleInput])
|
||||
|
||||
const handleSelectAiTitle = useCallback((title: string) => {
|
||||
setTitleSettings((prev) => ({ ...prev, title, aiAutoSelect: false }))
|
||||
message.success("已选用此标题")
|
||||
}, [])
|
||||
|
||||
const handleRefreshAiTitles = useCallback(async () => {
|
||||
if (!aiTitleInput.trim()) return
|
||||
setAiTitleGenerating(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
// 重新生成一批
|
||||
const topic = extractTopic(aiTitleInput)
|
||||
const results: typeof aiTitleResults = []
|
||||
const styles: Array<"catchy" | "emotional" | "informative"> = [
|
||||
"catchy",
|
||||
"emotional",
|
||||
"informative",
|
||||
]
|
||||
const highlights = { catchy: "吸睛标题", emotional: "情感共鸣", informative: "知识干货" }
|
||||
styles.forEach((style) => {
|
||||
const templates = AI_TITLE_TEMPLATES[style]
|
||||
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
|
||||
shuffled.forEach((tpl) => {
|
||||
results.push({
|
||||
title: tpl.replace(/\{topic\}/g, topic),
|
||||
highlight: highlights[style],
|
||||
style,
|
||||
})
|
||||
})
|
||||
})
|
||||
results.sort(() => Math.random() - 0.5)
|
||||
setAiTitleResults(results)
|
||||
setAiTitleGenerating(false)
|
||||
}, [aiTitleInput])
|
||||
|
||||
/* ── 智能配音推荐 ── */
|
||||
const handleVoiceRecommend = useCallback(async () => {
|
||||
if (presetVoices.length === 0) return
|
||||
setVoiceRecommendLoading(true)
|
||||
setHasVoiceRecommend(true)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
// 根据标题内容风格模拟推荐:情感类→温柔女声,知识类→沉稳男声,活力类→阳光少年
|
||||
const title = titleSettings.title.toLowerCase()
|
||||
let recommended: string[] = []
|
||||
|
||||
const femaleVoices = presetVoices.filter((v) => v.gender === "female").map((v) => v.voice_id)
|
||||
const maleVoices = presetVoices.filter((v) => v.gender === "male").map((v) => v.voice_id)
|
||||
const childVoices = presetVoices.filter((v) => v.gender === "child").map((v) => v.voice_id)
|
||||
|
||||
if (/情感|感人|温暖|治愈|故事|回忆/.test(title)) {
|
||||
recommended = femaleVoices.slice(0, 3)
|
||||
} else if (/教程|知识|科普|干货|讲解|分析/.test(title)) {
|
||||
recommended = maleVoices.slice(0, 2).concat(femaleVoices.slice(0, 1))
|
||||
} else if (/活力|热血|运动|搞笑|有趣/.test(title)) {
|
||||
recommended = childVoices.slice(0, 1).concat(maleVoices.slice(0, 1), femaleVoices.slice(0, 1))
|
||||
} else {
|
||||
// 默认推荐前3个
|
||||
recommended = presetVoices.slice(0, 3).map((v) => v.voice_id)
|
||||
}
|
||||
|
||||
// 不足3个时补足
|
||||
if (recommended.length < 3) {
|
||||
const others = presetVoices
|
||||
.filter((v) => !recommended.includes(v.voice_id))
|
||||
.map((v) => v.voice_id)
|
||||
recommended = recommended.concat(others.slice(0, 3 - recommended.length))
|
||||
}
|
||||
|
||||
setVoiceRecommendations(recommended)
|
||||
setVoiceRecommendLoading(false)
|
||||
}, [presetVoices, titleSettings.title])
|
||||
|
||||
const handleSelectRecommendedVoice = useCallback((voiceId: string) => {
|
||||
setVoiceMode("preset")
|
||||
setSelectedVoice(voiceId)
|
||||
}, [])
|
||||
|
||||
/* ── 生成阶段映射 ── */
|
||||
const getGenerationPhase = (p: number) => {
|
||||
if (p < 20) return { label: "分析素材与配置", icon: "🔍" }
|
||||
if (p < 50) return { label: "智能剪辑合成", icon: "🎬" }
|
||||
if (p < 80) return { label: "渲染视频中", icon: "⚡" }
|
||||
return { label: "即将完成", icon: "✨" }
|
||||
}
|
||||
|
||||
const handleCloneSuccess = useCallback(
|
||||
(voice: VoiceClone) => {
|
||||
addClone(voice)
|
||||
@@ -1021,6 +1206,12 @@ const GeneratePage: React.FC = () => {
|
||||
smartSelectedIds,
|
||||
])
|
||||
|
||||
/* 重新生成(失败后重试) */
|
||||
const handleRetryGenerate = useCallback(() => {
|
||||
setGenerateError(null)
|
||||
handleGenerate()
|
||||
}, [handleGenerate])
|
||||
|
||||
/* ── 下载视频 ── */
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!generatedVideos.length) return
|
||||
@@ -1502,6 +1693,90 @@ const GeneratePage: React.FC = () => {
|
||||
<div className="xx-form-section">
|
||||
<h3>📝 选择标题</h3>
|
||||
|
||||
{/* AI 智能生成标题 */}
|
||||
<div className="xx-ai-title-section">
|
||||
<div className="xx-ai-title-header">
|
||||
<span className="xx-ai-title-label">✨ AI 智能生成标题</span>
|
||||
</div>
|
||||
<div className="xx-ai-title-input-row">
|
||||
<input
|
||||
className="xx-ai-title-input"
|
||||
placeholder="输入视频内容描述或关键词,如:职场成长、副业赚钱…"
|
||||
value={aiTitleInput}
|
||||
onChange={(e) => setAiTitleInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleGenerateAiTitles()
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary"
|
||||
onClick={handleGenerateAiTitles}
|
||||
disabled={aiTitleGenerating || !aiTitleInput.trim()}
|
||||
>
|
||||
{aiTitleGenerating ? (
|
||||
<>
|
||||
<LoadingOutlined style={{ marginRight: 6 }} />
|
||||
生成中
|
||||
</>
|
||||
) : (
|
||||
"生成标题"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 生成结果 */}
|
||||
{hasGeneratedTitles && !aiTitleGenerating && aiTitleResults.length > 0 && (
|
||||
<div className="xx-ai-title-results">
|
||||
<div className="xx-ai-title-results-header">
|
||||
<span className="xx-ai-title-results-count">
|
||||
为你生成 {aiTitleResults.length} 个标题
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-link-btn"
|
||||
onClick={handleRefreshAiTitles}
|
||||
disabled={aiTitleGenerating}
|
||||
>
|
||||
🔄 换一批
|
||||
</button>
|
||||
</div>
|
||||
<div className="xx-ai-title-list">
|
||||
{aiTitleResults.map((item, idx) => {
|
||||
const isSelected = titleSettings.title === item.title
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`xx-ai-title-card ${isSelected ? "selected" : ""} ${item.style}`}
|
||||
onClick={() => handleSelectAiTitle(item.title)}
|
||||
>
|
||||
<div className="xx-ai-title-card-text">{item.title}</div>
|
||||
<div className="xx-ai-title-card-tag">{item.highlight}</div>
|
||||
{isSelected && (
|
||||
<div className="xx-ai-title-card-check">
|
||||
<CheckCircleFilled style={{ color: "#fff", fontSize: 14 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成中 */}
|
||||
{aiTitleGenerating && (
|
||||
<div className="xx-ai-title-loading">
|
||||
<LoadingOutlined style={{ color: "var(--primary-color)", marginRight: 8 }} />
|
||||
AI 正在为你创作标题…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="xx-divider">
|
||||
<span>或手动选择</span>
|
||||
</div>
|
||||
|
||||
{/* AI 自动选择开关 */}
|
||||
<div className="xx-title-ai-toggle">
|
||||
<span className="xx-toggle-label">AI 自动选择标题</span>
|
||||
@@ -1693,6 +1968,77 @@ const GeneratePage: React.FC = () => {
|
||||
<div className="xx-form-section">
|
||||
<h3>🎙️ 选择配音</h3>
|
||||
|
||||
{/* AI 智能推荐配音 */}
|
||||
<div className="xx-voice-recommend-section">
|
||||
<div className="xx-voice-recommend-header">
|
||||
<span className="xx-voice-recommend-label">✨ AI 智能推荐</span>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={handleVoiceRecommend}
|
||||
disabled={voiceRecommendLoading || presetVoicesLoading}
|
||||
>
|
||||
{voiceRecommendLoading ? (
|
||||
<>
|
||||
<LoadingOutlined style={{ marginRight: 6 }} />
|
||||
推荐中
|
||||
</>
|
||||
) : hasVoiceRecommend ? (
|
||||
"换一批"
|
||||
) : (
|
||||
"智能推荐"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{voiceRecommendLoading && (
|
||||
<div className="xx-voice-recommend-loading">
|
||||
<LoadingOutlined style={{ color: "var(--primary-color)", marginRight: 8 }} />
|
||||
根据视频内容为你匹配最合适的音色…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!voiceRecommendLoading && hasVoiceRecommend && voiceRecommendations.length > 0 && (
|
||||
<div className="xx-voice-recommend-list">
|
||||
{voiceRecommendations.map((voiceId) => {
|
||||
const v = presetVoices.find((pv) => pv.voice_id === voiceId)
|
||||
if (!v) return null
|
||||
const isSelected = voiceMode === "preset" && selectedVoice === v.voice_id
|
||||
return (
|
||||
<div
|
||||
key={v.voice_id}
|
||||
className={`xx-voice-recommend-card ${isSelected ? "selected" : ""}`}
|
||||
onClick={() => handleSelectRecommendedVoice(v.voice_id)}
|
||||
>
|
||||
<div className="xx-voice-recommend-avatar">
|
||||
{VOICE_GENDER_ICON[v.gender] ?? "✨"}
|
||||
</div>
|
||||
<div className="xx-voice-recommend-info">
|
||||
<div className="xx-voice-recommend-name">{v.name}</div>
|
||||
<div className="xx-voice-recommend-desc">{v.description}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="xx-voice-recommend-check">
|
||||
<CheckCircleFilled style={{ color: "#fff", fontSize: 16 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!voiceRecommendLoading && !hasVoiceRecommend && (
|
||||
<div className="xx-voice-recommend-empty">
|
||||
<span>点击「智能推荐」,AI 根据视频内容匹配音色</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="xx-divider">
|
||||
<span>全部音色</span>
|
||||
</div>
|
||||
|
||||
{/* 配音方式选择卡片 — 从 API 预设音色动态生成 */}
|
||||
<div className="xx-voice-choice-list" style={{ marginBottom: 16 }}>
|
||||
{presetVoices.slice(0, 3).map((v) => (
|
||||
@@ -2314,71 +2660,82 @@ const GeneratePage: React.FC = () => {
|
||||
{(generating || generated || generateError) && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
{generating && (
|
||||
<>
|
||||
<div className="xx-progress-bar">
|
||||
<div className="xx-gen-progress-card">
|
||||
<div className="xx-gen-progress-header">
|
||||
<div className="xx-gen-progress-icon">
|
||||
<LoadingOutlined />
|
||||
</div>
|
||||
<div className="xx-gen-progress-info">
|
||||
<div className="xx-gen-progress-phase">
|
||||
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
|
||||
</div>
|
||||
<div className="xx-gen-progress-sub">预计还需 1-2 分钟,请稍候…</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-bar">
|
||||
<div
|
||||
className="xx-progress-bar-fill"
|
||||
className="xx-gen-progress-bar-fill"
|
||||
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<Text style={{ color: "var(--text-secondary)", fontSize: 13 }}>
|
||||
<LoadingOutlined style={{ marginRight: 6 }} />
|
||||
正在生成视频,请稍候… {Math.round(progress)}%
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{generated && !generating && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
borderRadius: 8,
|
||||
background: "rgba(82, 196, 26, 0.08)",
|
||||
border: "1px solid rgba(82, 196, 26, 0.3)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<CheckCircleOutlined style={{ color: "#52c41a", fontSize: 18 }} />
|
||||
<div>
|
||||
<Text strong style={{ color: "#52c41a", display: "block", fontSize: 14 }}>
|
||||
视频生成完成!
|
||||
</Text>
|
||||
<Text style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
可在右侧预览或前往成片库查看
|
||||
</Text>
|
||||
<div className="xx-gen-progress-tip">
|
||||
💡 生成过程中可以切换到其他页面操作,完成后会自动通知
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generateError && !generating && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
borderRadius: 8,
|
||||
background: "rgba(255, 77, 79, 0.08)",
|
||||
border: "1px solid rgba(255, 77, 79, 0.3)",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<CloseCircleOutlined
|
||||
style={{
|
||||
color: "#ff4d4f",
|
||||
fontSize: 18,
|
||||
marginTop: 2,
|
||||
flexShrink: 0,
|
||||
{generated && !generating && (
|
||||
<div className="xx-gen-success-card">
|
||||
<div className="xx-gen-success-icon">
|
||||
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
|
||||
</div>
|
||||
<div className="xx-gen-success-info">
|
||||
<div className="xx-gen-success-title">视频生成完成!</div>
|
||||
<div className="xx-gen-success-sub">
|
||||
共生成 {generatedVideos.length} 条视频,可在右侧预览或前往成片库查看
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={() => {
|
||||
// 滚动到右侧预览区
|
||||
const el = document.querySelector(".xx-preview-section")
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Text strong style={{ color: "#ff4d4f", display: "block", fontSize: 14 }}>
|
||||
生成失败
|
||||
</Text>
|
||||
<Text style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
>
|
||||
查看结果
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{generateError && !generating && (
|
||||
<div className="xx-gen-error-card">
|
||||
<div className="xx-gen-error-icon">
|
||||
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
|
||||
</div>
|
||||
<div className="xx-gen-error-info">
|
||||
<div className="xx-gen-error-title">生成失败</div>
|
||||
<div className="xx-gen-error-msg">
|
||||
{typeof generateError === "string"
|
||||
? generateError
|
||||
: JSON.stringify(generateError)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={handleRetryGenerate}
|
||||
>
|
||||
🔄 重试
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={() => setGenerateError(null)}
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -273,6 +273,120 @@
|
||||
color: var(--info-color);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
AI 智能配音推荐
|
||||
============================================================ */
|
||||
.xx-voice-recommend-section {
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%);
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-voice-recommend-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.xx-voice-recommend-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.xx-voice-recommend-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-voice-recommend-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #fff;
|
||||
border: 2px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xx-voice-recommend-card:hover {
|
||||
border-color: var(--primary-color, #4f46e5);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.xx-voice-recommend-card.selected {
|
||||
border-color: var(--primary-color, #4f46e5);
|
||||
background: var(--primary-soft, #eef2ff);
|
||||
}
|
||||
|
||||
.xx-voice-recommend-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #a5b4fc, #c4b5fd);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-voice-recommend-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.xx-voice-recommend-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #1e293b);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.xx-voice-recommend-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.xx-voice-recommend-check {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--primary-color, #4f46e5);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-voice-recommend-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.xx-voice-recommend-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
配音卡片(步骤4 choice-list 变体)
|
||||
============================================================ */
|
||||
@@ -548,6 +662,147 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
生成进度 / 结果卡片
|
||||
============================================================ */
|
||||
.xx-gen-progress-card {
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #eef2ff 100%);
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.xx-gen-progress-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.xx-gen-progress-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--primary-color, #4f46e5);
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-gen-progress-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.xx-gen-progress-phase {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.xx-gen-progress-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.xx-gen-progress-percent {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color, #4f46e5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-gen-progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(79, 70, 229, 0.15);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.xx-gen-progress-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4f46e5, #7c3aed);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.xx-gen-progress-tip {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-gen-success-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.xx-gen-success-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-gen-success-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.xx-gen-success-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.xx-gen-success-sub {
|
||||
font-size: 12px;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.xx-gen-error-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.xx-gen-error-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.xx-gen-error-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.xx-gen-error-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #991b1b;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.xx-gen-error-msg {
|
||||
font-size: 12px;
|
||||
color: #b91c1c;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
确认生成(步骤5)摘要
|
||||
============================================================ */
|
||||
@@ -1382,6 +1637,173 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
AI 智能生成标题
|
||||
============================================================ */
|
||||
.xx-ai-title-section {
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%);
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-ai-title-header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.xx-ai-title-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.xx-ai-title-input-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.xx-ai-title-input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.xx-ai-title-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color, #4f46e5);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
.xx-ai-title-input::placeholder {
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
|
||||
.xx-ai-title-results {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.xx-ai-title-results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.xx-ai-title-results-count {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.xx-ai-title-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.xx-ai-title-card {
|
||||
position: relative;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
border: 2px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.xx-ai-title-card:hover {
|
||||
border-color: var(--primary-color, #4f46e5);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.xx-ai-title-card.selected {
|
||||
border-color: var(--primary-color, #4f46e5);
|
||||
background: var(--primary-soft, #eef2ff);
|
||||
}
|
||||
|
||||
.xx-ai-title-card-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #1e293b);
|
||||
line-height: 1.5;
|
||||
padding-right: 50px;
|
||||
}
|
||||
|
||||
.xx-ai-title-card-tag {
|
||||
display: inline-block;
|
||||
margin-top: 6px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
border-radius: 10px;
|
||||
background: #f1f5f9;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.xx-ai-title-card.catchy .xx-ai-title-card-tag {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.xx-ai-title-card.emotional .xx-ai-title-card-tag {
|
||||
background: #fce7f3;
|
||||
color: #be185d;
|
||||
}
|
||||
|
||||
.xx-ai-title-card.informative .xx-ai-title-card-tag {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.xx-ai-title-card-check {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 12px;
|
||||
transform: translateY(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--primary-color, #4f46e5);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.xx-ai-title-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.xx-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 16px 0;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.xx-divider::before,
|
||||
.xx-divider::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
.xx-divider span {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
标题设置(选择标题步骤)
|
||||
============================================================ */
|
||||
|
||||
Reference in New Issue
Block a user