Files
xiaoxia-saas/apps/web/src/pages/generate/components/ScriptSelectModal.tsx
T
saas-agent a4c6f492a7
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m35s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m31s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m48s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 28s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m4s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m13s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 5m40s
AI Code Review / AI Code Review (pull_request) Successful in 7m0s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m42s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 17m36s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 18s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 50s
feat(generate): #1970 智能剪辑流程重构 - 选择模式→素材→标题→确认→封面
## 主流程改造
- STEPS 改为 5 步:选择模式→选择素材→选择标题→确认生成→选择封面
- 原"选择配音"步骤从主流程移除,改为 Step1 下一步分支弹窗
- Step1 新组件 Step1EditMode:模式卡片(🎲随机混剪/📖叙事剪辑,紫底紫框) + 生成设置(数量-/+1-10、比例9:16/16:9、智能降重开关默认开)

## 新增 3 个弹窗
- VoiceSelectModal(随机混剪):复用配音库音频卡片+▶试听,无 TTS/克隆入口
- ScriptSelectModal(叙事剪辑):搜索框+300ms防抖+命中黄高亮+标签筛选(全部/带货/工厂/测评/教程/口播/种草)+max-height 420px 卡片列表
- TtsVoiceModal(叙事剪辑合成配音):Tabs(✨系统音色/🎙️我的克隆音色) + 2列音色卡片 + 合成按钮 + loading(spinner+"正在合成配音..."+"通常10-30秒")

## Step2 素材页
- 叙事剪辑模式显示"🤖 AI智能匹配"紫色提示卡

## Step4 确认页
- 摘要卡片按 editMode 展示剪辑模式+配音来源/文案标题+TTS音色+比例+降重

## payload 新增字段
- assembly_mode('random'|'narrative')、script_id、tts_voice_id、tts_voice_source('preset'|'clone')、dedup_enabled(默认true)、video_ratio('9:16')

## 不改动
- Step3/Step5 内容、文案库/配音库页面、AI数字人
2026-09-18 00:02:18 +08:00

