feat(generate): #1970 智能剪辑流程重构 - 选择模式→素材→标题→确认→封面 (#1973)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 31s
CI/CD Pipeline / Build Staging API Image (push) Successful in 40s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m46s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m1s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 36s
CI/CD Pipeline / Integration Tests (push) Successful in 4m53s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m36s
CI/CD Pipeline / Validate - Style (push) Failing after 5m37s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 6m10s
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled

This commit was merged in pull request #1973.
This commit is contained in:
2026-09-18 00:23:19 +08:00
parent 66409fde6f
commit f9daa08b2e
14 changed files with 1617 additions and 86 deletions
@@ -0,0 +1,243 @@
/**
* 叙事剪辑 — 文案选择弹窗(#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