fix(scripts): #1893 UX - prettier
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 6s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 57s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m33s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m46s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 11m25s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 12m22s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 31m29s
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m34s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 18h11m46s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 18h12m5s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 18h12m8s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 18h12m8s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 18h11m26s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 18h11m26s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 18h11m6s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 18h11m23s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 18h11m24s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 18h11m26s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 18h11m26s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 18h11m26s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 17h1m53s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 17h15m26s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 17h15m27s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 17h15m27s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 17h1m52s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 18h11m4s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 18h11m3s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 20h26m22s

This commit is contained in:
2026-09-15 18:56:22 +08:00
parent 8ea71581fe
commit a73c1a4c83
+176 -176
View File
@@ -15,7 +15,7 @@
*
* 风格对齐标题库(.xx-scripts-* 命名,沿用 CSS 变量)
*/
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useState } from "react"
import {
Button,
Card,
@@ -34,7 +34,7 @@ import {
Tooltip,
Typography,
message,
} from "antd";
} from "antd"
import {
PlusOutlined,
EditOutlined,
@@ -46,8 +46,8 @@ import {
VideoCameraOutlined,
RobotOutlined,
BulbOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
} from "@ant-design/icons"
import { useNavigate } from "react-router-dom"
import {
getScripts,
createScript,
@@ -58,119 +58,119 @@ import {
aiRewriteScript,
aiGenerateTitles,
REWRITE_STYLE_OPTIONS,
} from "@/api/scripts";
} from "@/api/scripts"
import type {
ScriptItem,
ScriptCategory,
ScriptUpsertRequest,
RewriteStyle,
AiRewriteResponse,
} from "@/api/scripts";
import { SCRIPT_CATEGORY_LABEL } from "@/api/scripts";
import "./scripts.css";
} from "@/api/scripts"
import { SCRIPT_CATEGORY_LABEL } from "@/api/scripts"
import "./scripts.css"
const { TextArea } = Input;
const { Paragraph, Text } = Typography;
const { TextArea } = Input
const { Paragraph, Text } = Typography
const PAGE_SIZE = 12;
const PAGE_SIZE = 12
const CATEGORY_OPTIONS: { value: ScriptCategory | "all"; label: string }[] = [
{ value: "all", label: "全部分类" },
...Object.entries(SCRIPT_CATEGORY_LABEL).map(([value, label]) => ({
value: value as ScriptCategory,
label,
})),
];
]
const FONT_OPTIONS = [
{ value: "default", label: "默认" },
{ value: "bold", label: "粗体" },
{ value: "handwritten", label: "手写" },
{ value: "serif", label: "衬线" },
];
]
const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
] as const;
] as const
/** 提取后端返回的错误 detail(全局拦截器可能已弹 toast,但这里再兜一层) */
function extractErrMsg(err: unknown, fallback: string): string {
const e = err as {
response?: { data?: { detail?: string | { message?: string }; message?: string } };
message?: string;
};
const data = e?.response?.data;
if (data?.detail) {
if (typeof data.detail === "string") return data.detail;
if (typeof data.detail.message === "string") return data.detail.message;
response?: { data?: { detail?: string | { message?: string }; message?: string } }
message?: string
}
if (data?.message && typeof data.message === "string") return data.message;
if (e?.message) return e.message;
return fallback;
const data = e?.response?.data
if (data?.detail) {
if (typeof data.detail === "string") return data.detail
if (typeof data.detail.message === "string") return data.detail.message
}
if (data?.message && typeof data.message === "string") return data.message
if (e?.message) return e.message
return fallback
}
const ScriptLibrary: React.FC = () => {
const navigate = useNavigate();
const navigate = useNavigate()
const [items, setItems] = useState<ScriptItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [keyword, setKeyword] = useState("");
const [category, setCategory] = useState<ScriptCategory | "all">("all");
const [items, setItems] = useState<ScriptItem[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
const [keyword, setKeyword] = useState("")
const [category, setCategory] = useState<ScriptCategory | "all">("all")
// 主弹窗状态
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<ScriptItem | null>(null);
const [submitting, setSubmitting] = useState(false);
const [form] = Form.useForm<ScriptUpsertRequest & { tags_text?: string }>();
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<ScriptItem | null>(null)
const [submitting, setSubmitting] = useState(false)
const [form] = Form.useForm<ScriptUpsertRequest & { tags_text?: string }>()
// ── #1893 AI 能力状态 ──
// 抖音提取
const [douyinModalOpen, setDouyinModalOpen] = useState(false);
const [douyinUrl, setDouyinUrl] = useState("");
const [douyinLoading, setDouyinLoading] = useState(false);
const [douyinModalOpen, setDouyinModalOpen] = useState(false)
const [douyinUrl, setDouyinUrl] = useState("")
const [douyinLoading, setDouyinLoading] = useState(false)
// AI 改写
const [rewriteModalOpen, setRewriteModalOpen] = useState(false);
const [rewriteStyle, setRewriteStyle] = useState<RewriteStyle>("口语化");
const [rewriteLoading, setRewriteLoading] = useState(false);
const [rewriteResult, setRewriteResult] = useState<AiRewriteResponse | null>(null);
const [rewriteModalOpen, setRewriteModalOpen] = useState(false)
const [rewriteStyle, setRewriteStyle] = useState<RewriteStyle>("口语化")
const [rewriteLoading, setRewriteLoading] = useState(false)
const [rewriteResult, setRewriteResult] = useState<AiRewriteResponse | null>(null)
// AI 生成标题
const [titleGenLoading, setTitleGenLoading] = useState(false);
const [titleCandidates, setTitleCandidates] = useState<string[]>([]);
const [titleGenLoading, setTitleGenLoading] = useState(false)
const [titleCandidates, setTitleCandidates] = useState<string[]>([])
const load = useCallback(async () => {
setLoading(true);
setLoading(true)
try {
const res = await getScripts({
page,
page_size: PAGE_SIZE,
keyword: keyword.trim() || undefined,
category,
});
})
if (Array.isArray(res)) {
setItems(res);
setTotal(res.length);
setItems(res)
setTotal(res.length)
} else {
setItems(res.items ?? []);
setTotal(res.total ?? 0);
setItems(res.items ?? [])
setTotal(res.total ?? 0)
}
} catch (err) {
message.error(extractErrMsg(err, "加载文案列表失败"));
message.error(extractErrMsg(err, "加载文案列表失败"))
} finally {
setLoading(false);
setLoading(false)
}
}, [page, keyword, category]);
}, [page, keyword, category])
useEffect(() => {
load();
}, [load]);
load()
}, [load])
const resetCreateForm = () => {
form.resetFields();
form.resetFields()
form.setFieldsValue({
title: "",
content: "",
@@ -187,19 +187,19 @@ const ScriptLibrary: React.FC = () => {
bold: true,
italic: false,
},
});
setRewriteResult(null);
setTitleCandidates([]);
};
})
setRewriteResult(null)
setTitleCandidates([])
}
const openCreate = () => {
setEditing(null);
resetCreateForm();
setModalOpen(true);
};
setEditing(null)
resetCreateForm()
setModalOpen(true)
}
const openEdit = (item: ScriptItem) => {
setEditing(item);
setEditing(item)
form.setFieldsValue({
title: item.title,
content: item.content,
@@ -214,24 +214,24 @@ const ScriptLibrary: React.FC = () => {
position: "center",
size: 48,
},
});
setRewriteResult(null);
setTitleCandidates([]);
setModalOpen(true);
};
})
setRewriteResult(null)
setTitleCandidates([])
setModalOpen(true)
}
const closeModal = () => {
setModalOpen(false);
setEditing(null);
setRewriteResult(null);
setTitleCandidates([]);
};
setModalOpen(false)
setEditing(null)
setRewriteResult(null)
setTitleCandidates([])
}
/** 提交新建/编辑 */
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitting(true);
const values = await form.validateFields()
setSubmitting(true)
const payload: ScriptUpsertRequest = {
title: values.title.trim(),
content: values.content,
@@ -240,67 +240,67 @@ const ScriptLibrary: React.FC = () => {
title_text: values.title_text?.trim() || undefined,
title_category: values.title_category,
title_config: values.title_config,
};
if (editing) {
await updateScript(editing.id, payload);
message.success("文案已更新");
} else {
await createScript(payload);
message.success("文案已创建");
}
closeModal();
await load();
if (editing) {
await updateScript(editing.id, payload)
message.success("文案已更新")
} else {
await createScript(payload)
message.success("文案已创建")
}
closeModal()
await load()
} catch (err) {
if ((err as { errorFields?: unknown })?.errorFields) return;
message.error(extractErrMsg(err, "保存失败"));
if ((err as { errorFields?: unknown })?.errorFields) return
message.error(extractErrMsg(err, "保存失败"))
} finally {
setSubmitting(false);
setSubmitting(false)
}
};
}
const handleDelete = async (id: string) => {
try {
await deleteScript(id);
message.success("文案已删除");
await deleteScript(id)
message.success("文案已删除")
if (items.length === 1 && page > 1) {
setPage(page - 1);
setPage(page - 1)
} else {
await load();
await load()
}
} catch (err) {
message.error(extractErrMsg(err, "删除失败"));
message.error(extractErrMsg(err, "删除失败"))
}
};
}
const handleDuplicate = async (id: string) => {
try {
await duplicateScript(id);
message.success("已复制");
setPage(1);
await load();
await duplicateScript(id)
message.success("已复制")
setPage(1)
await load()
} catch (err) {
message.error(extractErrMsg(err, "复制失败"));
message.error(extractErrMsg(err, "复制失败"))
}
};
}
/** 使用:跳创作页,query 带 scriptId 预填 */
const handleUse = (item: ScriptItem) => {
navigate(`/app/generate?scriptId=${encodeURIComponent(item.id)}`);
};
navigate(`/app/generate?scriptId=${encodeURIComponent(item.id)}`)
}
const preview = (content: string) => {
const text = content.replace(/\s+/g, " ").trim();
if (text.length <= 100) return text || "(空)";
return `${text.slice(0, 100)}…`;
};
const text = content.replace(/\s+/g, " ").trim()
if (text.length <= 100) return text || "(空)"
return `${text.slice(0, 100)}…`
}
const formatTime = (iso?: string) => {
if (!iso) return "-";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
if (!iso) return "-"
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
const pad = (n: number) => String(n).padStart(2, "0")
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
const categoryColor: Record<ScriptCategory, string> = {
promo: "red",
@@ -309,36 +309,36 @@ const ScriptLibrary: React.FC = () => {
story: "purple",
emotion: "magenta",
other: "default",
};
}
// ── #1893 AI 操作 ──
/** 打开抖音提取弹窗 */
const openDouyinModal = () => {
setDouyinUrl("");
setDouyinModalOpen(true);
};
setDouyinUrl("")
setDouyinModalOpen(true)
}
/** 执行抖音提取,成功后打开新建弹窗并预填 content */
const handleDouyinExtract = async () => {
const url = douyinUrl.trim();
const url = douyinUrl.trim()
if (!url) {
message.warning("请粘贴抖音视频链接");
return;
message.warning("请粘贴抖音视频链接")
return
}
if (!/^https?:\/\//i.test(url)) {
message.warning("请输入以 http(s):// 开头的完整链接");
return;
message.warning("请输入以 http(s):// 开头的完整链接")
return
}
setDouyinLoading(true);
setDouyinLoading(true)
try {
const res = await extractScriptFromDouyin({ url });
message.success(`提取成功${res.duration_seconds ? `(时长 ${res.duration_seconds}s)` : ""}`);
setDouyinModalOpen(false);
setDouyinUrl("");
const res = await extractScriptFromDouyin({ url })
message.success(`提取成功${res.duration_seconds ? `(时长 ${res.duration_seconds}s)` : ""}`)
setDouyinModalOpen(false)
setDouyinUrl("")
// 关闭抖音弹窗,打开新建弹窗预填 content
setEditing(null);
resetCreateForm();
setEditing(null)
resetCreateForm()
form.setFieldsValue({
title: "",
content: res.text,
@@ -354,74 +354,74 @@ const ScriptLibrary: React.FC = () => {
bold: true,
italic: false,
},
});
setModalOpen(true);
})
setModalOpen(true)
} catch (err) {
message.error(extractErrMsg(err, "抖音文案提取失败"));
message.error(extractErrMsg(err, "抖音文案提取失败"))
} finally {
setDouyinLoading(false);
setDouyinLoading(false)
}
};
}
/** 执行 AI 改写,结果写入 rewriteResult 让用户对比确认 */
const handleAiRewrite = async () => {
const content = form.getFieldValue("content") as string | undefined;
const content = form.getFieldValue("content") as string | undefined
if (!content || !content.trim()) {
message.warning("请先填写文案正文再改写");
return;
message.warning("请先填写文案正文再改写")
return
}
setRewriteLoading(true);
setRewriteResult(null);
setRewriteLoading(true)
setRewriteResult(null)
try {
const res = await aiRewriteScript({ content, style: rewriteStyle });
setRewriteResult(res);
const res = await aiRewriteScript({ content, style: rewriteStyle })
setRewriteResult(res)
} catch (err) {
message.error(extractErrMsg(err, "AI 改写失败"));
message.error(extractErrMsg(err, "AI 改写失败"))
} finally {
setRewriteLoading(false);
setRewriteLoading(false)
}
};
}
/** 应用改写结果:替换 content 字段,关闭改写弹窗 */
const applyRewrite = () => {
if (!rewriteResult) return;
form.setFieldsValue({ content: rewriteResult.rewritten });
setRewriteResult(null);
setRewriteModalOpen(false);
message.success("已应用改写结果");
};
if (!rewriteResult) return
form.setFieldsValue({ content: rewriteResult.rewritten })
setRewriteResult(null)
setRewriteModalOpen(false)
message.success("已应用改写结果")
}
/** 执行 AI 生成标题,生成候选 */
const handleGenerateTitles = async () => {
const content = form.getFieldValue("content") as string | undefined;
const content = form.getFieldValue("content") as string | undefined
if (!content || !content.trim()) {
message.warning("请先填写文案内容");
return;
message.warning("请先填写文案内容")
return
}
setTitleGenLoading(true);
setTitleCandidates([]);
setTitleGenLoading(true)
setTitleCandidates([])
try {
const res = await aiGenerateTitles({ content, count: 3 });
const res = await aiGenerateTitles({ content, count: 3 })
if (!res.titles || res.titles.length === 0) {
message.info("AI 未返回可用标题,请稍后再试");
return;
message.info("AI 未返回可用标题,请稍后再试")
return
}
setTitleCandidates(res.titles);
setTitleCandidates(res.titles)
} catch (err) {
message.error(extractErrMsg(err, "AI 生成标题失败"));
message.error(extractErrMsg(err, "AI 生成标题失败"))
} finally {
setTitleGenLoading(false);
setTitleGenLoading(false)
}
};
}
/** 点击候选标题直接填入 title 字段 */
const pickTitle = (t: string) => {
form.setFieldsValue({ title: t });
};
form.setFieldsValue({ title: t })
}
// 监听 content 字段,用于禁用生成标题按钮(content 为空时)
const watchedContent = Form.useWatch("content", form);
const contentEmpty = !watchedContent || !String(watchedContent).trim();
const watchedContent = Form.useWatch("content", form)
const contentEmpty = !watchedContent || !String(watchedContent).trim()
return (
<div className="xx-scripts-page">
@@ -434,8 +434,8 @@ const ScriptLibrary: React.FC = () => {
placeholder="搜索标题或正文"
value={keyword}
onChange={(e) => {
setKeyword(e.target.value);
setPage(1);
setKeyword(e.target.value)
setPage(1)
}}
allowClear
style={{ width: 260 }}
@@ -443,8 +443,8 @@ const ScriptLibrary: React.FC = () => {
<Select
value={category}
onChange={(v) => {
setCategory(v);
setPage(1);
setCategory(v)
setPage(1)
}}
style={{ width: 140 }}
options={CATEGORY_OPTIONS}
@@ -683,8 +683,8 @@ const ScriptLibrary: React.FC = () => {
onClick={() => {
// 每次打开重置上一次结果,避免误看旧对比
if (!rewriteLoading) {
setRewriteResult(null);
setRewriteModalOpen(true);
setRewriteResult(null)
setRewriteModalOpen(true)
}
}}
>
@@ -874,7 +874,7 @@ const ScriptLibrary: React.FC = () => {
)}
</Modal>
</div>
);
};
)
}
export default ScriptLibrary;
export default ScriptLibrary