244 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 叙事剪辑 — 文案选择弹窗(#1970)
* - 搜索框:防抖 300ms,命中文字黄色高亮
* - 标签筛选行:全部/带货/工厂/测评/教程/口播/种草
* - 数量统计 + 卡片列表(可滚动,max-height 420px)
* - 调用 GET /api/v1/scripts?keyword=&tag=&page_size=200
*/
import React, { useState, useEffect, useMemo, useRef, useCallback } from "react"
import { Modal, Input, Tag, Spin } from "antd"
import { SearchOutlined, CheckCircleFilled } from "@ant-design/icons"
import { useQuery } from "@tanstack/react-query"
import { getScripts } from "@/api/scripts"
import type { ScriptItem } from "@/api/scripts"
interface ScriptSelectModalProps {
open: boolean
selectedScriptId: string | null
onCancel: () => void
onConfirm: (script: ScriptItem) => void
}
const SCRIPT_TABS = [
{ key: "all", label: "全部" },
{ key: "带货", label: "带货" },
{ key: "工厂", label: "工厂" },
{ key: "测评", label: "测评" },
{ key: "教程", label: "教程" },
{ key: "口播", label: "口播" },
{ key: "种草", label: "种草" },
]
/** 在文本中用 <mark> 高亮关键词(黄色背景) */
function highlight(text: string, keyword: string): React.ReactNode {
if (!keyword) return text
const idx = text.toLowerCase().indexOf(keyword.toLowerCase())
if (idx < 0) return text
return (
<>
{text.slice(0, idx)}
<mark style={{ background: "#fef08a", color: "#713f12", padding: "0 2px", borderRadius: 2 }}>
{text.slice(idx, idx + keyword.length)}
</mark>
{text.slice(idx + keyword.length)}
</>
)
}
const ScriptSelectModal: React.FC<ScriptSelectModalProps> = ({
open,
selectedScriptId,
onCancel,
onConfirm,
}) => {
const [innerSelected, setInnerSelected] = useState<string | null>(selectedScriptId)
const [activeTag, setActiveTag] = useState<string>("all")
const [searchInput, setSearchInput] = useState("")
const [debouncedKw, setDebouncedKw] = useState("")
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (open) {
setInnerSelected(selectedScriptId)
setActiveTag("all")
setSearchInput("")
setDebouncedKw("")
}
}, [open, selectedScriptId])
// 300ms 防抖
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => setDebouncedKw(searchInput.trim()), 300)
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [searchInput])
const { data, isLoading } = useQuery({
queryKey: ["scripts", "select-modal", debouncedKw, activeTag],
queryFn: () =>
getScripts({
page: 1,
page_size: 200,
keyword: debouncedKw || undefined,
tag: activeTag === "all" ? undefined : activeTag,
}),
enabled: open,
})
const scripts: ScriptItem[] = useMemo(() => data?.items ?? [], [data])
const selected = useMemo(
() => scripts.find((s) => s.id === innerSelected) ?? null,
[scripts, innerSelected],
)
const handleConfirm = useCallback(() => {
if (selected) onConfirm(selected)
}, [selected, onConfirm])
return (
<Modal
title="📝 选择文案"
open={open}
onCancel={onCancel}
onOk={handleConfirm}
okText="确认选择"
cancelText="取消"
okButtonProps={{ disabled: !selected, style: { background: "#7c3aed" } }}
width={680}
destroyOnClose
>
{/* 搜索 */}
<Input
allowClear
prefix={<SearchOutlined style={{ color: "#9ca3af" }} />}
placeholder="搜索标题、内容或标签"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
style={{ marginBottom: 12 }}
/>
{/* 标签筛选 */}
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 12 }}>
{SCRIPT_TABS.map((t) => {
const active = activeTag === t.key
return (
<Tag
key={t.key}
onClick={() => setActiveTag(t.key)}
style={{
cursor: "pointer",
padding: "4px 14px",
borderRadius: 16,
border: active ? "1px solid #7c3aed" : "1px solid #e5e7eb",
background: active ? "#ede9fe" : "#fff",
color: active ? "#7c3aed" : "#4b5563",
margin: 0,
fontSize: 13,
}}
>
{t.label}
</Tag>
)
})}
</div>
{/* 数量统计 */}
<div style={{ fontSize: 12, color: "#6b7280", marginBottom: 8 }}>
共 {data?.total ?? scripts.length} 条文案
</div>
{/* 卡片列表 */}
<div style={{ maxHeight: 420, overflowY: "auto", paddingRight: 4 }}>
{isLoading ? (
<div style={{ textAlign: "center", padding: "40px 0" }}>
<Spin />
</div>
) : scripts.length === 0 ? (
<div style={{ textAlign: "center", padding: "40px 0", color: "#9ca3af" }}>
暂无匹配文案
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{scripts.map((s) => {
const isSel = innerSelected === s.id
const preview = (s.content || "").replace(/\s+/g, " ").slice(0, 80)
return (
<div
key={s.id}
onClick={() => setInnerSelected(s.id)}
style={{
padding: 14,
borderRadius: 8,
border: isSel ? "2px solid #7c3aed" : "1px solid #e5e7eb",
background: isSel ? "#faf5ff" : "#fff",
cursor: "pointer",
transition: "all 0.2s",
position: "relative",
}}
>
{isSel && (
<CheckCircleFilled
style={{
position: "absolute",
top: 12,
right: 12,
color: "#7c3aed",
fontSize: 18,
}}
/>
)}
<div
style={{
fontSize: 14,
fontWeight: 600,
color: isSel ? "#6d28d9" : "#111",
marginBottom: 4,
paddingRight: 24,
}}
>
{highlight(s.title || "未命名", debouncedKw)}
</div>
<div
style={{
fontSize: 12,
color: "#6b7280",
lineHeight: 1.6,
marginBottom: 8,
}}
>
{highlight(preview + ((s.content || "").length > 80 ? "..." : ""), debouncedKw)}
</div>
{s.tags && s.tags.length > 0 && (
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{s.tags.slice(0, 5).map((tg) => (
<Tag
key={tg}
style={{
margin: 0,
fontSize: 11,
padding: "1px 8px",
borderRadius: 10,
background: "#f3f4f6",
border: "none",
color: "#6b7280",
}}
>
{tg}
</Tag>
))}
</div>
)}
</div>
)
})}
</div>
)}
</div>
</Modal>
)
}
export default ScriptSelectModal