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
244 lines
7.8 KiB
TypeScript
244 lines
7.8 KiB
TypeScript
/**
|
||
* 叙事剪辑 — 文案选择弹窗(#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
|