Files
xiaoxia-saas/apps/web/src/pages/generate/components/ScriptSelectModal.tsx
T
xiaoxia f9daa08b2e
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
feat(generate): #1970 智能剪辑流程重构 - 选择模式→素材→标题→确认→封面 (#1973)
2026-09-18 00:23:19 +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